Compare commits

..
Author SHA1 Message Date
Robin Newhouse 8333e1e86b Align basic compaction budget expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse 25fa84c9e6 Align basic sanitization image expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse da35f08f03 Clarify basic projection budget logging 2026-07-07 13:23:54 -07:00
Robin Newhouse 02bc1bc432 Budget basic compaction projections 2026-07-07 13:23:54 -07:00
Robin Newhouse 49c0d1b6a3 Align agentic file ops with projected input 2026-07-07 13:23:54 -07:00
Robin Newhouse 92dc5dfed3 Align agentic compaction test tool result 2026-07-07 13:23:53 -07:00
Robin Newhouse ce85e49c7b Harden agentic summary budget fallback 2026-07-07 13:23:53 -07:00
Robin Newhouse dfecadbcbd Budget agentic compaction summary input 2026-07-07 13:23:53 -07:00
Robin Newhouse 519a22c5d5 Fix budget projection action accounting 2026-07-07 13:23:53 -07:00
Robin Newhouse fbdfa77bb9 fix(core): narrow compaction protected tail 2026-07-07 13:23:53 -07:00
Robin Newhouse 5ad8d33977 Clean up budget projection fixture indentation 2026-07-07 13:23:53 -07:00
Robin Newhouse 5226b107ba Align budget projection test tool results 2026-07-07 13:23:53 -07:00
Robin Newhouse 26f015fbd1 Recompute protected tail after thinking pruning 2026-07-07 13:23:52 -07:00
Robin Newhouse bdce31deea Drop provider-native blocks during budget projection 2026-07-07 13:23:52 -07:00
Robin Newhouse 406674d27f Fix budget projection truncation accounting 2026-07-07 13:23:52 -07:00
Robin Newhouse 24303ab0cb Add pure compaction budget projection engine 2026-07-07 13:23:52 -07:00
Robin Newhouse f48ba92357 Tighten dropped block action paths 2026-07-07 13:23:52 -07:00
Robin Newhouse 3693d2f867 Tighten budget projection contract types 2026-07-07 13:23:52 -07:00
Robin Newhouse 86aca36d03 Add compaction budget projection contract 2026-07-07 13:23:52 -07:00
94 changed files with 4545 additions and 11099 deletions
-7
View File
@@ -85,10 +85,3 @@ apps/vscode/webview-ui/src/**/*.js.map
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.maxInputTokens).toBe(383_616);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -11,7 +11,6 @@ import {
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
@@ -104,9 +103,7 @@ export async function handleDesktopCommand(
): Promise<unknown> {
if (command === "list_provider_catalog") {
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
return await listLocalProviders(providerSettingsManager);
}
if (command === "list_provider_models") {
const provider = String(args?.provider ?? "").trim();
@@ -168,11 +165,6 @@ export async function handleDesktopCommand(
providerId,
openExternalUrl,
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(providerSettingsManager, providerId, {
tokenSource: "oauth",
});
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
+1 -9
View File
@@ -5,7 +5,6 @@ import {
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
saveLocalProviderSettings,
} from "@cline/core";
@@ -100,9 +99,7 @@ export async function sendProviderCatalog(
peer: BrowserPeer,
): Promise<void> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
const payload = await listLocalProviders(providerSettingsManager);
ctx.send(peer, {
type: "provider_catalog",
providers: payload.providers,
@@ -141,11 +138,6 @@ export async function runProviderOAuthLogin(
normalized,
openExternalUrl,
);
if (saved.provider !== normalized) {
markLocalProviderEnabled(providerSettingsManager, normalized, {
tokenSource: "oauth",
});
}
ctx.send(peer, {
type: "provider_oauth_login_done",
providerId: normalized,
@@ -258,8 +258,8 @@ export function SettingsView({
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -386,7 +386,7 @@ export function SettingsView({
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
usesOAuth(selectedProvider)
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
@@ -46,7 +46,6 @@ export interface Provider {
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
capabilities?: string[];
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
-43
View File
@@ -13,51 +13,8 @@ From `apps/examples/desktop-app/`:
- `bun run build:sidecar` - build the Bun sidecar bundle
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
- `bun run build:binary` - build desktop binary
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Shareable Desktop Packages
Tauri desktop bundles are OS-specific, so build each package on the target OS:
- macOS: `bun run package:desktop:mac`
- Windows: `bun run package:desktop:windows`
- Linux: `bun run package:desktop:linux`
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
### macOS signing & notarization, step by step
One-time keychain setup:
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
Per-build:
```bash
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
export APPLE_API_ISSUER="<issuer UUID>"
bun run package:desktop:mac
```
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 210 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
## Runtime Overview
Startup flow:
+1 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.1",
"version": "0.0.0",
"private": true,
"scripts": {
"dev:web": "next dev webview -p 3125 --turbo",
@@ -10,11 +10,6 @@
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -1,311 +0,0 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
type DesktopPlatform = "mac" | "windows" | "linux";
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
const VALUE_FLAGS = new Set(["--platform", "--target"]);
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
const APP_NAME = "Cline Code";
const APP_ROOT = path.resolve(import.meta.dir, "..");
const BUNDLE_ROOT = path.join(
APP_ROOT,
"src-tauri",
"target",
"release",
"bundle",
);
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
process.chdir(APP_ROOT);
const validateArgs = (): void => {
const args = process.argv.slice(2);
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for ${arg}`);
}
index += 1;
continue;
}
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
continue;
}
if (arg.startsWith("--")) {
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
throw new Error(
suggestion
? `unknown option ${arg}. Did you mean ${suggestion}?`
: `unknown option ${arg}`,
);
}
throw new Error(`unexpected argument ${arg}`);
}
};
const getArgValue = (name: string): string | undefined => {
const prefix = `${name}=`;
const inline = process.argv.find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = process.argv.indexOf(name);
if (index >= 0) {
return process.argv[index + 1];
}
return undefined;
};
const hasArg = (name: string): boolean => process.argv.includes(name);
const hostPlatform = (): DesktopPlatform => {
if (process.platform === "darwin") {
return "mac";
}
if (process.platform === "win32") {
return "windows";
}
if (process.platform === "linux") {
return "linux";
}
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
};
const resolveRequestedPlatform = (): DesktopPlatform => {
const platform =
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
if (platform === "current") {
return hostPlatform();
}
if (platform === "mac" || platform === "windows" || platform === "linux") {
return platform;
}
throw new Error(
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
);
};
const sanitizeName = (value: string): string =>
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
const packageVersion = async (): Promise<string> => {
const packageJson = await Bun.file(
path.join(APP_ROOT, "package.json"),
).json();
return String(packageJson.version ?? "0.0.0");
};
const macDistributionCredentialsConfigured = (): boolean => {
const hasCertificate = Boolean(
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
);
const hasAppleIdNotarization = Boolean(
process.env.APPLE_ID &&
process.env.APPLE_PASSWORD &&
process.env.APPLE_TEAM_ID,
);
const hasApiKeyNotarization = Boolean(
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER,
);
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
};
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
const host = hostPlatform();
if (platform !== host) {
throw new Error(
[
`cannot build ${platform} desktop bundles from ${host}.`,
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
].join("\n"),
);
}
};
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
if (hostPlatform() !== "mac") {
return;
}
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
return;
}
throw new Error(
[
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
].join("\n"),
);
};
const walkFiles = (root: string): string[] => {
if (!existsSync(root)) {
return [];
}
const paths: string[] = [];
for (const entry of readdirSync(root)) {
const fullPath = path.join(root, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
paths.push(...walkFiles(fullPath));
continue;
}
paths.push(fullPath);
}
return paths;
};
const copyArtifact = (source: string, outputName: string): string => {
const destination = path.join(PACKAGE_ROOT, outputName);
rmSync(destination, { force: true, recursive: true });
cpSync(source, destination, { recursive: true });
return destination;
};
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --force --deep --sign - ${appPath}`;
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const verifySignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`spctl --assess --type execute --verbose ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const collectMacArtifacts = async (
version: string,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
if (!existsSync(appPath)) {
throw new Error(`macOS app bundle was not created at ${appPath}`);
}
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
console.warn(
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
);
await signUnsignedMacApp(appPath);
} else {
await verifySignedMacApp(appPath);
}
const arch = process.arch === "arm64" ? "arm64" : "x64";
const suffix =
allowUnsignedMac && !macDistributionCredentialsConfigured()
? "-local-unsigned"
: "";
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
const zipPath = path.join(PACKAGE_ROOT, zipName);
rmSync(zipPath, { force: true });
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
const artifacts = [zipPath];
if (!suffix) {
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
(file) => file.endsWith(".dmg"),
)) {
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
}
}
return artifacts;
};
const collectWindowsArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
.map((file) => copyArtifact(file, path.basename(file)));
const collectLinuxArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter(
(file) =>
file.endsWith(".AppImage") ||
file.endsWith(".deb") ||
file.endsWith(".rpm"),
)
.map((file) => copyArtifact(file, path.basename(file)));
const collectArtifacts = async (
platform: DesktopPlatform,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const version = await packageVersion();
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
mkdirSync(PACKAGE_ROOT, { recursive: true });
if (platform === "mac") {
return collectMacArtifacts(version, allowUnsignedMac);
}
if (platform === "windows") {
return collectWindowsArtifacts();
}
return collectLinuxArtifacts();
};
const main = async () => {
validateArgs();
const platform = resolveRequestedPlatform();
const allowUnsignedMac =
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
const skipBuild = hasArg("--skip-build");
assertCanBuildPlatform(platform);
if (platform === "mac") {
assertMacDistributionReady(allowUnsignedMac);
}
if (!skipBuild) {
await $`bun run build:binary`;
}
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
if (artifacts.length === 0) {
throw new Error(
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
);
}
console.log(`Packaged ${platform} desktop artifacts:`);
for (const artifact of artifacts) {
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
}
};
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
@@ -1,51 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSessionConnectionUpdate } from "./chat-session";
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
});
expect(Object.hasOwn(update, "thinking")).toBe(false);
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
it("clears reasoning settings when thinking is explicitly disabled", () => {
expect(
buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: false,
}),
).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: false,
reasoningEffort: null,
thinkingBudgetTokens: null,
});
});
it("updates explicit reasoning settings without clearing omitted settings", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
reasoningEffort: "high",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
});
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
});
@@ -20,10 +20,6 @@ import type {
SidecarContext,
} from "./types";
type SessionConnectionUpdate = Parameters<
ClineCore["updateSessionConnection"]
>[1];
// ---------------------------------------------------------------------------
// Session data helpers
// ---------------------------------------------------------------------------
@@ -107,40 +103,7 @@ function isoTimestampToMs(
return Number.isFinite(parsed) ? parsed : undefined;
}
function readReasoningEffort(
value: unknown,
): "low" | "medium" | "high" | "xhigh" | undefined {
if (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
) {
return value;
}
return undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
return undefined;
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
thinking === false
? undefined
: readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens =
thinking === false
? undefined
: readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return {
sessionId: config.sessionId ?? config.session_id,
providerId: config.provider ?? config.providerId ?? "",
@@ -162,9 +125,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
config.enableAgentTeams ??
config.enable_teams ??
false,
...(thinking !== undefined ? { thinking } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
teamName: config.teamName ?? config.team_name,
missionLogIntervalSteps:
config.missionStepInterval ?? config.missionLogIntervalSteps,
@@ -176,63 +136,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
};
}
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
const updates: SessionConnectionUpdate = {};
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (providerId) {
updates.providerId = providerId;
}
const modelId = String(config.model ?? config.modelId ?? "").trim();
if (modelId) {
updates.modelId = modelId;
}
const apiKey =
typeof config.apiKey === "string"
? config.apiKey.trim()
: typeof config.api_key === "string"
? config.api_key.trim()
: undefined;
if (apiKey) {
updates.apiKey = apiKey;
}
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
updates.baseUrl = config.baseUrl.trim();
}
if (config.headers && typeof config.headers === "object") {
updates.headers = config.headers as Record<string, string>;
}
if (config.providerConfig && typeof config.providerConfig === "object") {
updates.providerConfig =
config.providerConfig as SessionConnectionUpdate["providerConfig"];
}
if (thinking === false) {
updates.thinking = false;
updates.reasoningEffort = null;
updates.thinkingBudgetTokens = null;
return updates;
}
if (thinking === true) {
updates.thinking = true;
}
if (reasoningEffort) {
updates.thinking = true;
updates.reasoningEffort = reasoningEffort;
}
if (thinkingBudgetTokens !== undefined) {
updates.thinking = true;
updates.thinkingBudgetTokens = thinkingBudgetTokens;
}
return updates;
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -460,13 +363,6 @@ async function handleSend(
if (!prompt) throw new Error("prompt is required");
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
if (session) {
session.config = { ...session.config, ...request.config };
}
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
+1 -68
View File
@@ -25,7 +25,6 @@ import {
listLocalProviders,
listPluginTools,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
readGlobalSettings,
@@ -36,26 +35,13 @@ import {
SqliteSessionStore,
saveLocalProviderSettings,
sendHubCommand,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
findArtifactUnderDir,
readSessionManifest,
@@ -959,7 +945,7 @@ export async function handleCommand(
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await listLocalProviders(manager, { isClinePassEnabled: true });
return await listLocalProviders(manager);
}
if (command === "list_provider_models") {
const manager = new ProviderSettingsManager();
@@ -1039,45 +1025,12 @@ export async function handleCommand(
spawned.unref();
},
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
};
}
// ── Global settings ────────────────────────────────────────────────
if (command === "get_global_settings") {
return readGlobalSettings();
}
if (command === "set_telemetry_opt_out") {
if (typeof args?.telemetry_opt_out !== "boolean") {
throw new Error("telemetry_opt_out must be a boolean");
}
setTelemetryOptOutGlobally(args.telemetry_opt_out);
return readGlobalSettings();
}
if (command === "set_auto_update_enabled") {
if (typeof args?.auto_update_enabled !== "boolean") {
throw new Error("auto_update_enabled must be a boolean");
}
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
}
if (command === "start_connector_channel") {
return await startConnectorChannel(ctx.workspaceRoot, args);
}
if (command === "stop_connector_channel") {
return await stopConnectorChannel(ctx.workspaceRoot, args);
}
// ── MCP server management ─────────────────────────────────────────
if (command === "list_mcp_servers") {
return readMcpServersResponse();
@@ -1203,26 +1156,6 @@ export async function handleCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(ctx.workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(ctx.workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, {
workspaceRoot: ctx.workspaceRoot,
});
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) {
@@ -1,307 +0,0 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename, join, normalize } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
import type { JsonRecord } from "./types";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: {
flag: string;
equals?: string;
notEquals?: string;
};
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type WebviewConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type WebviewConnectorChannelsResponse = {
available: WebviewConnectorChannel[];
active: ReturnType<typeof listActiveConnectors>;
};
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function asRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
workspaceRoot: string,
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath =
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
type: platform.type,
hint: platform.hint,
fields: platform.fields.map((field) => ({
flag: field.flag,
label: field.label,
placeholder: field.placeholder,
required: field.required,
help: field.help,
initialValue: field.initialValue,
options: field.options,
includeWhen: field.includeWhen,
})),
security: platform.security
? {
prompt: platform.security.prompt,
fields: platform.security.fields.map((field) => ({
key: field.key,
label: field.label,
placeholder: field.placeholder,
help: field.help,
requiredMessage: field.requiredMessage,
})),
}
: undefined,
}));
return { available, active: listActiveConnectors() };
}
async function runCliConnectCommand(
workspaceRoot: string,
args: string[],
): Promise<{
code: number;
stdout: string;
stderr: string;
}> {
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const code = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (exitCode) => resolve(exitCode ?? 0));
});
return { code, stdout, stderr };
}
async function waitForConnectorState(
predicate: () => boolean,
timeoutMs = 5_000,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`connector did not reach expected state within ${timeoutMs}ms`,
);
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
const rawValue = values[field.flag];
if (typeof rawValue === "string") {
fieldValues[field.flag] = rawValue.trim();
} else if (field.initialValue) {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
}
return cliArgs;
}
export async function startConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
export async function stopConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
@@ -4,9 +4,6 @@ import type { SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -17,18 +14,7 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
subscribe = subscribeMock;
dispose = vi.fn();
@@ -53,20 +39,8 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -83,15 +57,6 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -102,20 +67,11 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar-approvals",
}),
);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
@@ -192,8 +148,6 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -5,13 +5,10 @@ import { dirname } from "node:path";
import {
type AgentToolContext,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
@@ -389,7 +386,6 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
unsubscribeSessionEvents: null,
};
@@ -434,12 +430,6 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -692,17 +682,10 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(`code-sidecar:${process.pid}:${randomUUID()}`),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -720,7 +703,6 @@ export async function initializeSessionManager(
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
@@ -734,6 +716,5 @@ export async function initializeSessionManager(
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
@@ -1,998 +0,0 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir as osHomedir, platform } from "node:os";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
type MarketplaceInstallInput = {
id: string;
type: MarketplacePrimitiveType;
name?: string;
install: {
args?: string[];
env?: MarketplaceEnvVar[];
command?: string;
notes?: string;
};
};
type MarketplaceInstallResult = {
id: string;
type: LocalPrimitiveType;
status: "installed" | "uninstalled";
message: string;
details?: JsonRecord;
output?: string;
};
type MarketplaceInstallStatusResult = {
installedKeys: string[];
};
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
type SpawnCommand = (
command: string,
args: string[],
options?: SpawnOptions,
) => Promise<SpawnResult>;
type CatalogFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
type CatalogLoader = () => Promise<unknown>;
const MAX_OUTPUT_CHARS = 12_000;
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
const MARKETPLACE_CATALOG_URL =
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
"https://cline.github.io/marketplace/catalog.json";
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
const SECRET_KEY_VALUE_PATTERN =
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
const SECRET_BEARER_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
const SECRET_AUTHORIZATION_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
export async function fetchMarketplaceCatalog(
fetchImpl: CatalogFetch = fetch,
): Promise<unknown> {
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
);
}
return response.json();
}
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function readInstallInput(
args?: Record<string, unknown>,
): MarketplaceInstallInput {
const entry = readInstallRecord(args);
const install =
entry.install && typeof entry.install === "object"
? (entry.install as Record<string, unknown>)
: {};
const installArgs = toStringArray(install.args);
if (installArgs.length === 0) {
throw new Error("marketplace install args are required");
}
const env = Array.isArray(install.env)
? install.env
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null)
: undefined;
return {
id: entry.id.trim(),
type: entry.type,
name: typeof entry.name === "string" ? entry.name : undefined,
install: {
args: installArgs,
command:
typeof install.command === "string" ? install.command : undefined,
env,
notes: typeof install.notes === "string" ? install.notes : undefined,
},
};
}
function readInstallRecord(
args?: Record<string, unknown>,
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
const entry =
args?.entry && typeof args.entry === "object"
? (args.entry as Record<string, unknown>)
: (args ?? {});
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
throw new Error("marketplace entry id is required");
}
if (!isPrimitiveType(entry.type)) {
throw new Error("marketplace entry type must be mcp, skill, or plugin");
}
return entry as Record<string, unknown> & {
id: string;
type: MarketplacePrimitiveType;
};
}
function readInstallRequest(args?: Record<string, unknown>) {
const entry = readInstallRecord(args);
return {
id: entry.id.trim(),
type: entry.type,
};
}
function readLocalUninstallInput(args?: Record<string, unknown>): {
id: string;
type: LocalPrimitiveType;
name?: string;
path?: string;
} {
const type = typeof args?.type === "string" ? args.type.trim() : "";
if (
type !== "mcp" &&
type !== "skill" &&
type !== "workflow" &&
type !== "plugin"
) {
throw new Error(
"local uninstall type must be mcp, skill, workflow, or plugin",
);
}
const id =
typeof args?.id === "string" && args.id.trim().length > 0
? args.id.trim()
: typeof args?.name === "string" && args.name.trim().length > 0
? args.name.trim()
: typeof args?.path === "string" && args.path.trim().length > 0
? args.path.trim()
: "";
if (!id) {
throw new Error("local uninstall id, name, or path is required");
}
return {
id,
type,
name: typeof args?.name === "string" ? args.name.trim() : undefined,
path: typeof args?.path === "string" ? args.path.trim() : undefined,
};
}
function readInstallInputList(
args?: Record<string, unknown>,
): MarketplaceInstallInput[] {
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
return rawEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
const catalogEntries =
catalog && typeof catalog === "object"
? (catalog as Record<string, unknown>).entries
: undefined;
if (!Array.isArray(catalogEntries)) {
throw new Error("marketplace catalog entries are required");
}
return catalogEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function marketplaceEntryKey(
entry: Pick<MarketplaceInstallInput, "id" | "type">,
) {
return `${entry.type}:${entry.id}`;
}
function redactOutput(value: string): string {
const lines = value.split(/\r?\n/).map((line) => {
if (!SECRET_PATTERN.test(line)) return line;
return line
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
"$1[redacted]",
);
});
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
}
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
new Promise<SpawnResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
const child = spawn(command, args, {
...options,
env: options.env ?? process.env,
shell: options.shell ?? platform() === "win32",
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
const forceKillTimeout = setTimeout(() => {
if (!settled) {
child.kill("SIGKILL");
}
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
const timeout = setTimeout(() => {
timedOut = true;
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
child.kill("SIGTERM");
}, INSTALL_COMMAND_TIMEOUT_MS);
forceKillTimeout.unref?.();
timeout.unref?.();
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
}
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
}
});
child.once("error", (error) => {
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
reject(error);
});
child.once("close", (code, signal) => {
settled = true;
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
const result = {
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
stdout,
stderr,
};
resolve(result);
});
});
function normalizeTransport(value: string | undefined): string {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertUrl(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Invalid MCP server URL: ${value}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid MCP server URL: ${value}`);
}
}
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
const [rawName, ...rest] = args;
const name = rawName?.trim();
if (!name) {
throw new Error("MCP marketplace install requires a server name");
}
let transportType = "stdio";
const headers: Record<string, string> = {};
const targetArgs: string[] = [];
let parsingMarketplaceOptions = true;
for (let index = 0; index < rest.length; index++) {
const arg = rest[index];
if (parsingMarketplaceOptions && arg === "--") {
targetArgs.push(...rest.slice(index + 1));
break;
}
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
const next = rest[index + 1]?.trim();
if (!next) throw new Error("--transport requires a value");
transportType = normalizeTransport(next);
index++;
continue;
}
const shouldParseHeader =
parsingMarketplaceOptions ||
normalizeTransport(transportType) !== "stdio";
if (
shouldParseHeader &&
(arg === "--header" || arg?.startsWith("--header="))
) {
const rawHeader =
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
if (!rawHeader) throw new Error("--header requires a value");
const separatorIndex = rawHeader.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
const headerName = rawHeader.slice(0, separatorIndex).trim();
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
if (!headerName || !headerValue) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
headers[headerName] = headerValue;
continue;
}
parsingMarketplaceOptions = false;
targetArgs.push(arg);
}
transportType = normalizeTransport(transportType);
if (transportType === "stdio") {
if (Object.keys(headers).length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...commandArgs] = targetArgs;
if (!command?.trim()) {
throw new Error("Stdio MCP install requires a command");
}
return {
name,
transportType,
command,
args: commandArgs.length > 0 ? commandArgs : undefined,
disabled: false,
};
}
if (targetArgs.length !== 1) {
throw new Error("Remote MCP install requires exactly one URL");
}
const url = targetArgs[0]?.trim() ?? "";
assertUrl(url);
return {
name,
transportType,
url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
disabled: false,
};
}
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
return { command: wrapperPath, argsPrefix: [] };
}
const entry = process.argv[1]?.trim();
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
return { command: process.execPath, argsPrefix: [entry] };
}
return { command: "cline", argsPrefix: [] };
}
function isInsidePath(childPath: string, parentPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function resolveUserInstructionRemovalTarget(input: {
type: "skill" | "workflow";
path: string;
workspaceRoot?: string;
}): string {
const filePath = resolve(input.path);
const searchPaths =
input.type === "skill"
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
const containingRoot = searchPaths.find((root) =>
isInsidePath(filePath, root),
);
if (!containingRoot) {
throw new Error(
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
);
}
const stats = statSync(filePath, { throwIfNoEntry: false });
if (!stats?.isFile()) {
throw new Error(`${input.type} file does not exist: ${filePath}`);
}
if (input.type === "workflow") {
return filePath;
}
const skillDir = dirname(filePath);
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
}
export async function uninstallLocalPrimitive(
args?: Record<string, unknown>,
options: { workspaceRoot?: string } = {},
): Promise<MarketplaceInstallResult> {
const input = readLocalUninstallInput(args);
if (input.type === "mcp") {
const name = input.name ?? input.id;
const response = deleteMcpServer(name);
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
details: { mcp: response },
};
}
if (input.type === "plugin") {
const result = await uninstallLocalPlugin({
name: input.path ? undefined : (input.name ?? input.id),
path: input.path,
workspaceRoot: options.workspaceRoot,
});
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
details: result as unknown as JsonRecord,
};
}
if (input.type === "skill" || input.type === "workflow") {
if (!input.path) {
throw new Error(`${input.type} uninstall requires a path.`);
}
const target = resolveUserInstructionRemovalTarget({
type: input.type,
path: input.path,
workspaceRoot: options.workspaceRoot,
});
const stats = statSync(target, { throwIfNoEntry: false });
if (!stats) {
throw new Error(`${input.type} target does not exist: ${target}`);
}
rmSync(target, { recursive: stats.isDirectory(), force: true });
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${input.name ?? basename(target)}.`,
details: { path: target },
};
}
throw new Error(`Unsupported local uninstall type: ${input.type}`);
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
function sanitizeSkillSegment(value: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z0-9._]+/g, "-")
.replace(/^[.-]+|[.-]+$/g, "")
.slice(0, 255);
return sanitized || "skill";
}
function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function getOfficialPluginInstallPath(source: string): string | undefined {
const slug = source.trim();
if (!isOfficialPluginSlug(slug)) return undefined;
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
return join(
resolveClineDir(),
"plugins",
"_installed",
"official",
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
);
}
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "plugin") return false;
const [source] = entry.install.args ?? [];
if (!source) return false;
const installPath = getOfficialPluginInstallPath(source);
return Boolean(installPath && existsSync(installPath));
}
function resolveHomeDir(): string {
return (
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
);
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
const candidates = new Set<string>();
const addCandidate = (value: string | undefined) => {
const normalized = sanitizeSkillSegment(value ?? "");
if (normalized && normalized !== "skill") {
candidates.add(normalized);
}
};
addCandidate(entry.id);
addCandidate(entry.name);
const installArgs = entry.install.args ?? [];
for (let index = 0; index < installArgs.length; index++) {
const arg = installArgs[index];
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
addCandidate(installArgs[index + 1]);
index++;
continue;
}
const skillFilter = arg.split("@").at(1);
if (skillFilter) {
addCandidate(skillFilter);
}
}
return [...candidates];
}
function getGlobalSkillPaths(skillName: string): string[] {
return [
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
].filter((path, index, paths) => paths.indexOf(path) === index);
}
function ensureGlobalSkillsDirWritable(): void {
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
try {
mkdirSync(skillsDir, { recursive: true });
const probePath = join(
skillsDir,
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
);
writeFileSync(probePath, "", { flag: "wx" });
unlinkSync(probePath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
);
}
}
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
return findInstalledGlobalSkillName(entry) !== undefined;
}
function findInstalledGlobalSkillName(
entry: MarketplaceInstallInput,
): string | undefined {
if (entry.type !== "skill") return undefined;
const candidates = getSkillInstallCandidates(entry);
return candidates.find((candidate) =>
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
);
}
function hasMatchingInventoryItem(
items: unknown,
entry: MarketplaceInstallInput,
): boolean {
if (!Array.isArray(items)) return false;
const candidates = new Set([
normalizeMatchValue(entry.id),
normalizeMatchValue(entry.name),
...(entry.install.args ?? []).map(normalizeMatchValue),
]);
candidates.delete("");
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
typeof record.path === "string" ? record.path : undefined,
]
.map(normalizeMatchValue)
.filter(Boolean);
return values.some((value) => candidates.has(value));
});
}
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "mcp") return false;
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = readMcpServersResponse();
const servers = Array.isArray(response.servers) ? response.servers : [];
return servers.some((server) => {
if (!server || typeof server !== "object") return false;
const record = server as JsonRecord;
return record.name === input.name;
});
}
function isMarketplaceEntryInstalled(
entry: MarketplaceInstallInput,
inventory?: JsonRecord,
): boolean {
try {
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
if (entry.type === "plugin") {
return (
isOfficialPluginInstalled(entry) ||
hasMatchingInventoryItem(inventory?.plugins, entry)
);
}
if (entry.type === "skill") {
return isGlobalSkillInstalled(entry);
}
return false;
} catch {
return false;
}
}
function commandOutput(result: SpawnResult): string | undefined {
const output = redactOutput(
[result.stdout, result.stderr].filter(Boolean).join("\n"),
);
return output.trim().length > 0 ? output.trim() : undefined;
}
async function installSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
if (isGlobalSkillInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"add",
...(entry.install.args ?? []),
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (/\bFailed to install\b/i.test(output ?? "")) {
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
}
if (!isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
if (installArgs.length !== 1) {
throw new Error(
"Plugin marketplace installs currently support exactly one source argument.",
);
}
if (isOfficialPluginInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"install",
installArgs[0] ?? "",
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
if (entry.type === "skill") {
return installSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return installPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function uninstallMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
}
export async function installMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return installMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export async function uninstallMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return uninstallMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export function listMarketplaceInstalledEntries(
args?: Record<string, unknown>,
inventory?: JsonRecord,
): MarketplaceInstallStatusResult {
const entries = readInstallInputList(args);
const installedKeys = entries
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
.map(marketplaceEntryKey);
return { installedKeys };
}
export async function installMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return installMarketplaceEntryFromCatalog(args, options);
}
export async function uninstallMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return uninstallMarketplaceEntryFromCatalog(args, options);
}
-154
View File
@@ -1,154 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { updateMcpSettingsFileSync } from "@cline/core";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
export function readMcpServersResponse(): JsonRecord {
const settingsPath = resolveMcpSettingsPath();
if (!existsSync(settingsPath)) {
return { settingsPath, hasSettingsFile: false, servers: [] };
}
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
const servers = parsed.mcpServers as JsonRecord | undefined;
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
const record = body as JsonRecord;
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const transportType = String(
transport?.type ?? record.transportType ?? record.type ?? "stdio",
).trim();
return {
name,
transportType,
disabled: record.disabled === true,
command:
typeof transport?.command === "string"
? transport.command
: typeof record.command === "string"
? record.command
: undefined,
args: Array.isArray(transport?.args)
? transport.args
: Array.isArray(record.args)
? record.args
: undefined,
cwd:
typeof transport?.cwd === "string"
? transport.cwd
: typeof record.cwd === "string"
? record.cwd
: undefined,
env:
transport?.env && typeof transport.env === "object"
? transport.env
: record.env && typeof record.env === "object"
? record.env
: undefined,
url:
typeof transport?.url === "string"
? transport.url
: typeof record.url === "string"
? record.url
: undefined,
headers:
transport?.headers && typeof transport.headers === "object"
? transport.headers
: record.headers && typeof record.headers === "object"
? record.headers
: undefined,
metadata: record.metadata,
};
});
return { settingsPath, hasSettingsFile: true, servers: entries };
}
export function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
}
export function ensureMcpSettingsFile(): string {
const path = resolveMcpSettingsPath();
if (!existsSync(path)) {
writeMcpServersMap({});
}
return path;
}
export function setMcpServerDisabled(
name: string,
disabled: boolean,
): JsonRecord {
// Hold the cross-process lock across read-modify-write so a concurrent writer
// (the extension, the CLI) cannot clobber this change.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function upsertMcpServer(input: JsonRecord): JsonRecord {
const name = String(input.name ?? "").trim();
if (!name) throw new Error("server name is required");
const previousName = String(
input.previousName ?? input.previous_name ?? "",
).trim();
const transportType = String(
input.transportType ?? input.transport_type ?? "",
).trim();
const next: JsonRecord =
transportType === "stdio"
? {
transport: {
type: "stdio",
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
},
disabled: input.disabled === true,
}
: {
transport: {
type: transportType === "sse" ? "sse" : "streamableHttp",
url: input.url,
headers: input.headers,
},
disabled: input.disabled === true,
};
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot clobber this upsert.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function deleteMcpServer(name: string): JsonRecord {
if (!name) throw new Error("server name is required");
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot resurrect the deleted server from a stale snapshot.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
delete servers[name];
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
@@ -1,84 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createFetchHandler } from "./server";
import type { SidecarContext } from "./types";
function createTestServer() {
return {
port: 3126,
upgrade: vi.fn(() => true),
};
}
function createHandler(onShutdown = vi.fn()) {
return createFetchHandler({} as SidecarContext, onShutdown);
}
describe("sidecar HTTP origin checks", () => {
it("rejects cross-origin shutdown preflight requests", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/shutdown", {
method: "OPTIONS",
headers: {
origin: "https://attacker.example",
"access-control-request-method": "POST",
},
}),
server,
);
expect(response?.status).toBe(403);
expect(response?.headers.get("access-control-allow-origin")).toBeNull();
});
it("rejects cross-origin shutdown POST requests", async () => {
const onShutdown = vi.fn();
const server = createTestServer();
const response = await createHandler(onShutdown)(
new Request("http://127.0.0.1:3126/shutdown", {
method: "POST",
headers: {
origin: "https://attacker.example",
},
}),
server,
);
expect(response?.status).toBe(403);
expect(onShutdown).not.toHaveBeenCalled();
});
it("rejects cross-origin websocket upgrades", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/transport", {
headers: {
origin: "https://attacker.example",
},
}),
server,
);
expect(response?.status).toBe(404);
expect(server.upgrade).not.toHaveBeenCalled();
});
it("allows desktop webview origins in preflight responses", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/api/marketplace/catalog", {
method: "OPTIONS",
headers: {
origin: "tauri://localhost",
"access-control-request-method": "GET",
},
}),
server,
);
expect(response?.status).toBe(204);
expect(response?.headers.get("access-control-allow-origin")).toBe(
"tauri://localhost",
);
});
});
+4 -102
View File
@@ -1,7 +1,6 @@
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { handleCommand } from "./commands";
import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import {
BunRuntime,
SIDECAR_MODE,
@@ -15,49 +14,6 @@ type SidecarServer = {
upgrade(req: Request): boolean;
};
const TRUSTED_BROWSER_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
"http://localhost:3125",
"http://127.0.0.1:3125",
]);
const JSON_HEADERS = {
"content-type": "application/json",
};
function readOrigin(req: Request): string | undefined {
const origin = req.headers.get("origin")?.trim();
return origin ? origin : undefined;
}
function isTrustedRequestOrigin(req: Request): boolean {
const origin = readOrigin(req);
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
}
function corsHeaders(req: Request): Record<string, string> {
const origin = readOrigin(req);
return {
"access-control-allow-headers": "accept, content-type",
"access-control-allow-methods": "GET, POST, OPTIONS",
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
? {
"access-control-allow-origin": origin,
vary: "Origin",
}
: {}),
};
}
function jsonHeaders(req: Request): Record<string, string> {
return {
...JSON_HEADERS,
...corsHeaders(req),
};
}
// ---------------------------------------------------------------------------
// JSON response helper
// ---------------------------------------------------------------------------
@@ -71,29 +27,6 @@ function jsonResponse(
return JSON.stringify({ type: "response", id, ok, result, error });
}
function createJsonResponse(
req: Request,
body: unknown,
status = 200,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: jsonHeaders(req),
});
}
const EMPTY_MARKETPLACE_CATALOG = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
// ---------------------------------------------------------------------------
// Bun HTTP + WebSocket server
// ---------------------------------------------------------------------------
@@ -133,20 +66,13 @@ export function startServer(
return { port: server.port };
}
export function createFetchHandler(
function createFetchHandler(
_ctx: SidecarContext,
onShutdown?: (reason?: string) => Promise<void>,
) {
return async (req: Request, server: SidecarServer) => {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
if (!isTrustedRequestOrigin(req)) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders(req) });
}
if (url.pathname === "/health") {
return new Response(
JSON.stringify({
@@ -154,39 +80,15 @@ export function createFetchHandler(
mode: SIDECAR_MODE,
pid: process.pid,
}),
{ headers: jsonHeaders(req) },
{ headers: { "content-type": "application/json" } },
);
}
if (
url.pathname === "/transport" &&
isTrustedRequestOrigin(req) &&
server.upgrade(req)
) {
if (url.pathname === "/transport" && server.upgrade(req)) {
return undefined;
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(req, await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(req, {
...EMPTY_MARKETPLACE_CATALOG,
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
});
}
}
if (url.pathname === "/shutdown" && req.method === "POST") {
if (!isTrustedRequestOrigin(req)) {
return new Response(JSON.stringify({ ok: false }), {
status: 403,
headers: jsonHeaders(req),
});
}
queueMicrotask(() => {
void onShutdown?.("code_sidecar_shutdown_endpoint")
.catch((error) => {
@@ -199,7 +101,7 @@ export function createFetchHandler(
.finally(() => process.exit(0));
});
return new Response(JSON.stringify({ ok: true }), {
headers: jsonHeaders(req),
headers: { "content-type": "application/json" },
});
}
@@ -1,7 +1,6 @@
import type {
AgentToolContext,
ClineCore,
HubServer,
NodeHubClient,
ToolApprovalResult,
} from "@cline/core";
@@ -104,7 +103,6 @@ export type SidecarContext = {
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
hubServer: HubServer | null;
workspaceRoot: string;
unsubscribeSessionEvents: (() => void) | null;
};
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Bun/JavaScriptCore requires JIT + shared executable memory under the hardened runtime -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+15 -28
View File
@@ -179,37 +179,30 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
candidates.into_iter().find(|path| path.exists())
}
fn desktop_backend_binary_names() -> Vec<String> {
let extension = if cfg!(windows) { ".exe" } else { "" };
let bundled_name = format!("code-sidecar{extension}");
fn desktop_backend_binary_name() -> String {
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
if target_triple.is_empty() {
return vec![bundled_name];
return "code-sidecar".to_string();
}
vec![
bundled_name,
format!("code-sidecar-{target_triple}{extension}"),
]
let extension = if cfg!(windows) { ".exe" } else { "" };
format!("code-sidecar-{target_triple}{extension}")
}
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
if cfg!(debug_assertions) {
return None;
}
let binary_name = desktop_backend_binary_name();
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let current_exe = std::env::current_exe().ok();
let mut candidates = Vec::new();
if let Some(path) = explicit {
candidates.push(path);
}
for binary_name in desktop_backend_binary_names() {
candidates.push(
let candidates = [
explicit,
Some(
PathBuf::from(&context.workspace_root)
.join("apps")
.join("examples")
@@ -217,23 +210,17 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
.join("src-tauri")
.join("bin")
.join(&binary_name),
);
if let Some(path) = current_exe
),
current_exe
.as_ref()
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
{
candidates.push(path);
}
if let Some(path) = current_exe.as_ref().and_then(|path| {
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name))),
current_exe.as_ref().and_then(|path| {
path.parent()
.and_then(|parent| parent.parent())
.map(|parent| parent.join("Resources").join(&binary_name))
}) {
candidates.push(path);
}
}
candidates.into_iter().find(|path| path.exists())
}),
];
candidates.into_iter().flatten().find(|path| path.exists())
}
fn ensure_desktop_backend_started(
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.1",
"version": "0.1.0",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -33,10 +33,6 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": "entitlements.plist",
"hardenedRuntime": true
}
]
}
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
@@ -1,41 +0,0 @@
const MARKETPLACE_CATALOG_URL =
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
"https://cline.github.io/marketplace/catalog.json";
export const dynamic = "force-static";
const EMPTY_MARKETPLACE_CATALOG = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
export async function GET() {
try {
const response = await fetch(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
return Response.json({
...EMPTY_MARKETPLACE_CATALOG,
error:
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
});
}
return Response.json(await response.json());
} catch (error) {
return Response.json({
...EMPTY_MARKETPLACE_CATALOG,
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
});
}
}
@@ -9,73 +9,39 @@
--font-geist-mono:
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
monospace;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--background: oklch(0.13 0.005 260);
--foreground: oklch(0.93 0 0);
--card: oklch(0.16 0.005 260);
--card-foreground: oklch(0.93 0 0);
--popover: oklch(0.16 0.005 260);
--popover-foreground: oklch(0.93 0 0);
--primary: oklch(0.75 0.12 165);
--primary-foreground: oklch(0.13 0.005 260);
--secondary: oklch(0.22 0.005 260);
--secondary-foreground: oklch(0.85 0 0);
--muted: oklch(0.2 0.005 260);
--muted-foreground: oklch(0.55 0 0);
--accent: oklch(0.22 0.01 260);
--accent-foreground: oklch(0.93 0 0);
--destructive: oklch(0.55 0.2 25);
--destructive-foreground: oklch(0.93 0 0);
--border: oklch(0.25 0.005 260);
--input: oklch(0.2 0.005 260);
--ring: oklch(0.75 0.12 165);
--chart-1: oklch(0.75 0.12 165);
--chart-2: oklch(0.65 0.15 250);
--chart-3: oklch(0.7 0.15 50);
--chart-4: oklch(0.65 0.18 320);
--chart-5: oklch(0.6 0.12 200);
--radius: 0.5rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar: oklch(0.11 0.005 260);
--sidebar-foreground: oklch(0.85 0 0);
--sidebar-primary: oklch(0.75 0.12 165);
--sidebar-primary-foreground: oklch(0.13 0.005 260);
--sidebar-accent: oklch(0.18 0.008 260);
--sidebar-accent-foreground: oklch(0.93 0 0);
--sidebar-border: oklch(0.22 0.005 260);
--sidebar-ring: oklch(0.75 0.12 165);
}
@theme inline {
@@ -138,17 +104,8 @@
}
@layer components {
.markdown {
@apply leading-relaxed;
}
.markdown * {
@apply text-sm leading-relaxed;
}
.markdown + .markdown {
@apply mt-2;
}
.markdown p {
@apply my-2 first:mt-0 last:mb-0;
@apply text-sm;
}
.markdown a {
@apply underline;
+8 -67
View File
@@ -22,21 +22,17 @@ import {
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import { DiffView } from "@/components/views/chat/diff-view";
import { SessionsView } from "@/components/views/sessions/sessions-view";
import { SettingsView } from "@/components/views/settings/settings-view";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
getSessionMetadataTitle,
type SessionHistoryItem,
type SessionMetadata,
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
function makeThreadId(): string {
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
@@ -74,24 +70,17 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
const [view, setView] = useState<"chat" | "diff" | "settings">("chat");
const [threads, setThreads] = useState<Thread[]>(() => [
{ id: makeThreadId() },
]);
const [activeThreadId, setActiveThreadId] = useState<string>(
() => threads[0]?.id,
);
useEffect(() => {
syncHubTheme();
return watchSystemHubTheme();
}, []);
const handleNewThread = useCallback(() => {
const id = makeThreadId();
setThreads((prev) => [...prev, { id }]);
setActiveThreadId(id);
setView("chat");
}, []);
const handleOpenSession = useCallback((session: SessionHistoryItem) => {
@@ -109,7 +98,6 @@ export default function Home() {
return [...prev, { id: threadId, historySession: session }];
});
setActiveThreadId(threadId);
setView("chat");
}, []);
const handleDeleteSession = useCallback(
@@ -188,12 +176,6 @@ export default function Home() {
?.sessionId ?? null;
const activeThread =
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
const sessionHistory = useSessionHistory({
activeSessionId: activeHistorySessionId,
onDeleteSession: handleDeleteSession,
onOpenSession: handleOpenSession,
onUpdateSessionMetadata: handleUpdateSessionMetadata,
});
return (
<>
@@ -206,18 +188,13 @@ export default function Home() {
<AgentSidebar
activeSessionId={activeHistorySessionId}
onNewThread={handleNewThread}
sessionHistory={sessionHistory}
onOpenSession={handleOpenSession}
setView={setView}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
/>
) : activeThread ? (
{activeThread ? (
<div className="flex min-h-0 flex-1 flex-col">
<ChatThreadPane
key={activeThread.id}
@@ -264,7 +241,6 @@ function ChatThreadPane({
sessionId,
status,
chatTransportState,
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
config,
@@ -478,12 +454,7 @@ function ChatThreadPane({
async (preferredWorkspace?: string) => {
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces((current) =>
current.length === results.length &&
current.every((workspace, index) => workspace === results[index])
? current
: results,
);
setWorkspaces(results);
} finally {
setWorkspacesLoaded(true);
}
@@ -624,26 +595,6 @@ function ChatThreadPane({
await sendPrompt(trimmed, toSend);
}, [pendingAttachments, promptInput, sendPrompt]);
const handleReasoningChange = useCallback(
(next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
setConfig((prev) => {
if (
prev.thinking === next.thinking &&
prev.reasoningEffort === next.reasoningEffort
) {
return prev;
}
return {
...prev,
thinking: next.thinking,
reasoningEffort:
next.thinking === false ? undefined : next.reasoningEffort,
};
});
},
[setConfig],
);
const handleUndoQueuedPrompt = useCallback(
async (item: PromptInQueue) => {
const removed = await removePromptInQueue(item.id);
@@ -876,7 +827,9 @@ function ChatThreadPane({
workspaceRoot: resolvedWorkspaceRoot,
workspaces,
listWorkspaces,
refreshWorkspaces,
refreshWorkspaces: async () => {
await refreshWorkspaces();
},
switchWorkspace,
pickWorkspaceDirectory,
}),
@@ -898,17 +851,8 @@ function ChatThreadPane({
<div className="flex h-full flex-1 flex-col items-center justify-center gap-3 bg-background text-foreground">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<p className="text-sm text-muted-foreground">
{chatTransportState === "unavailable"
? "Desktop backend unavailable"
: chatTransportState !== "connected"
? "Connecting..."
: "Loading..."}
{chatTransportState !== "connected" ? "Connecting..." : "Loading..."}
</p>
{chatTransportError ? (
<p className="max-w-xl px-6 text-center text-xs text-muted-foreground">
{chatTransportError}
</p>
) : null}
</div>
);
}
@@ -1015,7 +959,6 @@ function ChatThreadPane({
}))
}
onPromptInputChange={setPromptInput}
onReasoningChange={handleReasoningChange}
onSteerPromptInQueue={(promptId) => {
void steerPromptInQueue(promptId);
}}
@@ -1053,10 +996,8 @@ function ChatThreadPane({
promptsInQueue={promptsInQueue}
promptInput={promptInput}
provider={config.provider}
reasoningEffort={config.reasoningEffort}
status={status}
summary={summary}
thinking={config.thinking}
/>
</div>
</div>
@@ -80,7 +80,7 @@ export function AgentHeader({
const triggerDeleteSession = () => onDeleteSession?.();
return (
<header className="flex h-12 items-center justify-between px-4">
<header className="flex h-12 items-center justify-between border-b border-border bg-card px-4">
{/* Left: thread title */}
<div className="flex items-center gap-2">
<span
@@ -179,7 +179,7 @@ export function AgentHeader({
type="button"
variant="secondary"
>
<span className="text-chart-2">+{additions}</span>
<span className="text-primary">+{additions}</span>
<span className="text-destructive">-{deletions}</span>
</Button>
{/* New Chat Button */}
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer cursor-pointer data-[state=checked]:bg-primary/20 data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer cursor-pointer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
@@ -25,7 +25,7 @@ import {
} from "@/components/ui/combobox";
import { useWorkspace } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
@@ -66,62 +66,19 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.5"],
"openai-native": ["gpt-5.3-codex"],
openrouter: ["anthropic/claude-sonnet-4.6"],
gemini: ["gemini-3-pro-latest"],
gemini: ["gemini-2.5-pro"],
};
const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.5"],
"openai-native": ["gpt-5.3-codex"],
openrouter: ["anthropic/claude-sonnet-4.6"],
gemini: ["gemini-3-pro-latest"],
gemini: ["gemini-2.5-pro"],
};
type ReasoningEffort = NonNullable<ChatSessionConfig["reasoningEffort"]>;
type ReasoningEffortOption = {
label: string;
value: "none" | ReasoningEffort;
};
const DEFAULT_REASONING_EFFORT: ReasoningEffortOption = {
label: "Low",
value: "low",
};
const EFFORT_LEVELS: ReasoningEffortOption[] = [
{ label: "None", value: "none" },
DEFAULT_REASONING_EFFORT,
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
{ label: "Extra", value: "xhigh" },
];
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
const PROMPT_INPUT_FOCUSED_ROWS = 5;
function resolveEffortIndex(
thinking: ChatSessionConfig["thinking"],
reasoningEffort: ChatSessionConfig["reasoningEffort"],
): number {
if (thinking === false) {
return 0;
}
const index = EFFORT_LEVELS.findIndex(
(option) => option.value === reasoningEffort,
);
return index >= 0 ? index : 1;
}
function buildReasoningConfig(
option: ReasoningEffortOption,
): Pick<ChatSessionConfig, "thinking" | "reasoningEffort"> {
if (option.value === "none") {
return { thinking: false, reasoningEffort: undefined };
}
return { thinking: true, reasoningEffort: option.value };
}
function hasReasoningCapability(
providerReasoningModels: Record<string, string[]>,
provider: string,
@@ -192,17 +149,12 @@ type ChatInputBarProps = {
provider: string;
model: string;
mode: "act" | "plan";
thinking: ChatSessionConfig["thinking"];
reasoningEffort: ChatSessionConfig["reasoningEffort"];
gitBranch: string;
promptInput: string;
onPromptInputChange: (value: string) => void;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModeToggle: () => void;
onReasoningChange: (
next: Pick<ChatSessionConfig, "thinking" | "reasoningEffort">,
) => void;
onRefreshGitBranch: () => void;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
@@ -231,15 +183,12 @@ export function ChatInputBar({
provider,
model,
mode,
thinking,
reasoningEffort,
gitBranch,
promptInput,
onPromptInputChange,
onProviderChange,
onModelChange,
onModeToggle,
onReasoningChange,
onRefreshGitBranch,
onListGitBranches,
onSwitchGitBranch,
@@ -270,9 +219,10 @@ export function ChatInputBar({
hasReasoningCapability(FALLBACK_PROVIDER_REASONING_MODELS, provider, model),
);
const canSend = hasDraft;
const effortLevels = ["Low", "Medium", "High"] as const;
const [effortIndex, setEffortIndex] = useState(1);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [promptInputFocused, setPromptInputFocused] = useState(false);
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
const [mentionOpen, setMentionOpen] = useState(false);
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
@@ -308,35 +258,13 @@ export function ChatInputBar({
}
return `${total.toLocaleString()} tokens`;
}, [summary.tokensIn, summary.tokensOut]);
const effortIndex = useMemo(
() => resolveEffortIndex(thinking, reasoningEffort),
[reasoningEffort, thinking],
);
const effortLabel = modelSupportsReasoning
? (EFFORT_LEVELS[effortIndex]?.label ?? "Low")
: "None";
const effortLabel = effortLevels[effortIndex];
const handleEffortCycle = useCallback(() => {
if (!modelSupportsReasoning) {
return;
}
const nextOption = EFFORT_LEVELS[(effortIndex + 1) % EFFORT_LEVELS.length];
if (!nextOption) {
return;
}
onReasoningChange(buildReasoningConfig(nextOption));
}, [effortIndex, modelSupportsReasoning, onReasoningChange]);
useEffect(() => {
if (!modelSupportsReasoning) {
if (thinking !== false || reasoningEffort !== undefined) {
onReasoningChange({ thinking: false, reasoningEffort: undefined });
}
return;
}
if (thinking === undefined && reasoningEffort === undefined) {
onReasoningChange(buildReasoningConfig(DEFAULT_REASONING_EFFORT));
}
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
setEffortIndex((current) => (current + 1) % effortLevels.length);
}, [effortLevels.length, modelSupportsReasoning]);
const startQueuedPromptEdit = useCallback((item: PromptInQueue) => {
setEditingQueuedPromptId(item.id);
@@ -402,6 +330,20 @@ export function ChatInputBar({
}
}, [cancelQueuedPromptEdit, editingQueuedPromptId, promptsInQueue]);
useEffect(() => {
const input = promptInputRef.current;
if (!input) {
return;
}
input.style.height = "0px";
const styles = window.getComputedStyle(input);
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
const maxHeight = lineHeight * 10;
const nextHeight = Math.min(input.scrollHeight, maxHeight);
input.style.height = `${nextHeight}px`;
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
}, []);
useEffect(() => {
const nextMention = getActiveMention(promptInput, cursorIndex);
setActiveMention(nextMention);
@@ -806,7 +748,7 @@ export function ChatInputBar({
)}
<div className="flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20">
<textarea
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
className="max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
onChange={(e) => {
onPromptInputChange(e.target.value);
setCursorIndex(
@@ -818,8 +760,6 @@ export function ChatInputBar({
e.currentTarget.selectionStart ?? promptInput.length,
)
}
onBlur={() => setPromptInputFocused(false)}
onFocus={() => setPromptInputFocused(true)}
onKeyDown={(e) => {
// Slash command menu takes priority when open.
if (slashOpen && filteredSlashCommands.length > 0) {
@@ -895,14 +835,10 @@ export function ChatInputBar({
placeholder={
isBusy
? "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
: "Enter your question or type / for workflow or @ to attach files"
}
ref={promptInputRef}
rows={
promptInputFocused
? PROMPT_INPUT_FOCUSED_ROWS
: PROMPT_INPUT_COLLAPSED_ROWS
}
rows={1}
value={promptInput}
/>
</div>
@@ -3,22 +3,19 @@
import {
AlertCircle,
Bot,
BrainIcon,
Check,
ChevronDown,
ChevronRight,
Clock3,
Copy,
FileEdit,
FileIcon,
FileSearch,
GitBranch,
Loader2,
MessagesSquare,
RotateCcw,
Search,
ShieldAlert,
SplitIcon,
SquareTerminalIcon,
UndoIcon,
Terminal,
} from "lucide-react";
import {
memo,
@@ -31,7 +28,6 @@ import {
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
import { parseApplyPatchInput } from "@/lib/session-diff";
import { cn } from "@/lib/utils";
import { MemoizedMarkdown } from "../../ui/markdown";
import { normalizeTitle } from "../../utils";
@@ -40,11 +36,7 @@ import { WelcomeScreen } from "./welcome-chat";
type ChatMessagesProps = {
sessionId: string | null;
status: ChatSessionStatus;
chatTransportState?:
| "connecting"
| "reconnecting"
| "connected"
| "unavailable";
chatTransportState?: "connecting" | "reconnecting" | "connected";
isSessionSwitching?: boolean;
provider: string;
model: string;
@@ -373,10 +365,10 @@ function ChatMessagesImpl({
return (
<div className="relative h-full min-h-0 min-w-0">
<div
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
className="h-full min-h-0 min-w-0 overflow-y-auto"
ref={scrollAreaRef}
>
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
<div className="relative mx-auto w-full px-6 py-6">
{showIdleDetails ? (
<WelcomeScreen
provider={provider}
@@ -385,7 +377,7 @@ function ChatMessagesImpl({
quickActions={[]}
/>
) : (
<div className="flex h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
<div className="flex flex-col gap-2 w-full h-full">
{pendingToolApprovals.length > 0 ? (
<ToolApprovalPanel
items={pendingToolApprovals}
@@ -482,9 +474,7 @@ function ChatMessagesImpl({
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{chatTransportState === "reconnecting"
? "Reconnecting chat..."
: chatTransportState === "unavailable"
? "Chat backend unavailable"
: "Connecting chat..."}
: "Connecting chat..."}
</div>
) : null}
{shouldShowErrorBanner ? (
@@ -579,7 +569,7 @@ function ToolApprovalPanel({
Request {item.requestId}
{item.iteration != null ? ` · Iteration ${item.iteration}` : ""}
</div>
<pre className="mt-2 max-h-44 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
<pre className="mt-2 max-h-44 overflow-auto rounded-md border border-border/70 bg-background p-2 text-xs text-muted-foreground">
{formatApprovalInput(item.input)}
</pre>
{error ? (
@@ -732,17 +722,6 @@ function MessageBubble({
const isUser = message.role === "user";
const isError = message.role === "error";
const checkpoint = message.meta?.checkpoint;
const shouldRenderAssistantActions =
message.role === "assistant" &&
!isStreaming &&
!isError &&
Boolean(onCopyRawText || onForkSession);
const shouldRenderUserActions =
isUser && Boolean(onCopyRawText || checkpoint);
const keepUserActionsVisible = restorePending || Boolean(restoreError);
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
const hiddenActionButtonsClassName =
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
if (message.role === "tool") {
return <ToolMessageBlock message={message} />;
@@ -753,106 +732,71 @@ function MessageBubble({
return (
<div
className={cn(
"flex min-w-0",
isUser ? "justify-end" : "w-full justify-start",
)}
className={cn("flex", isUser ? "justify-end" : "justify-start w-full")}
>
<div
className={cn(
"group max-w-full min-w-0 wrap-break-word text-sm",
isUser && "flex max-w-[50%] flex-col items-end gap-1",
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
!isUser && !isError && "text-foreground",
"space-y-2 pl-3 text-sm",
isUser && "bg-card text-foreground/80 max-w-[50%]",
!isUser && !isError && "text-foreground w-full",
isError &&
"bg-destructive/10 border border-destructive/40 text-destructive",
)}
>
<div
className={cn(
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
isUser && "rounded-sm bg-card p-2 text-foreground/80",
)}
>
{isStreaming && message.role === "assistant" ? (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="whitespace-pre-wrap wrap-break-word leading-relaxed">
{normalizedContent || " "}
</div>
</>
) : (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="my-1 ml-3 min-w-0 max-w-full overflow-x-hidden wrap-break-word **:max-w-full [&_code]:whitespace-pre-wrap [&_code]:wrap-break-word [&_pre]:overflow-x-hidden [&_pre]:whitespace-pre-wrap [&_pre]:wrap-break-word">
<MemoizedMarkdown
content={normalizedContent || " "}
id={message.id}
/>
</div>
</>
)}
</div>
{shouldRenderUserActions ? (
<div className="space-y-1">
<div className="flex h-6 items-center justify-end">
<div
className={cn(
"flex items-center justify-end gap-2",
keepUserActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
{isStreaming && message.role === "assistant" ? (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<div className="whitespace-pre-wrap">
{normalizedContent || " "}
</div>
</>
) : (
<>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
/>
) : null}
<MemoizedMarkdown
content={normalizedContent || " "}
id={message.id}
/>
</>
)}
{isUser && checkpoint ? (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-end gap-2">
<Button
className="h-7 px-2 text-xs"
onClick={onCopyRawText}
size="sm"
type="button"
variant="outline"
>
{onCopyRawText ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label={
wasCopied ? "Copied user message" : "Copy user message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy message"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
) : null}
{checkpoint ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label="Restore checkpoint"
disabled={restoreDisabled || restorePending}
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
size="sm"
title="Restore checkpoint"
type="button"
variant="ghost"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UndoIcon className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>
<Copy className="h-3.5 w-3.5" />
{wasCopied ? "Copied" : "Copy"}
</Button>
<Button
className="h-7 px-2 text-xs"
disabled={restoreDisabled || restorePending}
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
size="sm"
type="button"
variant="outline"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
Undo
</Button>
</div>
{restoreError ? (
<div className="text-right text-xs text-destructive">
@@ -861,61 +805,30 @@ function MessageBubble({
) : null}
</div>
) : null}
{shouldRenderAssistantActions ? (
<div className="flex h-6 items-center hidden">
<div
className={cn(
"flex items-center gap-0",
keepAssistantActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
{!isUser &&
!isError &&
!isStreaming &&
message.role === "assistant" &&
onForkSession ? (
<div className="mt-1 flex items-center gap-1">
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
disabled={forkPending}
onClick={onForkSession}
size="sm"
title="Fork session — copy full message history into a new session"
type="button"
variant="ghost"
>
{onCopyRawText ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy raw assistant output"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
) : null}
{onForkSession ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label="Fork session"
disabled={forkPending}
onClick={onForkSession}
size="sm"
title="Fork session - copy full message history into a new session"
type="button"
variant="ghost"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SplitIcon className="h-3 w-3" />
)}
</Button>
) : null}
{forkError ? (
<span className="text-[11px] text-destructive">
{forkError}
</span>
) : null}
</div>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<GitBranch className="h-3 w-3" />
)}
</Button>
{forkError ? (
<span className="text-[11px] text-destructive">{forkError}</span>
) : null}
</div>
) : null}
</div>
@@ -937,18 +850,17 @@ function ReasoningBlock({
}
return (
<div className="my-2">
<div className="mb-2">
<Button
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground dark:hover:bg-transparent dark:hover:text-foreground"
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
<BrainIcon className="size-4" />
Thinking
</Button>
{expanded ? (
<div className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
<div className="mt-1 whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-3 text-xs text-muted-foreground">
{displayContent}
</div>
) : null}
@@ -966,10 +878,6 @@ type ToolPayload = {
type ToolSummary = {
label: string;
details: string[];
diff?: {
additions: number;
deletions: number;
};
};
function pruneRequestMap<T extends string>(
@@ -1058,12 +966,7 @@ function classifyTool(
].includes(normalized)
)
return "exploration";
if (
["editor", "edit_file", "edit", "apply_patch", "apply-patch"].includes(
normalized,
)
)
return "file-edit";
if (["editor", "edit_file", "edit"].includes(normalized)) return "file-edit";
if (["bash", "run_commands"].includes(normalized)) return "bash";
if (["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized))
return "spawn";
@@ -1082,62 +985,6 @@ function asStringArray(value: unknown): string[] {
);
}
/**
* read_files accepts many input shapes: { files: [{ path }] }, { files: path },
* { file_paths: [...] }, { paths: [...] }, a bare request, an array, or a string.
*/
function extractReadFilePaths(input: unknown): string[] {
const out: string[] = [];
const push = (value: unknown) => {
if (typeof value === "string" && value.length > 0) {
out.push(value);
return;
}
const record = asRecord(value);
if (record && typeof record.path === "string" && record.path.length > 0) {
out.push(record.path);
}
};
const record = asRecord(input);
const candidates =
record?.files ?? record?.file_paths ?? record?.paths ?? record ?? input;
if (Array.isArray(candidates)) {
for (const candidate of candidates) {
push(candidate);
}
} else {
push(candidates);
}
return out;
}
/**
* run_commands entries can be shell strings or structured { command, args }.
*/
function extractCommands(input: unknown): string[] {
const inputObject = asRecord(input);
const raw = Array.isArray(inputObject?.commands)
? inputObject.commands
: typeof inputObject?.command === "string"
? [inputObject.command]
: typeof input === "string"
? [input]
: [];
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string" && entry.length > 0) {
out.push(entry);
continue;
}
const record = asRecord(entry);
if (record && typeof record.command === "string") {
const args = asStringArray(record.args);
out.push([record.command, ...args].join(" "));
}
}
return out;
}
function toDisplayPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts.at(-1) || path;
@@ -1178,10 +1025,10 @@ function buildToolSummary(
const inputObject = asRecord(input);
if (["read_files", "file_read", "file-read"].includes(normalized)) {
const files = extractReadFilePaths(input);
const files = asStringArray(inputObject?.file_paths);
if (files.length > 0) {
return {
label: `${inProgress ? "Reading" : "Read"} ${pluralize(files.length, "file")}`,
label: `${inProgress ? "Exploring" : "Explored"} ${pluralize(files.length, "file")}`,
details: files.map(
(file) => `${inProgress ? "Reading" : "Read"} ${toDisplayPath(file)}`,
),
@@ -1200,8 +1047,14 @@ function buildToolSummary(
}
if (["run_commands", "bash"].includes(normalized)) {
const commands = extractCommands(input);
if (commands.length > 0) {
const commands = asStringArray(inputObject?.commands);
if (commands.length === 1) {
return {
label: `${inProgress ? "Running" : "Ran"} ${commands[0]}`,
details: [commands[0]],
};
}
if (commands.length > 1) {
return {
label: `${inProgress ? "Running" : "Ran"} ${pluralize(commands.length, "command")}`,
details: commands.map((command) => command.trim()),
@@ -1231,44 +1084,9 @@ function buildToolSummary(
}
}
if (["apply_patch", "apply-patch"].includes(normalized)) {
const patchText =
typeof input === "string"
? input
: typeof inputObject?.input === "string"
? inputObject.input
: "";
const fileDiffs = patchText ? parseApplyPatchInput(patchText) : [];
if (fileDiffs.length > 0) {
const additions = fileDiffs.reduce((sum, d) => sum + d.additions, 0);
const deletions = fileDiffs.reduce((sum, d) => sum + d.deletions, 0);
return {
label: `${inProgress ? "Editing" : "Edited"} ${pluralize(fileDiffs.length, "file")}`,
diff: { additions, deletions },
details: fileDiffs.map(
(d) =>
`${inProgress ? "Editing" : "Edited"} ${toDisplayPath(d.path)} +${d.additions} -${d.deletions}`,
),
};
}
return {
label: inProgress ? "Applying patch" : "Applied patch",
details: [],
};
}
if (["editor", "edit_file", "edit"].includes(normalized)) {
// Current editor schema has no `command`; derive it from the input shape.
const command =
typeof inputObject?.command === "string"
? inputObject.command
: inputObject?.insert_line != null
? "insert"
: typeof inputObject?.old_text === "string"
? "str_replace"
: typeof inputObject?.new_text === "string"
? "create"
: "edit";
typeof inputObject?.command === "string" ? inputObject.command : "edit";
const path =
typeof inputObject?.path === "string"
? toDisplayPath(inputObject.path)
@@ -1289,12 +1107,14 @@ function buildToolSummary(
: command === "insert"
? "Inserted"
: "Edited";
// The label already carries all the information; no expandable details.
const detail = `${action} ${path}`;
if (diff) {
return { label: detail, diff, details: [] };
return {
label: `${detail} +${diff.additions} -${diff.deletions}`,
details: [detail],
};
}
return { label: detail, details: [] };
return { label: detail, details: [detail] };
}
const query =
@@ -1342,17 +1162,13 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
hookEventName === "history_tool_use" ||
(Boolean(payload) && payload?.result == null && !payload?.isError);
const kind = classifyTool(toolName);
const isFileRead = ["read_files", "file_read", "file-read"].includes(
toolName.toLowerCase(),
);
const Icon = isFileRead
? FileIcon
: kind === "exploration"
const Icon =
kind === "exploration"
? Search
: kind === "file-edit"
? FileEdit
: kind === "bash"
? SquareTerminalIcon
? Terminal
: kind === "spawn"
? Bot
: FileSearch;
@@ -1364,52 +1180,39 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
IS_DEBUG && payload ? formatToolValue(payload.input) : "";
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
const hasExpandedSections =
details.length > 0 || Boolean(inputPreview || resultPreview);
details.length > 1 || Boolean(inputPreview || resultPreview);
return (
<div className="my-2 flex w-full min-w-0 justify-start">
<div
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
>
<div className="flex justify-start w-full">
<div className={cn("w-full rounded-xl text-xs")}>
<Button
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 dark:hover:bg-transparent dark:hover:text-primary/80"
className="w-full justify-start gap-2 p-0 text-left font-medium text-foreground/70 hover:bg-transparent text-xs"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
{payload?.isError ? (
<AlertCircle className="size-4 text-destructive/80" />
<AlertCircle className="size-3 text-destructive/80" />
) : (
<Icon className="size-4" />
<Icon className="size-3" />
)}
<span className="min-w-0 wrap-break-word">{summary.label}</span>
{summary.diff ? (
<span className="shrink-0 font-mono text-xs">
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
<span className="text-destructive">
-{summary.diff.deletions}
</span>
</span>
) : null}
<span>{summary.label}</span>
{hasExpandedSections ? (
<span className="shrink-0 text-muted-foreground">
<span className="text-muted-foreground">
{expanded ? (
<ChevronDown className="size-4" />
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-4" />
<ChevronRight className="size-3" />
)}
</span>
) : null}
</Button>
{expanded ? (
<div className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground">
<div className="pl-8 text-muted-foreground">
{hasExpandedSections ? (
<div className="space-y-1">
{details.map((detail) => (
<div
className="wrap-break-word"
key={`${message.id}_${detail}`}
>
<div className="text-xxs" key={`${message.id}_${detail}`}>
{detail}
</div>
))}
@@ -1420,7 +1223,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
<div className="text-xxs uppercase tracking-wide text-muted-foreground/80">
Input
</div>
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
<pre className="max-h-52 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
{inputPreview}
</pre>
</div>
@@ -1432,7 +1235,7 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
</div>
) : (
<div className="space-y-1">
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
<pre className="max-h-64 overflow-auto rounded-md border border-border/70 bg-background/60 p-2 text-xxs leading-relaxed text-foreground whitespace-pre-wrap break-all">
{resultPreview}
</pre>
</div>
@@ -108,10 +108,10 @@ export function WelcomeScreen({
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
<div className="mb-8 flex flex-col items-center">
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
What can I do for you?
What would you like to build?
</h1>
<p className="mt-2 text-balance text-center text-muted-foreground">
Let's explore, edit, and ship code together!
Start a conversation to explore, edit, and ship code together.
</p>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,111 +0,0 @@
import type { ComponentType, ReactNode } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type PageFrameProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
};
export function PageFrame({
children,
className,
contentClassName,
}: PageFrameProps) {
return (
<ScrollArea className="h-full">
<div
className={cn(
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
}
type PageHeaderProps = {
actions?: ReactNode;
className?: string;
description?: ReactNode;
icon?: ComponentType<{ className?: string }>;
meta?: ReactNode;
title: ReactNode;
};
export function PageHeader({
actions,
className,
description,
icon: Icon,
meta,
title,
}: PageHeaderProps) {
return (
<section
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
className,
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
{title}
</h1>
{meta}
</div>
{description ? (
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
{actions}
</div>
) : null}
</section>
);
}
type PageEmptyStateProps = {
children: ReactNode;
className?: string;
};
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
return (
<div
className={cn(
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
type CommandBadgeProps = {
children: ReactNode;
className?: string;
};
export function CommandBadge({ children, className }: CommandBadgeProps) {
return (
<span
className={cn(
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
@@ -1,550 +0,0 @@
"use client";
import {
ArrowUpDown,
Check,
Filter,
Folder,
GitFork,
Loader2,
MoreHorizontal,
Pencil,
Search,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
basenamePath,
formatCostUsd,
formatRelativeTime,
parseTimestamp,
type SessionThread,
type UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import type { SessionHistoryItem } from "@/lib/session-history";
import { cn } from "@/lib/utils";
type SessionsViewProps = {
activeSessionId?: string | null;
history: UseSessionHistoryResult;
};
function statusTone(status?: string): string {
if (status === "running") return "bg-green-500";
if (status === "completed") return "bg-emerald-400";
if (status === "failed") return "bg-destructive";
if (status === "cancelled") return "bg-yellow-500";
return "bg-muted-foreground";
}
function modelLabel(thread: SessionThread): string {
if (thread.provider && thread.model) {
return `${thread.provider}:${thread.model}`;
}
return thread.model || thread.provider || "No model";
}
function tokensLabel(thread: SessionThread): string {
if (thread.inputTokens == null && thread.outputTokens == null) {
return "-";
}
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
}
function sessionFilterDetails(
thread: SessionThread,
session?: SessionHistoryItem,
): string[] {
const workspacePath = session?.workspaceRoot || session?.cwd || "";
const workspace = workspacePath ? basenamePath(workspacePath) : "";
return [
workspace ? `workspace:${workspace}` : undefined,
thread.status ? `status:${thread.status}` : undefined,
thread.provider ? `provider:${thread.provider}` : undefined,
thread.model ? `model:${thread.model}` : undefined,
].filter((detail): detail is string => Boolean(detail));
}
function sortTimestamp(session?: SessionHistoryItem) {
const timestamp = parseTimestamp(session?.endedAt || session?.startedAt);
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
const [query, setQuery] = useState("");
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
const [sortDirection, setSortDirection] = useState<"newest" | "oldest">(
"newest",
);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
null,
);
const filterOptions = useMemo(
() =>
Array.from(
new Set(
history.threads.flatMap((thread) =>
sessionFilterDetails(thread, history.sessionById.get(thread.id)),
),
),
).sort((a, b) => a.localeCompare(b)),
[history.sessionById, history.threads],
);
const filteredThreads = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const selected = new Set(sessionFilters);
const filtered = history.threads.filter((thread) => {
const session = history.sessionById.get(thread.id);
const details = sessionFilterDetails(thread, session);
const matchesFilters =
selected.size === 0 || details.some((detail) => selected.has(detail));
if (!matchesFilters) {
return false;
}
if (!normalizedQuery) {
return true;
}
const searchable = [
thread.title,
thread.codebase,
thread.provider,
thread.model,
session?.workspaceRoot,
session?.cwd,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchable.includes(normalizedQuery);
});
return [...filtered].sort((a, b) => {
const aTime = sortTimestamp(history.sessionById.get(a.id));
const bTime = sortTimestamp(history.sessionById.get(b.id));
return sortDirection === "newest" ? bTime - aTime : aTime - bTime;
});
}, [
history.sessionById,
history.threads,
query,
sessionFilters,
sortDirection,
]);
const toggleFilter = (detail: string, checked: boolean) => {
setSessionFilters((current) => {
if (checked) {
return current.includes(detail) ? current : [...current, detail];
}
return current.filter((item) => item !== detail);
});
};
const startRename = (thread: SessionThread) => {
setEditingSessionId(thread.id);
setEditingTitle(thread.title);
};
const cancelRename = () => {
setEditingSessionId(null);
setEditingTitle("");
};
const submitRename = async (thread: SessionThread) => {
const renamed = await history.renameThread(thread.id, editingTitle);
if (renamed) {
cancelRename();
}
};
const confirmDelete = async () => {
if (!deleteCandidate) {
return;
}
const deleted = await history.deleteThread(deleteCandidate.id);
if (deleted) {
setDeleteCandidate(null);
}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-center justify-between gap-4 border-b px-6 py-4">
<div className="min-w-0">
<h1 className="text-lg font-semibold leading-tight">Sessions</h1>
<p className="mt-1 text-sm text-muted-foreground">
Recent sessions across clients and workspaces.
</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="relative min-w-44 max-w-72 flex-1">
<Search className="-translate-y-1/2 pointer-events-none absolute left-2.5 top-1/2 size-4 text-muted-foreground" />
<Input
aria-label="Search sessions"
className="h-8 pl-8"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
value={query}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Sort sessions"
className="h-8 rounded-md px-2.5"
size="sm"
title="Sort sessions"
type="button"
variant="outline"
>
<ArrowUpDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => setSortDirection("newest")}>
{sortDirection === "newest" ? "Newest first" : "Newest first"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortDirection("oldest")}>
{sortDirection === "oldest" ? "Oldest first" : "Oldest first"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Filter sessions"
className="h-8 rounded-md px-2.5"
size="sm"
title="Filter sessions"
type="button"
variant={sessionFilters.length > 0 ? "default" : "outline"}
>
<Filter className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Filter sessions</DropdownMenuLabel>
{sessionFilters.length > 0 ? (
<>
<DropdownMenuItem onClick={() => setSessionFilters([])}>
Clear filters
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{filterOptions.length === 0 ? (
<DropdownMenuItem disabled>
No filters available
</DropdownMenuItem>
) : (
filterOptions.map((detail) => (
<DropdownMenuCheckboxItem
checked={sessionFilters.includes(detail)}
key={detail}
onCheckedChange={(checked) =>
toggleFilter(detail, checked === true)
}
>
<span className="truncate" title={detail}>
{detail}
</span>
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<section className="min-h-0 flex-1 overflow-auto px-6 py-5">
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
<span>Session</span>
<span>Workspace</span>
<span>Model</span>
<span>Tokens</span>
<span>Cost</span>
<span>Updated</span>
<span />
</div>
<div>
{history.isLoadingHistory && history.threads.length === 0 ? (
<div className="flex items-center gap-2 border-t px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading session history...
</div>
) : null}
{!history.isLoadingHistory && filteredThreads.length === 0 ? (
<div className="border-t px-4 py-8 text-sm text-muted-foreground">
{history.threads.length === 0
? "No sessions yet."
: "No sessions match the current filters."}
</div>
) : null}
{filteredThreads.map((thread) => {
const session = history.sessionById.get(thread.id);
const isEditing = editingSessionId === thread.id;
const isPending = history.pendingAction?.sessionId === thread.id;
const pendingKind = isPending
? history.pendingAction?.action
: null;
const workspace = session?.workspaceRoot || session?.cwd || "";
const updated = formatRelativeTime(
session?.endedAt || session?.startedAt,
);
return (
<div
className={cn(
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
activeSessionId === thread.id
? "bg-accent/50"
: "hover:bg-accent/30",
)}
key={thread.id}
>
{isEditing ? (
<form
className="col-span-6 grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4"
onSubmit={(event) => {
event.preventDefault();
void submitRename(thread);
}}
>
<div className="col-span-2 flex min-w-0 items-center gap-2">
<Input
aria-label={`Rename ${thread.title}`}
autoFocus
className="h-8"
disabled={pendingKind === "rename"}
onChange={(event) =>
setEditingTitle(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelRename();
}
}}
value={editingTitle}
/>
<Button
aria-label="Save title"
className="h-8 rounded-md px-2.5"
disabled={
pendingKind === "rename" || !editingTitle.trim()
}
size="sm"
type="submit"
>
{pendingKind === "rename" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
</Button>
<Button
aria-label="Cancel rename"
className="h-8 rounded-md px-2.5"
disabled={pendingKind === "rename"}
onClick={cancelRename}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" />
</Button>
</div>
<span className="truncate text-muted-foreground">
{modelLabel(thread)}
</span>
<span className="text-muted-foreground">
{tokensLabel(thread)}
</span>
<span className="text-muted-foreground">
{formatCostUsd(thread.totalCostUsd) ?? "-"}
</span>
<span className="text-muted-foreground">
{updated || thread.time}
</span>
</form>
) : (
<button
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
disabled={Boolean(pendingKind)}
onClick={() => {
if (pendingKind) {
return;
}
// Don't open the session when the user is selecting text.
if (window.getSelection()?.toString()) {
return;
}
history.openThread(thread.id);
}}
type="button"
>
<span className="flex min-w-0 items-center gap-3 font-semibold">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
statusTone(thread.status),
)}
/>
<span className="truncate">{thread.title}</span>
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Folder className="size-3.5 shrink-0" />
<span className="truncate" title={workspace}>
{workspace ? basenamePath(workspace) : "No workspace"}
</span>
</span>
<span className="truncate text-muted-foreground">
{modelLabel(thread)}
</span>
<span className="text-muted-foreground">
{tokensLabel(thread)}
</span>
<span className="text-muted-foreground">
{formatCostUsd(thread.totalCostUsd) ?? "-"}
</span>
<span className="text-muted-foreground">
{updated || thread.time}
</span>
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Session actions for ${thread.title}`}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={Boolean(pendingKind)}
type="button"
>
{pendingKind ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void history.forkThread(thread.id)}
>
<GitFork className="size-4" />
Fork
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteCandidate(thread)}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
{history.mayHaveMoreSessions ? (
<div className="border-t px-4 py-3">
<Button
className="h-8 rounded-md px-3 text-xs"
disabled={history.isLoadingMore}
onClick={() =>
void history.loadMoreSessions(history.threads.length + 100)
}
type="button"
variant="outline"
>
{history.isLoadingMore ? (
<Loader2 className="size-3.5 animate-spin" />
) : null}
Load more
</Button>
</div>
) : null}
</div>
</div>
</section>
<AlertDialog
open={deleteCandidate !== null}
onOpenChange={(open) => {
if (!open && history.pendingAction?.action !== "delete") {
setDeleteCandidate(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete session?</AlertDialogTitle>
<AlertDialogDescription>
This removes "{deleteCandidate?.title ?? "this session"}" from
local history.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={history.pendingAction?.action === "delete"}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={
!deleteCandidate || history.pendingAction?.action === "delete"
}
onClick={(event) => {
event.preventDefault();
void confirmDelete();
}}
>
{history.pendingAction?.action === "delete" ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,642 +0,0 @@
"use client";
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import {
CommandBadge,
PageEmptyState,
PageFrame,
PageHeader,
} from "../page-layout";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: {
flag: string;
equals?: string;
notEquals?: string;
};
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type ConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type ActiveConnector = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
};
type ConnectorChannelsResponse = {
available: ConnectorChannel[];
active: ActiveConnector[];
};
type ConnectorFormState = {
channelId: string;
values: Record<string, string>;
securityEnabled: boolean;
securityValues: Record<string, string>;
};
function connectorName(
connector: ActiveConnector,
channels: ConnectorChannel[],
): string {
return (
channels.find((channel) => channel.id === connector.type)?.name ??
connector.type
);
}
function connectorIdentity(connector: ActiveConnector): string {
if (connector.botUsername) {
return `@${connector.botUsername}`;
}
if (connector.userName) {
return connector.userName;
}
if (connector.applicationId) {
return connector.applicationId;
}
return `pid ${connector.pid}`;
}
function formatDateTime(value?: string): string {
if (!value) {
return "-";
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function isSecretField(
field: ConnectorField | ConnectorSecurityField,
): boolean {
const label = field.label.toLowerCase();
const key =
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
return (
label.includes("token") ||
label.includes("secret") ||
label.includes("key") ||
key.includes("token") ||
key.includes("secret") ||
key.includes("key")
);
}
function isMultilineField(field: ConnectorField): boolean {
const label = field.label.toLowerCase();
return label.includes("json") || field.flag.includes("credentials");
}
function shouldIncludeField(
field: ConnectorField,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) {
return true;
}
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
function initialValuesForChannel(
channel?: ConnectorChannel,
): Record<string, string> {
const values: Record<string, string> = {};
for (const field of channel?.fields ?? []) {
if (field.initialValue) {
values[field.flag] = field.initialValue;
}
}
return values;
}
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
const channel = channels[0];
return {
channelId: channel?.id ?? "",
values: initialValuesForChannel(channel),
securityEnabled: false,
securityValues: {},
};
}
export function ChannelsContent() {
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
[],
);
const [isLoading, setIsLoading] = useState(true);
const [busyChannel, setBusyChannel] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [formState, setFormState] = useState<ConnectorFormState>({
channelId: "",
values: {},
securityEnabled: false,
securityValues: {},
});
const [formError, setFormError] = useState<string | null>(null);
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
null,
);
const selectedChannel = useMemo(
() => channels.find((channel) => channel.id === formState.channelId),
[channels, formState.channelId],
);
const visibleFields = useMemo(() => {
const values = {
...initialValuesForChannel(selectedChannel),
...formState.values,
};
return (selectedChannel?.fields ?? []).filter((field) =>
shouldIncludeField(field, values),
);
}, [selectedChannel, formState.values]);
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
setChannels(response.available);
setActiveConnectors(response.active);
setFormState((prev) =>
prev.channelId ? prev : createFormState(response.available),
);
}, []);
const refreshChannels = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"list_connector_channels",
);
applyResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setIsLoading(false);
}
}, [applyResponse]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void refreshChannels();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [refreshChannels]);
const openAddDialog = () => {
setFormState(createFormState(channels));
setFormError(null);
setDialogOpen(true);
};
const updateFieldValue = (flag: string, value: string) => {
setFormState((prev) => ({
...prev,
values: { ...prev.values, [flag]: value },
}));
};
const updateSecurityFieldValue = (key: string, value: string) => {
setFormState((prev) => ({
...prev,
securityValues: { ...prev.securityValues, [key]: value },
}));
};
const startConnector = async () => {
if (!selectedChannel) {
setFormError("Choose a channel");
return;
}
for (const field of selectedChannel.fields) {
if (!visibleFields.includes(field)) {
continue;
}
if (field.required && !formState.values[field.flag]?.trim()) {
setFormError(`${field.label} is required`);
return;
}
}
if (formState.securityEnabled && selectedChannel.security) {
for (const field of selectedChannel.security.fields) {
if (!formState.securityValues[field.key]?.trim()) {
setFormError(field.requiredMessage);
return;
}
}
}
setBusyChannel(selectedChannel.id);
setFormError(null);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"start_connector_channel",
{
channel: selectedChannel.id,
values: formState.values,
security: {
enabled: formState.securityEnabled,
values: formState.securityValues,
},
},
);
applyResponse(response);
setDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setFormError(message);
} finally {
setBusyChannel(null);
}
};
const stopConnector = async (connector: ActiveConnector) => {
setBusyChannel(connector.type);
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"stop_connector_channel",
{ channel: connector.type },
);
applyResponse(response);
setRemoveTarget(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setBusyChannel(null);
}
};
return (
<PageFrame>
<PageHeader
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline.`}
title="Channels"
meta={<CommandBadge>cline connect</CommandBadge>}
actions={
<>
<Button
disabled={isLoading}
onClick={() => void refreshChannels()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw
className={cn("size-4", isLoading && "animate-spin")}
/>
</Button>
<Button
disabled={channels.length === 0}
onClick={openAddDialog}
size="sm"
type="button"
>
<Plus className="size-4" />
Add Channel
</Button>
</>
}
/>
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{isLoading ? (
<PageEmptyState>Loading channels...</PageEmptyState>
) : activeConnectors.length === 0 ? (
<PageEmptyState>No channels connected.</PageEmptyState>
) : (
<section className="overflow-hidden rounded-lg border bg-card">
<div className="grid gap-2 p-2.5">
{activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
</span>
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
</span>
{connector.connectionMode ? (
<span className="rounded-md border bg-background px-1.5 py-0.5">
{connector.connectionMode}
</span>
) : null}
</div>
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))}
</div>
</section>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add Channel</DialogTitle>
<DialogDescription>
Start a connector channel for Cline.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Channel</Label>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
setFormState({
channelId: value,
values: initialValuesForChannel(
channels.find((channel) => channel.id === value),
),
securityEnabled: false,
securityValues: {},
});
}}
value={formState.channelId}
>
<SelectTrigger>
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{visibleFields.map((field) => (
<div className="grid gap-2" key={field.flag}>
<Label>
{field.label}
{field.required ? (
<span className="text-destructive"> *</span>
) : null}
</Label>
{field.options ? (
<Select
onValueChange={(value) => {
if (value) {
updateFieldValue(field.flag, value);
}
}}
value={
formState.values[field.flag] ?? field.initialValue ?? ""
}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : isMultilineField(field) ? (
<Textarea
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
rows={5}
value={formState.values[field.flag] ?? ""}
/>
) : (
<Input
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.values[field.flag] ?? ""}
/>
)}
</div>
))}
{selectedChannel?.security ? (
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm">Restrict access</Label>
<Switch
checked={formState.securityEnabled}
onCheckedChange={(checked: boolean) =>
setFormState((prev) => ({
...prev,
securityEnabled: checked,
}))
}
/>
</div>
{formState.securityEnabled
? selectedChannel.security.fields.map((field) => (
<div className="grid gap-2" key={field.key}>
<Label>{field.label}</Label>
<Input
onChange={(event) =>
updateSecurityFieldValue(
field.key,
event.target.value,
)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.securityValues[field.key] ?? ""}
/>
</div>
))
: null}
</div>
) : null}
{formError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
) : null}
</div>
<DialogFooter>
<Button
disabled={busyChannel !== null}
onClick={() => setDialogOpen(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={busyChannel !== null || !selectedChannel}
onClick={() => void startConnector()}
type="button"
>
{busyChannel ? "Starting..." : "Add Channel"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={removeTarget !== null}
onOpenChange={(open: boolean) => {
if (!open) {
setRemoveTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Channel</AlertDialogTitle>
<AlertDialogDescription>
Confirm that you want to stop the active{" "}
{removeTarget ? connectorName(removeTarget, channels) : "channel"}{" "}
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyChannel !== null}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={busyChannel !== null || !removeTarget}
onClick={() => {
if (removeTarget) {
void stopConnector(removeTarget);
}
}}
className={buttonVariants({ variant: "destructive" })}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageFrame>
);
}
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -34,7 +35,6 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
type McpTransportType = "stdio" | "sse" | "streamableHttp";
@@ -203,10 +203,7 @@ export function McpServersContent() {
}, [applyResponse]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void refreshServers();
}, 0);
return () => window.clearTimeout(timeoutId);
void refreshServers();
}, [refreshServers]);
const toggleServer = async (server: McpServer, disabled: boolean) => {
@@ -405,24 +402,18 @@ export function McpServersContent() {
};
return (
<PageFrame>
<PageHeader
description={
hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."
}
title="MCP Servers"
meta={
<>
<CommandBadge>cline config mcp</CommandBadge>
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
MCP Servers
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
From settings file
</span>
</>
}
actions={
<>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -432,138 +423,151 @@ export function McpServersContent() {
<RefreshCw
className={cn("h-4 w-4", isLoading && "animate-spin")}
/>
Refresh
</Button>
<Button size="sm" onClick={openCreateDialog}>
<Plus className="h-4 w-4" />
Add MCP Server
</Button>
</>
}
/>
</div>
</div>
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
</div>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
</div>
)}
<p className="mb-6 text-xs text-muted-foreground">
{hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."}
</p>
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">
Command:
</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers &&
Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">
Headers:
</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">Command:</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers && Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">Headers:</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<Dialog
open={editorOpen}
onOpenChange={(open) => {
@@ -848,6 +852,6 @@ export function McpServersContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageFrame>
</ScrollArea>
);
}
@@ -2,21 +2,18 @@
import {
ArrowLeft,
ChevronRight,
Copy,
Eye,
EyeOff,
FileIcon,
ImageIcon,
Link as LinkIcon,
Loader2,
Paperclip,
PlusCircle,
RefreshCw,
Search,
Settings2,
Star,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -85,146 +82,94 @@ function coerceFieldValue(
return trimmed;
}
function assignSettingsPath(
target: Record<string, unknown>,
path: string,
value: ProviderConfigFieldPrimitive,
) {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return;
let cursor = target;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const last = segments.at(-1);
if (last) {
cursor[last] = value;
}
}
export function toSettingsPatch(
values: Record<string, ProviderConfigFieldPrimitive>,
): Record<string, unknown> {
const settings: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
assignSettingsPath(settings, path, value);
}
return settings;
}
export function ProviderListContent({
providers,
onToggle,
onConfigure,
onAddProvider,
selectedProviderId,
variant = "page",
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
selectedProviderId?: string | null;
variant?: "page" | "panel";
}) {
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
const [providerSearch, setProviderSearch] = useState("");
const enabledProviderCount = providers.filter(
(provider) => provider.enabled,
).length;
const providerSearchQuery = providerSearch.trim().toLowerCase();
const filteredProviders = providerSearchQuery
? providers.filter((provider) =>
provider.name.toLowerCase().includes(providerSearchQuery),
)
: providers;
const isPanel = variant === "panel";
return (
<ScrollArea className="h-full">
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
)}
>
<div
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="min-w-0">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
Model Providers
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
{providers.length} available &middot; {enabledProviderCount}{" "}
enabled
</p>
</div>
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
<Button
aria-label="Search providers"
className="size-8 rounded-md"
onClick={() => setProviderSearchOpen((open) => !open)}
size="icon-sm"
type="button"
variant={providerSearchOpen ? "default" : "secondary"}
>
<Search className="size-4" />
</Button>
<Button
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
onClick={onAddProvider}
type="button"
>
<PlusCircle className="size-4" />
Add provider
</Button>
</div>
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Model Providers
</h2>
<Button
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
onClick={onAddProvider}
variant="ghost"
>
<PlusCircle className="h-4 w-4" />
Add Provider
</Button>
</div>
{providerSearchOpen ? (
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search model providers"
autoFocus
className="h-7 border-0 bg-transparent px-0 text-sm"
onChange={(event) => setProviderSearch(event.target.value)}
placeholder="Search providers"
value={providerSearch}
/>
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
{filteredProviders.length === 0 ? (
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
No providers match "{providerSearch.trim()}".
</div>
) : null}
{filteredProviders.map((prov) => (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
{providers.map((prov) => (
<div
className={cn(
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
selectedProviderId === prov.id && "bg-accent/45",
)}
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
key={prov.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{prov.name}
</p>
<p className="shrink-0 text-[15px] text-muted-foreground">
<p className="text-xs text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
</p>
</button>
</div>
<Button
aria-label={`Configure ${prov.name}`}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onConfigure(prov.id)}
variant="ghost"
>
<Settings2 className="h-4 w-4" />
</Button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
<button
aria-label={`Configure ${prov.name}`}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<ChevronRight className="size-4" />
</button>
</div>
))}
</div>
@@ -242,7 +187,6 @@ export function ProviderDetailContent({
modelsError,
onOAuthLogin,
oauthLoginPending = false,
variant = "page",
}: {
provider: Provider;
onBack: () => void;
@@ -252,49 +196,18 @@ export function ProviderDetailContent({
modelsError?: string | null;
onOAuthLogin?: () => void;
oauthLoginPending?: boolean;
variant?: "page" | "panel";
}) {
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
Record<string, ProviderConfigFieldPrimitive>
>(() => getInitialConfigValues(provider));
const [modelSearchState, setModelSearchState] = useState<{
providerId: string;
value: string;
} | null>(null);
const [copiedModelState, setCopiedModelState] = useState<{
modelId: string;
providerId: string;
} | null>(null);
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
useEffect(() => {
setLocalConfigValues(getInitialConfigValues(provider));
}, [provider]);
const configFields = provider.configFields ?? [];
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
const modelList = provider.modelList ?? [];
const modelSearch =
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
const copiedModelId =
copiedModelState?.providerId === provider.id
? copiedModelState.modelId
: null;
const modelSearchQuery = modelSearch.trim().toLowerCase();
const filteredModelList = modelSearchQuery
? modelList.filter(
(model) =>
model.name.toLowerCase().includes(modelSearchQuery) ||
model.id.toLowerCase().includes(modelSearchQuery),
)
: modelList;
const isPanel = variant === "panel";
useEffect(
() => () => {
if (copiedModelTimeoutRef.current !== undefined) {
window.clearTimeout(copiedModelTimeoutRef.current);
}
},
[],
);
const commitField = (
field: ProviderConfigField,
@@ -319,83 +232,46 @@ export function ProviderDetailContent({
onUpdate(updates);
};
const copyModelId = (modelId: string) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(modelId).then(() => {
setCopiedModelState({ modelId, providerId: provider.id });
if (copiedModelTimeoutRef.current !== undefined) {
window.clearTimeout(copiedModelTimeoutRef.current);
}
copiedModelTimeoutRef.current = window.setTimeout(
() => setCopiedModelState(null),
1600,
);
});
};
return (
<ScrollArea className="h-full">
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
)}
>
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Back + title */}
<div className="mb-8 flex items-center gap-3">
<Button
aria-label={
isPanel ? "Close provider details" : "Back to providers"
}
aria-label="Back to providers"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={onBack}
variant="ghost"
>
{isPanel ? (
<X className="h-4 w-4" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
<ArrowLeft className="h-4 w-4" />
</Button>
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
<h2 className="text-lg font-semibold text-foreground">
{provider.name}
</h1>
</h2>
</div>
{configFields.length > 0 ? (
<section
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
>
<div className="flex flex-col">
<section className="mb-8">
<div className="flex flex-col gap-5">
{configFields.map((field) => {
const value = localConfigValues[field.path];
const valueText = fieldValueToString(value);
const isSecret = field.type === "password" || field.secret;
const isShown = shownSecrets[field.path] ?? false;
return (
<div
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
key={field.path}
>
<header>
<h3 className="text-[17px] font-semibold text-foreground">
<div key={field.path}>
<header className="mb-2">
<h3 className="text-sm font-semibold text-foreground">
{field.label}
</h3>
{field.description ? (
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{field.description}
</p>
) : null}
</header>
{field.type === "boolean" ? (
<div className="flex items-center justify-end">
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
@@ -408,7 +284,7 @@ export function ProviderDetailContent({
</div>
) : field.type === "select" ? (
<select
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
commitField(field, event.target.value)
}
@@ -425,12 +301,12 @@ export function ProviderDetailContent({
))}
</select>
) : (
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
{field.type === "url" ? (
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
<Input
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
onBlur={() => commitField(field, valueText)}
onChange={(event) =>
setLocalConfigValues((current) => ({
@@ -516,18 +392,10 @@ export function ProviderDetailContent({
) : null}
{/* Models section */}
<section
className={cn(
"overflow-hidden rounded-lg border",
isPanel ? "max-w-none" : "max-w-[46rem]",
)}
>
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
<h2 className="text-[17px] font-medium text-muted-foreground">
Models
</h2>
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<div className="flex items-center gap-1">
<Search className="size-4 text-muted-foreground" />
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
@@ -546,83 +414,39 @@ export function ProviderDetailContent({
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-destructive">{modelsError}</p>
</div>
) : modelList.length > 0 ? (
<div className="space-y-3">
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search models"
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
onChange={(event) =>
setModelSearchState({
providerId: provider.id,
value: event.target.value,
})
}
placeholder="Search models by name or ID"
spellCheck={false}
value={modelSearch}
/>
</div>
{filteredModelList.length > 0 ? (
<div className="max-h-125 overflow-y-scroll border-t">
{filteredModelList.map((model) => (
<div
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
<div className="min-w-0 flex-1 font-mono">
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
<span className="truncate">{model.name}</span>
{/* Capability icons */}
{model.supportsAttachments && (
<div title="File Support">
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
{model.supportsVision && (
<div title="Image Support">
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
</div>
<button
aria-label={`Copy model ID ${model.id}`}
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => copyModelId(model.id)}
title="Copy model ID"
type="button"
>
<span className="min-w-0 truncate">{model.id}</span>
<Copy className="size-3 shrink-0" />
{copiedModelId === model.id ? (
<span className="shrink-0 text-foreground">
Copied
</span>
) : null}
</button>
</div>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
) : provider.modelList && provider.modelList.length > 0 ? (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
{provider.modelList.map((model) => (
<div
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
{/* Model name */}
<span className="flex-1 text-sm text-foreground font-mono">
<div className="flex items-center gap-1.5">
{model.name}
{/* Capability icons */}
{model.supportsAttachments && (
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
)}
{model.supportsVision && (
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
))}
</span>
{/* Action icons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
aria-label={`Favorite ${model.name}`}
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
>
<Star className="h-3.5 w-3.5" />
</Button>
</div>
</div>
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
No models match "{modelSearch.trim()}".
</p>
</div>
)}
))}
</div>
) : (
<div className="rounded-lg border border-border px-4 py-8 text-center">
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
import type { ProviderConfigFieldPrimitive } from "@/lib/provider-schema";
function assignSettingsPath(
target: Record<string, unknown>,
path: string,
value: ProviderConfigFieldPrimitive,
) {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return;
let cursor = target;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const last = segments.at(-1);
if (last) {
cursor[last] = value;
}
}
export function toSettingsPatch(
values: Record<string, ProviderConfigFieldPrimitive>,
): Record<string, unknown> {
const settings: Record<string, unknown> = {};
for (const [path, value] of Object.entries(values)) {
assignSettingsPath(settings, path, value);
}
return settings;
}
@@ -1,8 +1,9 @@
import { X } from "lucide-react";
"use client";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
import type {
Provider,
@@ -10,25 +11,20 @@ import type {
ProviderModelsResponse,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import {
type HubTheme,
readStoredHubTheme,
readSystemHubTheme,
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { CustomizationSectionView, RulesView } from "./extensions-view";
import { primeExtensionsListsCache, RulesView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
import {
ProviderDetailContent,
ProviderListContent,
toSettingsPatch,
} from "./provider-list-view";
import { RoutineSchedulesContent } from "./routine-view";
import { toSettingsPatch } from "./settings-patch";
import {
primeRoutineOverviewCache,
RoutineSchedulesContent,
} from "./routine-view";
// -----------------------------------------------------------
// Settings nav categories
@@ -37,15 +33,14 @@ import { toSettingsPatch } from "./settings-patch";
const navCategories = [
"General",
"Providers",
"MCP",
"Marketplace",
"Extensions",
"Channels",
"Schedules",
"MCP",
"Routine",
"Features",
"Account",
] as const;
export type SettingsSection = (typeof navCategories)[number];
type NavCategory = (typeof navCategories)[number];
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
@@ -58,18 +53,9 @@ let providerCatalogCache: {
// Component
// -----------------------------------------------------------
export function SettingsView({
chrome = "full",
initialSection = "General",
onClose,
onNavigateSection,
}: {
chrome?: "full" | "content";
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
export function SettingsView({ onClose }: { onClose: () => void }) {
const [activeNav, setActiveNav] = useState<NavCategory>("Providers");
const [providersExpanded, setProvidersExpanded] = useState(true);
const [providers, setProviders] = useState<Provider[]>(
() => providerCatalogCache?.providers ?? [],
);
@@ -139,14 +125,14 @@ export function SettingsView({
}, [setProvidersWithCache]);
useEffect(() => {
if (activeNav !== "Providers") {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderCatalog();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [activeNav, loadProviderCatalog]);
void loadProviderCatalog();
void primeRoutineOverviewCache().catch(() => {
// Keep settings responsive even if routine prefetch fails.
});
void primeExtensionsListsCache().catch(() => {
// Keep settings responsive even if extension prefetch fails.
});
}, [loadProviderCatalog]);
const persistProviderSettings = useCallback(
async (
@@ -251,12 +237,13 @@ export function SettingsView({
[setProvidersWithCache],
);
const enabledProviders = providers.filter((p) => p.enabled);
const selectedProvider = selectedProviderId
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -289,7 +276,6 @@ export function SettingsView({
const openProviderDetail = (id: string) => {
setActiveNav("Providers");
onNavigateSection?.("Providers");
setSelectedProviderId(id);
};
@@ -303,14 +289,10 @@ export function SettingsView({
if (!selected || (selected.modelList?.length ?? 0) > 0) {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderModels(selectedProviderId);
}, 0);
return () => window.clearTimeout(timeoutId);
void loadProviderModels(selectedProviderId);
}, [loadProviderModels, providers, selectedProviderId]);
const backToProviderList = () => {
onNavigateSection?.("Providers");
setSelectedProviderId(null);
setAddingProvider(false);
};
@@ -337,102 +319,10 @@ export function SettingsView({
);
const openAddProvider = () => {
onNavigateSection?.("Providers");
setSelectedProviderId(null);
setAddingProvider(true);
};
const selectSection = (section: SettingsSection) => {
setActiveNav(section);
onNavigateSection?.(section);
setSelectedProviderId(null);
setAddingProvider(false);
};
const providerContent = addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading providers...</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : selectedProvider ? (
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
/>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
usesOAuth(selectedProvider)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
provider={selectedProvider}
variant="panel"
/>
</aside>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
);
const content =
activeNav === "Providers" ? (
providerContent
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Marketplace" ? (
<CustomizationSectionView catalogPrimitive="mcp" section="MCP" />
) : activeNav === "Extensions" ? (
<RulesView />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
);
if (chrome === "content") {
return (
<div className="h-full overflow-hidden bg-background">{content}</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
@@ -454,68 +344,144 @@ export function SettingsView({
<nav className="w-56 shrink-0 border-r border-border">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-3">
{navCategories.map((cat) => (
<Button
className={cn(
"justify-start",
activeNav === cat
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
key={cat}
onClick={() => {
selectSection(cat);
}}
variant="ghost"
>
{cat}
</Button>
))}
{navCategories.map((cat) => {
if (cat === "Providers") {
return (
<div key={cat}>
<Button
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
activeNav === "Providers"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
onClick={() => {
setActiveNav("Providers");
setSelectedProviderId(null);
setAddingProvider(false);
setProvidersExpanded((p) => !p);
}}
variant="ghost"
>
<span>Providers</span>
{providersExpanded ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</Button>
{providersExpanded && (
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-border pl-2">
{enabledProviders.map((prov) => (
<Button
className={cn(
"justify-start",
selectedProviderId === prov.id
? "bg-accent/80 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/30",
)}
disabled={oauthSigningProviderId === prov.id}
key={prov.id}
onClick={() => openProviderDetail(prov.id)}
variant="ghost"
>
<span className="truncate">{prov.name}</span>
</Button>
))}
</div>
)}
</div>
);
}
return (
<Button
className={cn(
"justify-start",
activeNav === cat && !selectedProviderId
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
key={cat}
onClick={() => {
setActiveNav(cat);
setSelectedProviderId(null);
setAddingProvider(false);
}}
variant="ghost"
>
{cat}
</Button>
);
})}
</div>
</ScrollArea>
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">{content}</div>
<div className="flex-1 overflow-hidden">
{activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={
modelsLoadingByProvider[selectedProvider.id] ?? false
}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) =>
updateProvider(selectedProvider.id, updates)
}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading providers...
</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Routine" ? (
<RoutineSchedulesContent />
) : activeNav === "Extensions" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
)}
</div>
</div>
</div>
);
}
function GeneralSettingsContent() {
const [theme, setTheme] = useState<HubTheme>(() => {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
});
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
};
return (
<PageFrame>
<PageHeader
description="Manage desktop preferences for this browser and CLI environment."
title="Settings"
/>
<section className="max-w-[86rem]">
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Dark mode
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Keep the desktop interface in dark mode on this browser.
</p>
</div>
<Switch
aria-label="Dark mode"
checked={theme === "dark"}
onCheckedChange={updateTheme}
/>
</div>
</section>
</PageFrame>
);
}
@@ -28,8 +28,6 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
mode: "act",
systemPrompt: undefined,
maxIterations: undefined,
thinking: undefined,
reasoningEffort: undefined,
enableTools: true,
enableSpawn: undefined,
enableTeams: undefined,
@@ -116,13 +116,10 @@ export function normalizeRuntimeConfig(
): ChatSessionConfig {
const normalizedWorkspaceRoot = config.workspaceRoot.trim();
const normalizedCwd = (config.cwd?.trim() || normalizedWorkspaceRoot).trim();
const thinking = config.reasoningEffort ? true : config.thinking;
return {
...config,
workspaceRoot: normalizedWorkspaceRoot,
cwd: normalizedCwd || normalizedWorkspaceRoot,
thinking,
reasoningEffort: thinking === false ? undefined : config.reasoningEffort,
enableSpawn: false,
enableTeams: false,
};
@@ -101,11 +101,7 @@ export type ChatWsChunkEvent = {
event: AgentChunkEvent;
};
export type ChatTransportState =
| "connecting"
| "reconnecting"
| "connected"
| "unavailable";
export type ChatTransportState = "connecting" | "reconnecting" | "connected";
export type CoreLogChunk = {
level?: string;
@@ -34,7 +34,6 @@ import {
import { desktopClient } from "@/lib/desktop-client";
import {
buildSessionDiffState,
type SessionHookEvent,
EMPTY_DIFF_SUMMARY,
type SessionDiffSummary,
type SessionFileDiff,
@@ -242,9 +241,6 @@ export function useChatSession() {
const hydrationRequestIdRef = useRef(0);
const [chatTransportState, setChatTransportState] =
useState<ChatTransportState>(desktopClient.getTransportState());
const [chatTransportError, setChatTransportError] = useState<string | null>(
desktopClient.getTransportError(),
);
// ---- Ref syncs ----
useEffect(() => {
@@ -497,50 +493,6 @@ export function useChatSession() {
void refreshPromptsInQueue(sessionId);
}, [refreshPromptsInQueue, refreshSessionDiffSummary, sessionId]);
// Fallback for sessions with no tool events in the hook log (e.g. sessions
// recorded before tool_call/tool_result hook logging existed): rebuild the
// diff state from the tool messages themselves.
useEffect(() => {
if (!sessionId || fileDiffs.length > 0) {
return;
}
const events: SessionHookEvent[] = [];
for (const message of messages) {
if (message.sessionId !== sessionId || message.role !== "tool") {
continue;
}
let payload: {
toolName?: string;
input?: unknown;
result?: unknown;
isError?: boolean;
} | null = null;
try {
payload = JSON.parse(message.content);
} catch {
continue;
}
if (!payload?.toolName || payload.result == null || payload.isError) {
continue;
}
events.push({
hookName: "tool_result",
toolName: payload.toolName,
toolInput: payload.input,
toolOutput: payload.result,
});
}
if (events.length === 0) {
return;
}
const diffState = buildSessionDiffState(events);
if (diffState.fileDiffs.length === 0) {
return;
}
setFileDiffs(diffState.fileDiffs);
setDiffSummary(diffState.summary);
}, [sessionId, messages, fileDiffs.length]);
useEffect(() => {
const activeSessionId = sessionId;
if (!activeSessionId) {
@@ -838,10 +790,7 @@ export function useChatSession() {
useEffect(() => {
const unsubscribeTransport = desktopClient.subscribeTransportState(
(state) => {
setChatTransportState(state);
setChatTransportError(desktopClient.getTransportError());
},
setChatTransportState,
);
const unsubscribeEvents = desktopClient.subscribe(
"chat_event",
@@ -1720,7 +1669,6 @@ export function useChatSession() {
sessionId,
status,
chatTransportState,
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
config,
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,6 @@ export const ChatSessionConfigSchema = z.object({
systemPrompt: z.string().optional(),
rules: z.string().optional(),
maxIterations: z.number().int().positive().optional(),
thinking: z.boolean().optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
enableTools: z.boolean(),
enableSpawn: z.boolean().optional(),
enableTeams: z.boolean().optional(),
@@ -18,9 +18,8 @@ async function tryTauriInvoke<T>(
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(command, args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Tauri invoke failed for ${command}: ${message}`);
} catch {
throw new Error(`Tauri invoke unavailable for command: ${command}`);
}
}
@@ -38,7 +37,7 @@ let resolvedEndpointCache: string | null = null;
* 3. Fallback to `ws://127.0.0.1:3126/transport` the sidecar's default port
* when running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
*/
export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
async function resolveBackendEndpoint(): Promise<string> {
if (resolvedEndpointCache) return resolvedEndpointCache;
// 1. Explicit injection from sidecar or test harness.
@@ -52,7 +51,7 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
}
// 2. Tauri command (full desktop app).
if (isTauriAvailable()) {
try {
const endpoint = await tryTauriInvoke<string>(
"get_desktop_backend_endpoint",
);
@@ -61,26 +60,15 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
resolvedEndpointCache = trimmed;
return resolvedEndpointCache;
}
throw new Error("Tauri returned an empty desktop backend endpoint");
} catch {
// Tauri not available — fall through to default.
}
// 3. Default sidecar port for local dev mode without the Tauri bridge.
// 3. Default sidecar port for local dev mode.
resolvedEndpointCache = "ws://127.0.0.1:3126/transport";
return resolvedEndpointCache;
}
export async function resolveDesktopBackendHttpEndpoint(): Promise<string> {
const wsEndpoint = await resolveDesktopBackendWsEndpoint();
const endpoint = new URL(wsEndpoint);
endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:";
if (endpoint.pathname.endsWith("/transport")) {
endpoint.pathname = endpoint.pathname.slice(0, -"/transport".length);
}
endpoint.search = "";
endpoint.hash = "";
return endpoint.toString().replace(/\/$/, "");
}
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
@@ -114,7 +102,6 @@ class DesktopClient {
private handlers = new Map<string, Set<EventHandler>>();
private transportStateHandlers = new Set<TransportStateHandler>();
private transportState: DesktopTransportState = "connecting";
private transportError: string | null = null;
private hasConnectedOnce = false;
private endpoint: string | null = null;
@@ -129,7 +116,7 @@ class DesktopClient {
if (this.endpoint?.trim()) {
return this.endpoint;
}
const endpoint = await resolveDesktopBackendWsEndpoint();
const endpoint = await resolveBackendEndpoint();
this.endpoint = endpoint;
return this.endpoint;
}
@@ -216,7 +203,6 @@ class DesktopClient {
this.socket = socket;
socket.onopen = () => {
this.hasConnectedOnce = true;
this.transportError = null;
this.setTransportState("connected");
resolve();
};
@@ -231,9 +217,7 @@ class DesktopClient {
this.socket = null;
}
if (this.transportState !== "connected") {
reject(
new Error(`Desktop backend transport unavailable at ${endpoint}`),
);
reject(new Error("Desktop backend transport unavailable"));
return;
}
this.setTransportState("reconnecting");
@@ -241,18 +225,9 @@ class DesktopClient {
this.scheduleReconnect();
};
});
})()
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.transportError = message;
if (!this.hasConnectedOnce) {
this.setTransportState("unavailable");
}
throw error;
})
.finally(() => {
this.connectPromise = null;
});
})().finally(() => {
this.connectPromise = null;
});
return this.connectPromise;
}
@@ -332,10 +307,6 @@ class DesktopClient {
getTransportState(): DesktopTransportState {
return this.transportState;
}
getTransportError(): string | null {
return this.transportError;
}
}
export const desktopClient = new DesktopClient();
@@ -25,11 +25,7 @@ export type DesktopTransportMessage =
| DesktopTransportResponse
| DesktopTransportEvent;
export type DesktopTransportState =
| "connecting"
| "reconnecting"
| "connected"
| "unavailable";
export type DesktopTransportState = "connecting" | "reconnecting" | "connected";
export type DesktopBackendReadyPayload = {
endpoint: string;
@@ -1,222 +0,0 @@
"use client";
import { resolveDesktopBackendHttpEndpoint } from "@/lib/desktop-client";
export type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
export type MarketplaceTag = {
id: string;
label: string;
count: number;
};
export type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
install: {
args: string[];
env?: MarketplaceEnvVar[];
notes?: string;
command: string;
};
};
export type MarketplaceCatalog = {
version: number;
generatedAt?: string;
baseUrl?: string;
counts: {
total: number;
plugins: number;
skills: number;
mcps: number;
};
tags: MarketplaceTag[];
entries: MarketplaceEntry[];
};
const MARKETPLACE_CATALOG_URL = "/api/marketplace/catalog";
const EMPTY_CATALOG: MarketplaceCatalog = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function parseCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function parseEnv(value: unknown): MarketplaceEnvVar[] | undefined {
if (!Array.isArray(value)) return undefined;
const env = value
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null);
return env.length > 0 ? env : undefined;
}
export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
const urls = [MARKETPLACE_CATALOG_URL];
try {
const backendEndpoint = await resolveDesktopBackendHttpEndpoint();
urls.unshift(new URL(MARKETPLACE_CATALOG_URL, `${backendEndpoint}/`).href);
} catch {
// Fall back to the statically exported route when the sidecar is unavailable.
}
let data: unknown;
let lastError: unknown;
for (const url of urls) {
try {
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace: ${response.status}`);
}
data = await response.json();
break;
} catch (error) {
lastError = error;
}
}
if (data === undefined) {
throw lastError instanceof Error
? lastError
: new Error("Failed to fetch marketplace");
}
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
const rawCounts =
typeof data?.counts === "object" && data.counts !== null ? data.counts : {};
const tags: MarketplaceTag[] = Array.isArray(data?.tags)
? data.tags
.map((tag: unknown) => {
if (!tag || typeof tag !== "object") return null;
const candidate = tag as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.label !== "string"
) {
return null;
}
return {
id: candidate.id,
label: candidate.label,
count: parseCount(candidate.count),
};
})
.filter(
(tag: MarketplaceTag | null): tag is MarketplaceTag => tag !== null,
)
: [];
const entries: MarketplaceEntry[] = Array.isArray(data?.entries)
? data.entries
.map((entry: unknown) => {
if (!entry || typeof entry !== "object") return null;
const candidate = entry as Record<string, unknown>;
const install =
typeof candidate.install === "object" && candidate.install !== null
? (candidate.install as Record<string, unknown>)
: {};
if (
typeof candidate.id !== "string" ||
!isPrimitiveType(candidate.type) ||
typeof candidate.name !== "string" ||
typeof candidate.tagline !== "string" ||
typeof candidate.description !== "string" ||
typeof install.command !== "string"
) {
return null;
}
return {
id: candidate.id,
type: candidate.type,
name: candidate.name,
featured:
typeof candidate.featured === "boolean"
? candidate.featured
: undefined,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
install: {
args: toStringArray(install.args),
command: install.command,
env: parseEnv(install.env),
notes:
typeof install.notes === "string" ? install.notes : undefined,
},
};
})
.filter(
(entry: MarketplaceEntry | null): entry is MarketplaceEntry =>
entry !== null && entry.install.args.length > 0,
)
: [];
return {
version: parseCount(data?.version) || EMPTY_CATALOG.version,
generatedAt:
typeof data?.generatedAt === "string" ? data.generatedAt : undefined,
baseUrl,
counts: {
total: parseCount(rawCounts.total) || entries.length,
plugins: parseCount(rawCounts.plugins),
skills: parseCount(rawCounts.skills),
mcps: parseCount(rawCounts.mcps),
},
tags,
entries,
};
}
export { EMPTY_CATALOG, MARKETPLACE_CATALOG_URL };
@@ -46,7 +46,6 @@ export interface Provider {
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
capabilities?: string[];
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
@@ -59,39 +59,6 @@ function toStringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* Tool outputs arrive in several shapes depending on the source: a plain
* record (live hook events), a JSON-encoded string, or a list of content
* blocks such as [{ type: "text", text: "<json>" }] (persisted history).
*/
function normalizeToolOutput(value: unknown): Record<string, unknown> | null {
if (typeof value === "string") {
try {
return asRecord(JSON.parse(value));
} catch {
return null;
}
}
if (Array.isArray(value)) {
for (const entry of value) {
const record = asRecord(entry);
if (!record) {
continue;
}
if (typeof record.text === "string") {
const inner = normalizeToolOutput(record.text);
if (inner) {
return inner;
}
continue;
}
return record;
}
return null;
}
return asRecord(value);
}
function getHookEventName(event: SessionHookEvent): string {
return event.hookEventName ?? event.hookName ?? "";
}
@@ -134,7 +101,7 @@ function stripApplyPatchWrapperLines(lines: string[]): string[] {
return result;
}
export function parseApplyPatchInput(input: string): SessionFileDiff[] {
function parseApplyPatchInput(input: string): SessionFileDiff[] {
const lines = stripApplyPatchWrapperLines(
input.split("\n").map((line) => line.replace(/\r$/, "")),
);
@@ -365,20 +332,12 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
}
const input = asRecord(event.toolInput);
const output = normalizeToolOutput(event.toolOutput);
const output = asRecord(event.toolOutput);
if (!input || !output || output.success === false) {
return null;
}
// Current editor schema has no `command` field; derive the operation from
// the input shape (legacy `command` values still take precedence).
const command =
toStringValue(input.command) ??
(input.insert_line != null
? "insert"
: toStringValue(input.old_text) != null
? "str_replace"
: "create");
const command = toStringValue(input.command);
const pathFromInput = toStringValue(input.path);
const query = toStringValue(output.query);
const pathFromQuery = query?.includes(":")
@@ -389,10 +348,10 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
return null;
}
const resultText = toStringValue(output.result) ?? "";
if (command === "str_replace" && !resultText.startsWith("File created")) {
const parsed = parseDiffFromEditorResult(resultText);
if (command === "str_replace") {
const parsed = parseDiffFromEditorResult(
toStringValue(output.result) ?? "",
);
return {
path,
additions: parsed.additions,
@@ -401,12 +360,9 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
};
}
if (command === "create" || command === "insert" || command === "str_replace") {
if (command === "create" || command === "insert") {
const newContent =
toStringValue(input.new_text) ??
toStringValue(input.file_text) ??
toStringValue(input.new_str) ??
"";
toStringValue(input.file_text) ?? toStringValue(input.new_str) ?? "";
return {
path,
additions: countAddedLines(newContent),
@@ -436,15 +392,13 @@ function parseApplyPatchFileDiffs(event: SessionHookEvent): SessionFileDiff[] {
return [];
}
const output = normalizeToolOutput(event.toolOutput);
if (!output || output.success === false) {
const input = asRecord(event.toolInput);
const output = asRecord(event.toolOutput);
if (!input || !output || output.success === false) {
return [];
}
// apply_patch accepts either { input: string } or a raw patch string.
const patchInput =
toStringValue(event.toolInput) ??
toStringValue(asRecord(event.toolInput)?.input);
const patchInput = toStringValue(input.input);
if (!patchInput) {
return [];
}
@@ -1,56 +0,0 @@
export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
export type HubTheme = "light" | "dark";
export function readStoredHubTheme(): HubTheme | null {
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
}
export function readSystemHubTheme(): HubTheme {
const kind = document.body.dataset.vscodeThemeKind;
if (kind) {
return kind === "vscode-dark" || kind === "vscode-high-contrast"
? "dark"
: "light";
}
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export function applyHubTheme(theme: HubTheme): HubTheme {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
return theme;
}
export function syncHubTheme(): HubTheme {
return applyHubTheme(readStoredHubTheme() ?? readSystemHubTheme());
}
export function setStoredHubTheme(theme: HubTheme): HubTheme {
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
return applyHubTheme(theme);
}
/**
* Follow OS light/dark changes while the user has no stored preference.
* Returns a cleanup function that removes the listener.
*/
export function watchSystemHubTheme(
onChange?: (theme: HubTheme) => void,
): () => void {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!media) {
return () => {};
}
const handle = () => {
if (readStoredHubTheme() !== null) {
return;
}
onChange?.(applyHubTheme(readSystemHubTheme()));
};
media.addEventListener("change", handle);
return () => media.removeEventListener("change", handle);
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -13
View File
@@ -39,18 +39,17 @@ import type {
PendingPromptsServiceApi,
RuntimeHost,
RuntimeHostSubscribeOptions,
SessionConnectionRuntimeService,
SessionModelRuntimeService,
SessionUsageRuntimeService,
StartSessionInput,
StartSessionResult,
} from "./runtime/host/runtime-host";
import { compareCheckpointToWorkspace } from "./session/checkpoint-diff";
import {
FeatureFlagsService,
NoOpFeatureFlagsProvider,
} from "./services/feature-flags";
import { resolveCoreDistinctId } from "./services/telemetry/distinct-id";
import { compareCheckpointToWorkspace } from "./session/checkpoint-diff";
import type { CoreSessionEvent } from "./types/events";
import type { SessionHistoryRecord } from "./types/sessions";
@@ -70,10 +69,10 @@ export type {
ClineCoreOptions,
ClineCoreSettingsApi,
ClineCoreStartInput,
CompareCheckpointInput,
CompareCheckpointResult,
HubOptions,
RemoteOptions,
CompareCheckpointInput,
CompareCheckpointResult,
RestoreInput,
RestoreOptions,
RestoreResult,
@@ -621,13 +620,4 @@ export class ClineCore {
const service = this.host as RuntimeHostServiceExtensions;
return service.updateSessionModel?.(...args) ?? Promise.resolve();
};
/**
* Updates provider/model/reasoning connection options for subsequent turns in
* an active session.
*/
updateSessionConnection: SessionConnectionRuntimeService["updateSessionConnection"] =
(...args) => {
const service = this.host as RuntimeHostServiceExtensions;
return service.updateSessionConnection?.(...args) ?? Promise.resolve();
};
}
@@ -2,7 +2,6 @@ import type {
PendingPromptsRuntimeService,
PendingPromptsServiceApi,
RuntimeHost,
SessionConnectionRuntimeService,
SessionModelRuntimeService,
SessionUsageRuntimeService,
} from "../runtime/host/runtime-host";
@@ -28,7 +27,6 @@ export type RuntimeHostServiceExtensions = RuntimeHost &
Partial<
PendingPromptsRuntimeService &
SessionUsageRuntimeService &
SessionConnectionRuntimeService &
SessionModelRuntimeService
>;
@@ -6,6 +6,10 @@ import type {
CoreCompactionSummarizerConfig,
} from "../../types/config";
import type { ProviderConfig } from "../../types/provider-settings";
import {
buildBudgetProjection,
type BudgetProjectionResult,
} from "./budget-projection";
import {
buildSummaryMessage,
buildSummaryRequest,
@@ -20,6 +24,43 @@ import {
serializeConversation,
} from "./compaction-shared";
const MIN_AGENTIC_SUMMARY_INPUT_TOKENS = 1_024;
function resolveProviderMaxInputTokens(
providerConfig: ProviderConfig,
): number | undefined {
const explicit = providerConfig.maxInputTokens;
if (typeof explicit === "number" && Number.isFinite(explicit)) {
return explicit;
}
const modelInfoLimit =
providerConfig.modelInfo?.maxInputTokens ??
providerConfig.modelInfo?.contextWindow;
if (typeof modelInfoLimit === "number" && Number.isFinite(modelInfoLimit)) {
return modelInfoLimit;
}
const knownModelInfo = providerConfig.knownModels?.[providerConfig.modelId];
const knownModelLimit =
knownModelInfo?.maxInputTokens ?? knownModelInfo?.contextWindow;
if (typeof knownModelLimit === "number" && Number.isFinite(knownModelLimit)) {
return knownModelLimit;
}
return undefined;
}
export function buildAgenticSummaryInputBudget(options: {
messages: CoreCompactionContext["messages"];
targetTokens: number;
estimateMessageTokens: EstimateMessageTokens;
}): BudgetProjectionResult {
return buildBudgetProjection({
messages: options.messages,
targetTokens: Math.max(1, options.targetTokens),
policyIntent: "agentic_summary",
estimateMessageTokens: options.estimateMessageTokens,
});
}
async function generateSummary(options: {
providerConfig: ProviderConfig;
request: string;
@@ -92,8 +133,80 @@ export async function runAgenticCompaction(options: {
return undefined;
}
const fileOps = extractFileOps(messagesToSummarize);
const conversationText = serializeConversation(newMessagesToFold);
const preProjectionFileOps = extractFileOps(messagesToSummarize);
const summarizerProviderConfig = resolveSummarizerConfig({
activeProviderConfig: options.providerConfig,
summarizer: options.summarizer,
});
const resolvedSummarizerInputLimit = resolveProviderMaxInputTokens(
summarizerProviderConfig,
);
const canUseActiveContextLimit = options.summarizer === undefined;
const activeCompactionInputLimit = Math.max(
options.context.maxInputTokens,
options.context.triggerTokens,
MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
);
if (
resolvedSummarizerInputLimit === undefined &&
!canUseActiveContextLimit
) {
options.logger?.log(
"Agentic compaction summarizer has no known input limit; using conservative summary budget",
{
severity: "warn",
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
fallbackInputLimit: MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
},
);
}
const summarizerInputLimit =
resolvedSummarizerInputLimit ??
(canUseActiveContextLimit
? activeCompactionInputLimit
: MIN_AGENTIC_SUMMARY_INPUT_TOKENS);
const summaryRequestOverheadTokens = estimateTokens(
buildSummaryRequest({
previousSummary,
conversationText: "",
fileOps: preProjectionFileOps,
}).length,
);
const availableSummaryInputTokens =
summarizerInputLimit - summaryRequestOverheadTokens;
if (availableSummaryInputTokens <= 0) {
options.logger?.debug("Skipped agentic compaction: summarizer budget exhausted", {
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
summarizerInputLimit,
summaryRequestOverheadTokens,
});
return undefined;
}
const summaryInputBudget = buildAgenticSummaryInputBudget({
messages: newMessagesToFold,
targetTokens: availableSummaryInputTokens,
estimateMessageTokens: options.estimateMessageTokens,
});
if (summaryInputBudget.status === "failed") {
options.logger?.log(
"Skipped agentic compaction: summary input budget failed",
{
severity: "warn",
budgetWarnings: summaryInputBudget.warnings.map(
(warning) => warning.code,
),
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
targetTokens: availableSummaryInputTokens,
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
},
);
return undefined;
}
const fileOps = extractFileOps(summaryInputBudget.messages);
const conversationText = serializeConversation(summaryInputBudget.messages);
const summaryRequest = buildSummaryRequest({
previousSummary,
conversationText,
@@ -108,14 +221,20 @@ export async function runAgenticCompaction(options: {
summaryRequestChars: summaryRequest.length,
summaryRequestEstimatedTokens: estimateTokens(summaryRequest.length),
newMessagesJsonChars: safeJsonSize(newMessagesToFold),
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
summaryInputActions: summaryInputBudget.actions.length,
summaryInputWarnings: summaryInputBudget.warnings.map(
(warning) => warning.code,
),
summaryRequestOverheadTokens,
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
summarizerInputLimit,
maxInputTokens: options.context.maxInputTokens,
triggerTokens: options.context.triggerTokens,
});
const rawSummary = await generateSummary({
providerConfig: resolveSummarizerConfig({
activeProviderConfig: options.providerConfig,
summarizer: options.summarizer,
}),
providerConfig: summarizerProviderConfig,
request: summaryRequest,
logger: options.logger,
});
@@ -7,6 +7,7 @@ import type {
CoreCompactionContext,
CoreCompactionResult,
} from "../../types/config";
import { buildBudgetProjection } from "./budget-projection";
import {
DEFAULT_TARGET_RATIO,
type EstimateMessageTokens,
@@ -444,21 +445,46 @@ export function runBasicCompaction(options: {
...candidates.map((candidate) => candidate.message),
...protectedTail,
];
if (!haveMessagesChanged(options.context.messages, nextMessages)) {
const budgeted = buildBudgetProjection({
messages: nextMessages,
targetTokens: totalTargetTokens,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: options.estimateMessageTokens,
});
// This final projection owns the hard output budget. Unlike the earlier
// basic candidate passes, it may drop the original first-user message when
// preserving the latest typed prompt and coherent tool closures requires it.
if (budgeted.status === "failed") {
options.logger?.debug("Basic compaction returned best-effort projection", {
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
projectedTokens: budgeted.estimatedTokens,
targetTokens: totalTargetTokens,
maxInputTokens: options.context.maxInputTokens,
});
}
const resultMessages = budgeted.messages;
if (!haveMessagesChanged(options.context.messages, resultMessages)) {
return undefined;
}
const beforeTokens = beforeCompactableTokens + protectedTailTokens;
const afterTokens = totalTokens + protectedTailTokens;
const afterTokens = getTotalTokens(
resultMessages,
options.estimateMessageTokens,
);
options.logger?.debug("Performed basic compaction", {
messagesBefore: options.context.messages.length,
messagesAfter: nextMessages.length,
messagesRemoved: options.context.messages.length - nextMessages.length,
messagesAfter: resultMessages.length,
messagesRemoved: options.context.messages.length - resultMessages.length,
tokensBefore: beforeTokens,
tokensAfter: afterTokens,
budgetStatus: budgeted.status,
budgetActions: budgeted.actions.length,
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
targetTokens: totalTargetTokens,
maxInputTokens: options.context.maxInputTokens,
});
return { messages: nextMessages };
return { messages: resultMessages };
}
@@ -0,0 +1,17 @@
export {
buildBudgetProjection,
findLatestTypedUserMessageIndex,
} from "./project";
export type {
BlockBudgetClass,
BudgetAction,
BudgetActionKind,
BudgetActionReason,
BudgetPath,
BudgetPolicyIntent,
BudgetProjectionOptions,
BudgetProjectionResult,
BudgetProjectionWarning,
ContentBlockBudgetClassification,
LiveTailHandling,
} from "./types";
@@ -0,0 +1,476 @@
import type { MessageWithMetadata } from "@cline/shared";
import { describe, expect, it } from "vitest";
import {
buildBudgetProjection,
findLatestTypedUserMessageIndex,
} from "./project";
const estimateChars = (message: MessageWithMetadata) =>
JSON.stringify(message).length;
describe("buildBudgetProjection", () => {
it("fails explicitly for impossible budgets", () => {
const result = buildBudgetProjection({
messages: [{ role: "user", content: "keep me" }],
targetTokens: 0,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(result.status).toBe("failed");
expect(result.messages).toHaveLength(1);
expect(result.warnings[0]?.code).toBe("budget_impossible");
});
it("drops unsafe image and redacted thinking blocks instead of truncating them", () => {
const result = buildBudgetProjection({
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "old context" },
{
type: "redacted_thinking",
data: "x".repeat(500),
},
],
},
{
role: "assistant",
content: [
{
type: "image",
data: "y".repeat(500),
mediaType: "image/png",
},
],
},
{ role: "user", content: "latest task" },
],
targetTokens: 150,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).not.toContain("redacted_thinking");
expect(serialized).not.toContain("image/png");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
expect(result.liveTailHandling).toBe("included_degraded");
});
it("keeps unsafe blocks when input is already under budget", () => {
const result = buildBudgetProjection({
messages: [
{
role: "user",
content: [
{ type: "text", text: "look at this" },
{
type: "image",
data: "small-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(result.status).toBe("ok");
expect(result.actions).toEqual([]);
expect(result.liveTailHandling).toBe("included_verbatim");
expect(JSON.stringify(result.messages)).toContain("small-image");
});
it("preserves unsafe blocks in the latest typed user message", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{
role: "user",
content: [
{ type: "text", text: "what is in this image?" },
{
type: "image",
data: "live-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 120,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
expect(JSON.stringify(result.messages)).toContain("live-image");
expect(result.actions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "dropped_block" }),
]),
);
});
it("protects latest typed user after thinking-only messages are pruned", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task" },
{
role: "assistant",
content: [{ type: "thinking", thinking: "discard me" }],
},
{
role: "user",
content: [
{ type: "text", text: "what is in this image?" },
{
type: "image",
data: "live-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("live-image");
expect(serialized).not.toContain("discard me");
});
it("keeps tool-use and tool-result pairs coherent when dropping history", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "original task" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "read_files",
input: { file_paths: ["/tmp/a.ts"] },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read_files",
content: "x".repeat(1000),
},
],
},
{ role: "user", content: "latest task" },
],
targetTokens: 140,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).not.toContain("tool_1");
expect(serialized).toContain("latest task");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: "tool_pair_boundary" }),
]),
);
});
it("records budget action paths against original message indexes", () => {
const result = buildBudgetProjection({
messages: [
{
role: "assistant",
content: [{ type: "image", data: "x", mediaType: "image/png" }],
},
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "assistant", content: "old answer " + "y".repeat(500) },
{ role: "user", content: "latest task" },
],
targetTokens: 80,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "preserved",
path: expect.objectContaining({ messageIndex: 1 }),
}),
expect.objectContaining({
kind: "dropped_message",
path: expect.objectContaining({ messageIndex: 2 }),
}),
]),
);
});
it("detects the latest typed user message when tool results follow it", () => {
const messages: MessageWithMetadata[] = [
{ role: "user", content: "old task" },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_1", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read",
content: "result",
},
],
},
];
expect(findLatestTypedUserMessageIndex(messages)).toBe(1);
});
it("preserves the latest typed prompt under pressure", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read",
content: "result " + "y".repeat(500),
},
],
},
],
targetTokens: 120,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(JSON.stringify(result.messages)).toContain("latest typed prompt");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: "protected_live_tail" }),
]),
);
});
it("drops completed tool pairs after the latest typed prompt", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_after", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_after",
name: "read",
content: "huge result " + "y".repeat(2_000),
},
],
},
],
targetTokens: 140,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).not.toContain("tool_after");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_message",
reason: "tool_pair_boundary",
path: expect.objectContaining({ messageIndex: 2 }),
}),
expect.objectContaining({
kind: "dropped_message",
reason: "tool_pair_boundary",
path: expect.objectContaining({ messageIndex: 3 }),
}),
]),
);
});
it("preserves unresolved tool use after the latest typed prompt", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_live",
name: "run_command",
input: { command: "sleep 1" },
},
],
},
],
targetTokens: 80,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).toContain("tool_live");
expect(result.status).toBe("failed");
expect(result.warnings[0]?.code).toBe(
"budget_unachievable_with_protections",
);
});
it("does not preserve later text or file blocks after tool-result budget is exhausted", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_live", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_live",
name: "read",
content: [
{ type: "text", text: "a".repeat(200) },
{ type: "file", path: "/tmp/huge.txt", content: "b".repeat(1_000) },
],
},
],
},
],
targetTokens: 260,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).not.toContain("b".repeat(100));
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "truncated_text",
reason: "over_budget",
}),
]),
);
});
it("drops thinking blocks instead of mutating provider-native reasoning", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "text", text: "a".repeat(1_000) },
{ type: "thinking", thinking: "b".repeat(1_000) },
],
},
],
targetTokens: 900,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const assistant = result.messages.find(
(message) => message.role === "assistant",
);
expect(JSON.stringify(assistant)).not.toContain("b".repeat(100));
expect(JSON.stringify(assistant)).not.toContain("\"thinking\"");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
});
it("drops nested unsafe tool-result blocks outside the protected tail", () => {
const result = buildBudgetProjection({
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_old",
name: "read",
content: [
{ type: "text", text: "old output" },
{
type: "image",
data: "old-image-data",
mediaType: "image/png",
},
],
},
],
},
{ role: "user", content: "latest typed prompt" },
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("old output");
expect(serialized).not.toContain("old-image-data");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
});
});
@@ -0,0 +1,670 @@
import type {
ContentBlock,
MessageWithMetadata,
ToolResultContent,
} from "@cline/shared";
import type {
BudgetAction,
BudgetMutationAction,
BudgetProjectionOptions,
BudgetProjectionResult,
BudgetProjectionWarning,
BudgetPolicyIntent,
} from "./types";
type EstimateMessageTokens = (message: MessageWithMetadata) => number;
interface ProjectionPolicy {
protectLatestTypedUser: boolean;
protectLiveTailFromDrop: boolean;
dropUnsafeOutsideLiveTail: boolean;
dropThinkingBlocks: boolean;
}
function resolveProjectionPolicy(
intent: BudgetPolicyIntent,
): ProjectionPolicy {
switch (intent) {
case "agentic_summary":
case "basic_compaction_projection":
return {
protectLatestTypedUser: true,
protectLiveTailFromDrop: true,
dropUnsafeOutsideLiveTail: true,
dropThinkingBlocks: true,
};
case "normal_provider_request":
return {
protectLatestTypedUser: true,
protectLiveTailFromDrop: true,
dropUnsafeOutsideLiveTail: false,
dropThinkingBlocks: false,
};
}
}
function cloneMessages(messages: MessageWithMetadata[]): MessageWithMetadata[] {
return messages.map((message) => ({
...message,
content: Array.isArray(message.content)
? message.content.map((block) => ({ ...block }) as ContentBlock)
: message.content,
...(message.metadata ? { metadata: { ...message.metadata } } : {}),
}));
}
function safeJsonSize(value: unknown): number {
try {
return JSON.stringify(value).length;
} catch {
return String(value).length;
}
}
function totalTokens(
messages: MessageWithMetadata[],
estimateMessageTokens: EstimateMessageTokens,
): number {
return messages.reduce(
(total, message) => total + estimateMessageTokens(message),
0,
);
}
function isToolResultOnlyUserMessage(message: MessageWithMetadata): boolean {
return (
message.role === "user" &&
Array.isArray(message.content) &&
message.content.length > 0 &&
message.content.every((block) => block.type === "tool_result")
);
}
export function findLatestTypedUserMessageIndex(
messages: MessageWithMetadata[],
): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
return index;
}
}
return -1;
}
function findFirstTypedUserMessageIndex(
messages: MessageWithMetadata[],
): number {
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
return index;
}
}
return -1;
}
function collectToolIds(message: MessageWithMetadata): Set<string> {
const ids = new Set<string>();
if (!Array.isArray(message.content)) {
return ids;
}
for (const block of message.content) {
if (block.type === "tool_use") {
ids.add(block.id);
} else if (block.type === "tool_result") {
ids.add(block.tool_use_id);
}
}
return ids;
}
function buildToolPairIndex(
messages: MessageWithMetadata[],
): Map<string, Set<number>> {
const index = new Map<string, Set<number>>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
for (const id of collectToolIds(messages[messageIndex])) {
const existing = index.get(id);
if (existing) {
existing.add(messageIndex);
} else {
index.set(id, new Set([messageIndex]));
}
}
}
return index;
}
function findProtectedTailStartIndex(messages: MessageWithMetadata[]): number {
const resolvedToolUseIds = new Set<string>();
for (const message of messages) {
if (!Array.isArray(message.content)) {
continue;
}
for (const block of message.content) {
if (block.type === "tool_result") {
resolvedToolUseIds.add(block.tool_use_id);
}
}
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!Array.isArray(message.content)) {
continue;
}
if (
message.content.some(
(block) =>
block.type === "tool_use" && !resolvedToolUseIds.has(block.id),
)
) {
return index;
}
}
return messages.length;
}
function collectMessageClosure(
messages: MessageWithMetadata[],
startIndex: number,
): Set<number> {
const pairIndex = buildToolPairIndex(messages);
const removal = new Set<number>();
const queue = [startIndex];
while (queue.length > 0) {
const index = queue.shift();
if (index === undefined || removal.has(index)) {
continue;
}
removal.add(index);
for (const id of collectToolIds(messages[index])) {
for (const linked of pairIndex.get(id) ?? []) {
if (!removal.has(linked)) {
queue.push(linked);
}
}
}
}
return removal;
}
function isUnsafeBlock(block: ContentBlock): boolean {
return block.type === "image" || block.type === "redacted_thinking";
}
function isNestedUnsafeToolResultBlock(
block: Extract<ToolResultContent["content"], unknown[]>[number],
): boolean {
return block.type === "image";
}
function shouldDropWholeBlock(
block: ContentBlock,
policy: ProjectionPolicy,
isProtected: boolean,
): boolean {
if (policy.dropThinkingBlocks && block.type === "thinking") {
return true;
}
return policy.dropUnsafeOutsideLiveTail && !isProtected && isUnsafeBlock(block);
}
function pruneEmptyMessages(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
reason: BudgetMutationAction["reason"] = "over_budget",
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
const next: MessageWithMetadata[] = [];
const nextOriginalIndexes: number[] = [];
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (Array.isArray(message.content) && message.content.length === 0) {
actions.push({
kind: "dropped_message",
path: { messageIndex: originalIndexes[index] },
reason,
originalSize: safeJsonSize(message),
finalSize: 0,
});
continue;
}
next.push(message);
nextOriginalIndexes.push(originalIndexes[index]);
}
return { messages: next, originalIndexes: nextOriginalIndexes };
}
function dropUnsafeBlocks(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
latestTypedUserIndex: number,
protectedTailStartIndex: number,
policy: ProjectionPolicy,
): MessageWithMetadata[] {
return messages.map((message, messageIndex) => {
if (!Array.isArray(message.content)) {
return message;
}
let changed = false;
const protectedBlock =
messageIndex === latestTypedUserIndex ||
messageIndex >= protectedTailStartIndex;
const content = message.content.flatMap((block, blockIndex) => {
if (shouldDropWholeBlock(block, policy, protectedBlock)) {
changed = true;
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: 0,
});
return [];
}
if (block.type === "tool_result" && Array.isArray(block.content)) {
const nestedContent = block.content.filter((nestedBlock) => {
if (
policy.dropUnsafeOutsideLiveTail &&
!protectedBlock &&
isNestedUnsafeToolResultBlock(nestedBlock)
) {
return false;
}
return true;
});
if (nestedContent.length !== block.content.length) {
changed = true;
const nextBlock = { ...block, content: nestedContent };
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: safeJsonSize(nextBlock),
});
return [nextBlock];
}
}
return [block];
});
return changed ? { ...message, content } : message;
});
}
function dropThinkingBlocks(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
): MessageWithMetadata[] {
return messages.map((message, messageIndex) => {
if (!Array.isArray(message.content)) {
return message;
}
let changed = false;
const content = message.content.filter((block, blockIndex) => {
if (block.type !== "thinking") {
return true;
}
changed = true;
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: 0,
});
return false;
});
return changed ? { ...message, content } : message;
});
}
function truncateText(text: string, maxChars: number): string {
if (maxChars <= 0) {
return "";
}
if (text.length <= maxChars) {
return text;
}
if (maxChars <= 16) {
return text.slice(0, Math.max(1, maxChars));
}
const estimateMarker = `\n...[truncated ${text.length - maxChars} chars]`;
const keep = Math.max(1, maxChars - estimateMarker.length);
const marker = `\n...[truncated ${text.length - keep} chars]`;
return `${text.slice(0, keep)}${marker}`;
}
function truncateToolResultContent(
content: ToolResultContent["content"],
maxChars: number,
): ToolResultContent["content"] {
if (typeof content === "string") {
return truncateText(content, maxChars);
}
let remaining = maxChars;
return content.map((block) => {
if (remaining <= 0) {
if (block.type === "text") {
return { ...block, text: "" };
}
if (block.type === "file") {
return { ...block, content: "" };
}
return block;
}
if (block.type === "text") {
const text = truncateText(block.text, remaining);
remaining -= text.length;
return { ...block, text };
}
if (block.type === "file") {
const content = truncateText(block.content, remaining);
remaining -= content.length;
return { ...block, content };
}
return block;
});
}
function toolResultTextLength(content: ToolResultContent["content"]): number {
if (typeof content === "string") {
return content.length;
}
return content.reduce((total, block) => {
if (block.type === "text") {
return total + block.text.length;
}
if (block.type === "file") {
return total + block.content.length;
}
return total;
}, 0);
}
function truncateMessageText(
message: MessageWithMetadata,
maxChars: number,
): MessageWithMetadata {
if (typeof message.content === "string") {
return { ...message, content: truncateText(message.content, maxChars) };
}
let remaining = maxChars;
return {
...message,
content: message.content.map((block) => {
if (remaining <= 0) {
if (block.type === "text") {
return { ...block, text: "" };
}
if (block.type === "file") {
return { ...block, content: "" };
}
if (block.type === "tool_result") {
return {
...block,
content: truncateToolResultContent(block.content, 0),
};
}
return block;
}
if (block.type === "text") {
const text = truncateText(block.text, remaining);
remaining -= text.length;
return { ...block, text };
}
if (block.type === "file") {
const content = truncateText(block.content, remaining);
remaining -= content.length;
return { ...block, content };
}
if (block.type === "tool_result") {
const content = truncateToolResultContent(block.content, remaining);
remaining -= toolResultTextLength(content);
return { ...block, content };
}
return block;
}),
};
}
function hasTruncatableText(message: MessageWithMetadata): boolean {
if (typeof message.content === "string") {
return message.content.length > 0;
}
return message.content.some(
(block) =>
block.type === "text" ||
block.type === "file" ||
block.type === "tool_result",
);
}
function removeMessagesAt(
messages: MessageWithMetadata[],
originalIndexes: number[],
removal: Set<number>,
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
return {
messages: messages.filter((_, index) => !removal.has(index)),
originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)),
};
}
function closureTouchesProtectedTail(
closure: Set<number>,
protectedStartIndex: number,
): boolean {
if (protectedStartIndex < 0) {
return false;
}
for (const removalIndex of closure) {
if (removalIndex >= protectedStartIndex) {
return true;
}
}
return false;
}
function closureTouchesPinnedMessage(
closure: Set<number>,
pinnedIndex: number,
): boolean {
return pinnedIndex >= 0 && closure.has(pinnedIndex);
}
export function buildBudgetProjection(
options: BudgetProjectionOptions,
): BudgetProjectionResult {
const actions: BudgetAction[] = [];
const warnings: BudgetProjectionWarning[] = [];
const policy = resolveProjectionPolicy(options.policyIntent);
if (options.targetTokens <= 0) {
return {
status: "failed",
messages: cloneMessages(options.messages),
actions,
liveTailHandling: "preserved_out_of_band",
estimatedTokens: totalTokens(
options.messages,
options.estimateMessageTokens,
),
warnings: [
{
code: "budget_impossible",
message: "Target budget must be greater than zero.",
},
],
};
}
let messages = cloneMessages(options.messages);
let originalIndexes = messages.map((_, index) => index);
if (policy.dropThinkingBlocks) {
const prunedThinking = pruneEmptyMessages(
dropThinkingBlocks(messages, originalIndexes, actions),
originalIndexes,
actions,
"unsafe_to_truncate",
);
messages = prunedThinking.messages;
originalIndexes = prunedThinking.originalIndexes;
}
const latestTypedUserIndex = policy.protectLatestTypedUser
? findLatestTypedUserMessageIndex(messages)
: -1;
const protectedTailStartIndex = policy.protectLiveTailFromDrop
? findProtectedTailStartIndex(messages)
: messages.length;
if (policy.dropUnsafeOutsideLiveTail) {
const prunedUnsafe = pruneEmptyMessages(
dropUnsafeBlocks(
messages,
originalIndexes,
actions,
latestTypedUserIndex,
protectedTailStartIndex,
policy,
),
originalIndexes,
actions,
);
messages = prunedUnsafe.messages;
originalIndexes = prunedUnsafe.originalIndexes;
}
let estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
if (estimatedTokens <= options.targetTokens) {
return {
status: "ok",
messages,
actions,
liveTailHandling:
actions.length > 0 ? "included_degraded" : "included_verbatim",
estimatedTokens,
warnings,
};
}
for (
let index = messages.length - 1;
index >= 0 && estimatedTokens > options.targetTokens;
index -= 1
) {
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
if (index === latestTypedUserIndex) {
continue;
}
if (
policy.protectLiveTailFromDrop &&
index >= findProtectedTailStartIndex(messages)
) {
continue;
}
if (!hasTruncatableText(messages[index])) {
continue;
}
const originalSize = safeJsonSize(messages[index]);
const charsPerToken = Math.max(
1,
originalSize /
Math.max(1, options.estimateMessageTokens(messages[index])),
);
const targetChars = Math.max(
16,
Math.floor(
(options.targetTokens * charsPerToken) /
Math.max(1, messages.length),
),
);
messages[index] = truncateMessageText(messages[index], targetChars);
actions.push({
kind: "truncated_text",
path: { messageIndex: originalIndexes[index] },
reason: "over_budget",
originalSize,
finalSize: safeJsonSize(messages[index]),
});
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
}
for (
let index = 0;
index < messages.length && estimatedTokens > options.targetTokens;
) {
const firstTypedUserIndex = findFirstTypedUserMessageIndex(messages);
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
const protectedStartIndex = policy.protectLiveTailFromDrop
? findProtectedTailStartIndex(messages)
: messages.length;
if (index === firstTypedUserIndex || index === latestTypedUserIndex) {
actions.push({
kind: "preserved",
path: { messageIndex: originalIndexes[index] },
reason: "protected_live_tail",
originalSize: safeJsonSize(messages[index]),
finalSize: safeJsonSize(messages[index]),
});
index += 1;
continue;
}
const closure = collectMessageClosure(messages, index);
if (closureTouchesPinnedMessage(closure, firstTypedUserIndex)) {
index += 1;
continue;
}
if (closureTouchesPinnedMessage(closure, latestTypedUserIndex)) {
index += 1;
continue;
}
if (closureTouchesProtectedTail(closure, protectedStartIndex)) {
index += 1;
continue;
}
for (const removalIndex of closure) {
actions.push({
kind: "dropped_message",
path: { messageIndex: originalIndexes[removalIndex] },
reason:
closure.size > 1 || collectToolIds(messages[removalIndex]).size > 0
? "tool_pair_boundary"
: "over_budget",
originalSize: safeJsonSize(messages[removalIndex]),
finalSize: 0,
});
}
const removed = removeMessagesAt(messages, originalIndexes, closure);
messages = removed.messages;
originalIndexes = removed.originalIndexes;
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
}
if (estimatedTokens > options.targetTokens) {
warnings.push({
code: "budget_unachievable_with_protections",
message:
"Projection could not reach budget without violating protected content.",
});
return {
status: "failed",
messages,
actions,
liveTailHandling: "included_degraded",
estimatedTokens,
warnings,
};
}
return {
status: "ok",
messages,
actions,
liveTailHandling:
actions.length > 0 ? "included_degraded" : "included_verbatim",
estimatedTokens,
warnings,
};
}
@@ -0,0 +1,103 @@
import type { ContentBlock, MessageWithMetadata } from "@cline/shared";
export type BudgetPolicyIntent =
| "agentic_summary"
| "basic_compaction_projection"
| "normal_provider_request";
export type BudgetActionKind =
| "truncated_text"
| "dropped_block"
| "dropped_message"
| "preserved";
export type BudgetActionReason =
| "over_budget"
| "unsafe_to_truncate"
| "tool_pair_boundary"
| "protected_live_tail";
export type LiveTailHandling =
| "included_verbatim"
| "included_degraded"
| "summarized_as_context"
| "omitted_with_warning"
| "preserved_out_of_band";
export type BlockBudgetClass =
| "text"
| "thinking"
| "tool_use"
| "tool_result"
| "unsafe_binary"
| "unsafe_encrypted"
| "opaque";
export interface BudgetPath {
messageIndex: number;
blockIndex?: number;
}
interface BaseBudgetAction {
path: BudgetPath;
originalSize: number;
finalSize: number;
}
export type BudgetMutationAction =
| (BaseBudgetAction & {
kind: "truncated_text";
reason: Extract<BudgetActionReason, "over_budget">;
})
| (BaseBudgetAction & {
kind: "dropped_block";
path: Required<BudgetPath>;
reason: Exclude<BudgetActionReason, "protected_live_tail">;
})
| (BaseBudgetAction & {
kind: "dropped_message";
reason: Exclude<BudgetActionReason, "protected_live_tail">;
});
export interface BudgetPreservedAction extends BaseBudgetAction {
kind: "preserved";
reason: Extract<
BudgetActionReason,
"protected_live_tail" | "tool_pair_boundary"
>;
}
export type BudgetAction = BudgetMutationAction | BudgetPreservedAction;
export type BudgetProjectionWarningCode =
| "budget_impossible"
| "budget_unachievable_with_protections";
export interface BudgetProjectionWarning {
code: BudgetProjectionWarningCode;
message: string;
path?: BudgetPath;
}
export interface BudgetProjectionOptions {
messages: MessageWithMetadata[];
targetTokens: number;
policyIntent: BudgetPolicyIntent;
estimateMessageTokens: (message: MessageWithMetadata) => number;
}
export interface BudgetProjectionResult {
status: "ok" | "failed";
messages: MessageWithMetadata[];
actions: BudgetAction[];
liveTailHandling: LiveTailHandling;
estimatedTokens: number;
warnings: BudgetProjectionWarning[];
}
export interface ContentBlockBudgetClassification {
block: ContentBlock;
budgetClass: BlockBudgetClass;
canStringTruncate: boolean;
canDropWholeBlock: boolean;
}
@@ -16,12 +16,7 @@ import type { ProviderConfig } from "../../types/provider-settings";
export const DEFAULT_MAX_INPUT_TOKENS = 128_000;
export const DEFAULT_THRESHOLD_RATIO = 0.9;
export const DEFAULT_TARGET_RATIO = 0.7;
/**
* Estimated output reserve for shared-context models that do not declare
* `maxTokens`. Only consulted in that fallback; explicit input ceilings
* (config or true input limits) never reserve output.
*/
export const FALLBACK_OUTPUT_RESERVE_TOKENS = 16_384;
export const DEFAULT_RESERVE_TOKENS = 16_384;
export const DEFAULT_PRESERVE_RECENT_TOKENS = 20_000;
export const DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS = 1_024;
export const TOOL_RESULT_CHAR_LIMIT = 2_000;
@@ -490,6 +485,7 @@ export function resolveSummarizerConfig(options: {
apiKey: summarizer.apiKey ?? baseProviderConfig?.apiKey,
baseUrl: summarizer.baseUrl ?? baseProviderConfig?.baseUrl,
headers: summarizer.headers ?? baseProviderConfig?.headers,
modelInfo: summarizer.modelInfo ?? baseProviderConfig?.modelInfo,
knownModels: summarizer.knownModels ?? baseProviderConfig?.knownModels,
maxOutputTokens:
summarizer.maxOutputTokens ?? DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
@@ -3,6 +3,7 @@ import type { MessageWithMetadata } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import type { CoreCompactionContext } from "../../types/config";
import { buildAgenticSummaryInputBudget } from "./agentic-compaction";
import { runBasicCompaction } from "./basic-compaction";
import {
createCompactionStateAwarePrepareTurn,
@@ -10,6 +11,7 @@ import {
} from "./compaction";
import {
createTokenEstimator,
estimateTokens,
resolveSummarizerConfig,
serializeMessage,
TOOL_RESULT_CHAR_LIMIT,
@@ -400,11 +402,25 @@ describe("createContextCompactionPrepareTurn", () => {
expect(compacted).toEqual([
{ role: "user", content: "Old request" },
{ role: "user", content: "Read the latest file" },
assistantToolUseMessage("tool-a"),
toolResultMessage("tool-a", "latest result"),
]);
});
it("budgets the complete basic compaction output including the latest turn", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "original task" },
{ role: "assistant", content: "old assistant " + "x".repeat(10_000) },
{ role: "user", content: "latest typed prompt" },
assistantToolUseMessage("tool-live"),
toolResultMessage("tool-live", "live result " + "y".repeat(10_000)),
];
const compacted = runForcedBasicCompaction(messages, 700);
expect(totalJsonTokens(compacted)).toBeLessThanOrEqual(700);
expect(JSON.stringify(compacted)).toContain("latest typed prompt");
expectNoOrphanedToolPairs(compacted);
});
it("does not compact a single typed user message", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "Only current request" },
@@ -520,6 +536,23 @@ describe("createContextCompactionPrepareTurn", () => {
expect(anthropicConfig.maxOutputTokens).toBe(1_024);
});
it("preserves summarizer modelInfo without a nested providerConfig", () => {
const resolved = resolveSummarizerConfig({
activeProviderConfig: {
providerId: "anthropic",
modelId: "primary-model",
modelInfo: { id: "primary-model", maxInputTokens: 100_000 },
} as LlmsProviders.ProviderConfig,
summarizer: {
providerId: "openai",
modelId: "small-summary",
modelInfo: { id: "small-summary", maxInputTokens: 600 },
},
});
expect(resolved.modelInfo?.maxInputTokens).toBe(600);
});
it("summarizes older messages and keeps recent messages", async () => {
const emitStatusNotice = vi.fn();
createHandlerMock.mockReturnValue({
@@ -772,6 +805,43 @@ describe("createContextCompactionPrepareTurn", () => {
expect(summarizerPrompt.length).toBeLessThan(longToolOutput.length);
});
it("budgets agentic summary input before serialization", () => {
const result = buildAgenticSummaryInputBudget({
messages: [
{ role: "user", content: "Run a large command" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-large",
name: "execute_command",
input: { command: "print-large-output" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-large",
name: "execute_command",
content: "x".repeat(50_000),
},
],
},
{ role: "user", content: "Latest typed prompt" },
],
targetTokens: 400,
estimateMessageTokens: estimateJsonTokens,
});
expect(result.estimatedTokens).toBeLessThanOrEqual(400);
expect(JSON.stringify(result.messages)).toContain("Latest typed prompt");
expect(result.actions.length).toBeGreaterThan(0);
});
it("never lands the agentic cut in the middle of a tool pair", async () => {
// Repro for the "No tool call found for function call output" provider
// error: findCutIndex used to walk back by token budget and could land
@@ -946,6 +1016,79 @@ describe("createContextCompactionPrepareTurn", () => {
);
});
it("budgets agentic summary input against the configured summarizer context window", async () => {
let summaryRequest = "";
createHandlerMock.mockReturnValue({
createMessage: vi.fn((_system: string, messages: LlmsProviders.Message[]) => {
summaryRequest = String(messages[0]?.content ?? "");
return streamChunks([
{ type: "text", id: "summary-small", text: "## Goal\nSummarized" },
{ type: "done", id: "summary-small", success: true },
]);
}),
});
const summarizerLimit = 600;
const oversizedAssistant = "assistant details ".repeat(5_000);
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "anthropic",
modelId: "primary-model",
providerConfig: {
providerId: "anthropic",
modelId: "primary-model",
modelInfo: { id: "primary-model", maxInputTokens: 10_000 },
} as LlmsProviders.ProviderConfig,
compaction: {
enabled: true,
strategy: "agentic",
preserveRecentTokens: 1,
reserveTokens: 5,
summarizer: {
providerId: "openai",
modelId: "small-summary",
modelInfo: {
id: "small-summary",
maxInputTokens: summarizerLimit,
},
},
},
logger: undefined,
});
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages: [
{ role: "user", content: "Old request" },
{ role: "assistant", content: oversizedAssistant },
{ role: "user", content: "Latest turn" },
{ role: "assistant", content: "Latest answer" },
],
apiMessages: [
{ role: "user", content: "Old request" },
{ role: "assistant", content: oversizedAssistant },
{ role: "user", content: "Latest turn" },
{ role: "assistant", content: "Latest answer" },
],
model: {
id: "primary-model",
provider: "anthropic",
info: { id: "primary-model", maxInputTokens: 10_000 },
},
});
expect(createHandlerMock).toHaveBeenCalledTimes(1);
expect(estimateTokens(summaryRequest.length)).toBeLessThanOrEqual(
summarizerLimit,
);
expect(summaryRequest).not.toContain(oversizedAssistant);
});
it("uses basic compaction without calling the summarizer", async () => {
const emitStatusNotice = vi.fn();
const prepareTurn = createContextCompactionPrepareTurn({
@@ -1105,7 +1248,7 @@ describe("createContextCompactionPrepareTurn", () => {
}
});
it("uses the true input budget when no context window is available", async () => {
it("uses the default reserve when no trigger is configured", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [{ role: "user" as const, content: "Compacted by reserve" }],
}));
@@ -1146,7 +1289,7 @@ describe("createContextCompactionPrepareTurn", () => {
expect(createHandlerMock).not.toHaveBeenCalled();
expect(compact).toHaveBeenCalledTimes(1);
const context = compact.mock.calls[0]?.[0];
expect(context?.triggerTokens).toBe(180);
expect(context?.triggerTokens).toBe(0);
expect(result?.messages).toEqual([
{ role: "user", content: "Compacted by reserve" },
]);
@@ -1367,7 +1510,7 @@ describe("createContextCompactionPrepareTurn", () => {
expect(context?.targetTokens).toBe(39);
});
it("reserves output once for a shared context window", async () => {
it("derives input budget by reserving model max output tokens from context window", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [
{ role: "user" as const, content: "Compacted by derived input budget" },
@@ -1421,191 +1564,6 @@ describe("createContextCompactionPrepareTurn", () => {
]);
});
it("does not double-reserve output for mirrored Qwen context metadata", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [{ role: "user" as const, content: "Compacted Qwen" }],
}));
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "openrouter",
modelId: "qwen/qwen3-32b",
providerConfig: {
providerId: "openrouter",
modelId: "qwen/qwen3-32b",
} as LlmsProviders.ProviderConfig,
compaction: { enabled: true, compact },
logger: undefined,
});
const messages: MessageWithMetadata[] = [
{ role: "user", content: "large prompt ".repeat(6_000) },
];
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages,
apiMessages: messages,
model: {
id: "qwen/qwen3-32b",
provider: "openrouter",
info: {
id: "qwen/qwen3-32b",
contextWindow: 40_960,
maxInputTokens: 40_960,
maxTokens: 16_384,
},
},
});
expect(compact).toHaveBeenCalledTimes(1);
const context = compact.mock.calls[0]?.[0];
expect(context?.maxInputTokens).toBe(24_576);
expect(context?.triggerTokens).toBe(22_118);
expect(context?.thresholdRatio).toBe(22_118 / 24_576);
});
it("caps shared-context output reserve at half the context window", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [{ role: "user" as const, content: "Compacted capped output" }],
}));
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "kilo",
modelId: "qwen/qwen3-32b",
providerConfig: {
providerId: "kilo",
modelId: "qwen/qwen3-32b",
} as LlmsProviders.ProviderConfig,
compaction: { enabled: true, compact },
logger: undefined,
});
const messages: MessageWithMetadata[] = [
{ role: "user", content: "large prompt ".repeat(5_000) },
];
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages,
apiMessages: messages,
model: {
id: "qwen/qwen3-32b",
provider: "kilo",
info: {
id: "qwen/qwen3-32b",
contextWindow: 40_960,
maxInputTokens: 40_960,
maxTokens: 40_960,
},
},
});
expect(compact).toHaveBeenCalledTimes(1);
const context = compact.mock.calls[0]?.[0];
expect(context?.maxInputTokens).toBe(20_480);
expect(context?.triggerTokens).toBe(18_432);
});
it("applies explicit threshold ratio to the usable budget", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [{ role: "user" as const, content: "Compacted threshold" }],
}));
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "openrouter",
modelId: "qwen/qwen3-32b",
providerConfig: {
providerId: "openrouter",
modelId: "qwen/qwen3-32b",
} as LlmsProviders.ProviderConfig,
compaction: { enabled: true, thresholdRatio: 0.8, compact },
logger: undefined,
});
const messages: MessageWithMetadata[] = [
{ role: "user", content: "large prompt ".repeat(6_000) },
];
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages,
apiMessages: messages,
model: {
id: "qwen/qwen3-32b",
provider: "openrouter",
info: {
id: "qwen/qwen3-32b",
contextWindow: 40_960,
maxInputTokens: 40_960,
maxTokens: 16_384,
},
},
});
expect(compact).toHaveBeenCalledTimes(1);
const context = compact.mock.calls[0]?.[0];
expect(context?.triggerTokens).toBe(19_660);
expect(context?.thresholdRatio).toBe(19_660 / 24_576);
});
it("applies explicit reserve tokens to the usable budget", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [{ role: "user" as const, content: "Compacted reserve" }],
}));
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "openrouter",
modelId: "qwen/qwen3-32b",
providerConfig: {
providerId: "openrouter",
modelId: "qwen/qwen3-32b",
} as LlmsProviders.ProviderConfig,
compaction: { enabled: true, reserveTokens: 4_096, compact },
logger: undefined,
});
const messages: MessageWithMetadata[] = [
{ role: "user", content: "large prompt ".repeat(6_000) },
];
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages,
apiMessages: messages,
model: {
id: "qwen/qwen3-32b",
provider: "openrouter",
info: {
id: "qwen/qwen3-32b",
contextWindow: 40_960,
maxInputTokens: 40_960,
maxTokens: 16_384,
},
},
});
expect(compact).toHaveBeenCalledTimes(1);
const context = compact.mock.calls[0]?.[0];
expect(context?.triggerTokens).toBe(20_480);
expect(context?.thresholdRatio).toBe(20_480 / 24_576);
});
it("uses the lower split input budget when it is below context-derived input budget", async () => {
const compact = vi.fn((_context: CoreCompactionContext) => ({
messages: [
@@ -1983,7 +1941,7 @@ describe("createContextCompactionPrepareTurn", () => {
expect(result?.messages.length).toBeLessThan(4);
});
it("preserves user image blocks during basic compaction sanitization", () => {
it("drops old user image blocks during basic compaction sanitization", () => {
const messages: LlmsProviders.Message[] = [
{
role: "user",
@@ -2019,7 +1977,6 @@ describe("createContextCompactionPrepareTurn", () => {
expect(result?.messages).toBeDefined();
expect(result?.messages[0]?.content).toEqual([
{ type: "text", text: "Older user turn" },
{ type: "image", data: "abc", mediaType: "image/png" },
]);
expect(result?.messages.at(-1)).toEqual({
role: "user",
@@ -22,9 +22,9 @@ import {
createTokenEstimator,
DEFAULT_MAX_INPUT_TOKENS,
DEFAULT_PRESERVE_RECENT_TOKENS,
DEFAULT_RESERVE_TOKENS,
DEFAULT_TARGET_RATIO,
DEFAULT_THRESHOLD_RATIO,
FALLBACK_OUTPUT_RESERVE_TOKENS,
} from "./compaction-shared";
export interface ContextPipelinePrepareTurnInput {
@@ -78,6 +78,7 @@ export interface ContextCompactionPrepareTurnOptions {
manualTargetRatio?: number;
}
const MIN_CONTEXT_DERIVED_INPUT_RATIO = 0.5;
const LONG_CONVERSATION_TARGET_RATIO = 0.5;
function safeJsonSize(value: unknown): number {
@@ -92,86 +93,35 @@ function isPositiveFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
type CompactionBudgetSource = "config" | "true_input" | "context" | "default";
interface CompactionBudget {
ceilingTokens: number;
usableBudgetTokens: number;
outputReserveTokens: number;
triggerTokens: number;
thresholdRatio: number;
ceilingSource: CompactionBudgetSource;
reserveCapped: boolean;
}
function resolveCompactionBudget(input: {
config: CoreCompactionConfig;
function resolveMaxInputTokens(input: {
configMaxInputTokens?: number;
modelMaxInputTokens?: number;
contextWindow?: number;
modelMaxTokens?: number;
}): CompactionBudget {
let ceilingTokens = DEFAULT_MAX_INPUT_TOKENS;
let ceilingSource: CompactionBudgetSource = "default";
if (isPositiveFiniteNumber(input.config.maxInputTokens)) {
ceilingTokens = input.config.maxInputTokens;
ceilingSource = "config";
} else if (
isPositiveFiniteNumber(input.modelMaxInputTokens) &&
(!isPositiveFiniteNumber(input.contextWindow) ||
input.modelMaxInputTokens < input.contextWindow)
) {
ceilingTokens = input.modelMaxInputTokens;
ceilingSource = "true_input";
} else if (isPositiveFiniteNumber(input.contextWindow)) {
ceilingTokens = input.contextWindow;
ceilingSource = "context";
}): number {
const candidates: number[] = [];
if (isPositiveFiniteNumber(input.configMaxInputTokens)) {
candidates.push(input.configMaxInputTokens);
}
// Output space is reserved once, and only when the ceiling is a shared
// context window. Explicit config and true input limits already describe
// input-only budgets, so reserving there would double-count output.
const maxReserveTokens = Math.floor(ceilingTokens / 2);
const outputReserveTokens =
ceilingSource === "config" ||
ceilingSource === "true_input" ||
ceilingSource === "default"
? 0
: isPositiveFiniteNumber(input.modelMaxTokens)
? Math.min(input.modelMaxTokens, maxReserveTokens)
: Math.min(
FALLBACK_OUTPUT_RESERVE_TOKENS,
Math.floor(ceilingTokens * 0.25),
);
const usableBudgetTokens = Math.max(1, ceilingTokens - outputReserveTokens);
let triggerTokens: number;
if (typeof input.config.reserveTokens === "number") {
triggerTokens = Math.max(
0,
usableBudgetTokens - Math.max(0, input.config.reserveTokens),
);
} else if (typeof input.config.thresholdRatio === "number") {
triggerTokens = Math.floor(
usableBudgetTokens * input.config.thresholdRatio,
);
} else {
triggerTokens = Math.floor(usableBudgetTokens * DEFAULT_THRESHOLD_RATIO);
if (isPositiveFiniteNumber(input.modelMaxInputTokens)) {
candidates.push(input.modelMaxInputTokens);
}
return {
ceilingTokens,
usableBudgetTokens,
outputReserveTokens,
triggerTokens,
thresholdRatio:
usableBudgetTokens > 0 ? triggerTokens / usableBudgetTokens : 0,
ceilingSource,
reserveCapped:
ceilingSource === "context" &&
isPositiveFiniteNumber(input.modelMaxTokens) &&
input.modelMaxTokens > maxReserveTokens,
};
if (isPositiveFiniteNumber(input.contextWindow)) {
candidates.push(input.contextWindow);
const derivedInputTokens = isPositiveFiniteNumber(input.modelMaxTokens)
? input.contextWindow - input.modelMaxTokens
: undefined;
if (
isPositiveFiniteNumber(derivedInputTokens) &&
derivedInputTokens >=
input.contextWindow * MIN_CONTEXT_DERIVED_INPUT_RATIO
) {
candidates.push(derivedInputTokens);
}
}
return candidates.length > 0
? Math.min(...candidates)
: DEFAULT_MAX_INPUT_TOKENS;
}
function summarizeToolResults(messages: CoreCompactionContext["messages"]): {
@@ -239,6 +189,47 @@ const BUILTIN_COMPACTION_STRATEGIES = {
}),
} satisfies Record<CoreCompactionStrategy, BuiltinCompactionStrategyRunner>;
function resolveTriggerState(input: {
inputTokens: number;
maxInputTokens: number;
config: CoreCompactionConfig;
}): { shouldCompact: boolean; triggerTokens: number; thresholdRatio: number } {
if (typeof input.config.reserveTokens === "number") {
const reserveTokens = Math.max(0, input.config.reserveTokens);
const triggerTokens = Math.max(0, input.maxInputTokens - reserveTokens);
return {
shouldCompact: input.inputTokens > triggerTokens,
triggerTokens,
thresholdRatio:
input.maxInputTokens > 0 ? triggerTokens / input.maxInputTokens : 0,
};
}
if (typeof input.config.thresholdRatio === "number") {
const thresholdRatio = input.config.thresholdRatio;
const triggerTokens = input.maxInputTokens * thresholdRatio;
return {
shouldCompact: input.inputTokens > triggerTokens,
triggerTokens,
thresholdRatio,
};
}
const triggerTokens = Math.max(
0,
Math.min(
input.maxInputTokens - DEFAULT_RESERVE_TOKENS,
input.maxInputTokens * DEFAULT_THRESHOLD_RATIO,
),
);
return {
shouldCompact: input.inputTokens > triggerTokens,
triggerTokens,
thresholdRatio:
input.maxInputTokens > 0 ? triggerTokens / input.maxInputTokens : 0,
};
}
function resolveManualTargetState(input: {
inputTokens: number;
maxInputTokens: number;
@@ -267,8 +258,7 @@ function resolveManualTargetState(input: {
}
function resolveBasicTargetTokens(input: {
ceilingTokens: number;
usableBudgetTokens: number;
maxInputTokens: number;
modelMaxTokens?: number;
triggerTokens: number;
messagePairCount: number;
@@ -277,13 +267,13 @@ function resolveBasicTargetTokens(input: {
input.messagePairCount >= 5 &&
typeof input.modelMaxTokens === "number" &&
Number.isFinite(input.modelMaxTokens) &&
input.modelMaxTokens < input.ceilingTokens
? Math.floor(input.usableBudgetTokens * LONG_CONVERSATION_TARGET_RATIO)
input.modelMaxTokens < input.maxInputTokens
? Math.floor(input.maxInputTokens * LONG_CONVERSATION_TARGET_RATIO)
: Math.floor(input.triggerTokens * DEFAULT_TARGET_RATIO);
const triggerCeiling = Math.max(1, input.triggerTokens - 1);
return Math.max(
1,
Math.min(targetTokens, input.usableBudgetTokens, triggerCeiling),
Math.min(targetTokens, input.maxInputTokens, triggerCeiling),
);
}
@@ -358,18 +348,22 @@ export function createContextCompactionPrepareTurn(
(total: number, message) => total + estimateMessageTokens(message),
0,
);
const compactionBudget = resolveCompactionBudget({
const maxInputTokens = resolveMaxInputTokens({
configMaxInputTokens: userCompaction?.maxInputTokens,
modelMaxInputTokens: context.model.info?.maxInputTokens,
contextWindow: context.model.info?.contextWindow,
modelMaxTokens: context.model.info?.maxTokens,
});
const triggerState = resolveTriggerState({
inputTokens,
maxInputTokens,
config: {
maxInputTokens: userCompaction?.maxInputTokens,
reserveTokens: userCompaction?.reserveTokens,
thresholdRatio: userCompaction?.thresholdRatio,
},
modelMaxInputTokens: context.model.info?.maxInputTokens,
contextWindow: context.model.info?.contextWindow,
modelMaxTokens: context.model.info?.maxTokens,
});
const maxInputTokens = compactionBudget.usableBudgetTokens;
const shouldCompact = inputTokens > compactionBudget.triggerTokens;
config.logger?.debug("Context compaction diagnostics", {
mode,
strategy,
@@ -378,19 +372,15 @@ export function createContextCompactionPrepareTurn(
modelId: config.modelId,
inputTokens,
maxInputTokens,
ceilingTokens: compactionBudget.ceilingTokens,
outputReserveTokens: compactionBudget.outputReserveTokens,
ceilingSource: compactionBudget.ceilingSource,
reserveCapped: compactionBudget.reserveCapped,
triggerTokens: compactionBudget.triggerTokens,
thresholdRatio: compactionBudget.thresholdRatio,
shouldCompact,
triggerTokens: triggerState.triggerTokens,
thresholdRatio: triggerState.thresholdRatio,
shouldCompact: triggerState.shouldCompact,
messageCount: context.messages.length,
apiMessageCount: context.apiMessages.length,
apiMessagesJsonChars: safeJsonSize(context.apiMessages),
...summarizeToolResults(context.apiMessages),
});
if (mode === "auto" && !shouldCompact) {
if (mode === "auto" && !triggerState.shouldCompact) {
return undefined;
}
const targetState =
@@ -398,15 +388,14 @@ export function createContextCompactionPrepareTurn(
? resolveManualTargetState({
inputTokens,
maxInputTokens,
autoTriggerTokens: compactionBudget.triggerTokens,
autoTriggerTokens: triggerState.triggerTokens,
manualTargetRatio: options.manualTargetRatio,
})
: compactionBudget;
: triggerState;
const targetTokens =
mode === "auto"
? resolveBasicTargetTokens({
ceilingTokens: compactionBudget.ceilingTokens,
usableBudgetTokens: compactionBudget.usableBudgetTokens,
maxInputTokens,
modelMaxTokens: context.model.info?.maxTokens,
triggerTokens: targetState.triggerTokens,
messagePairCount: countUserAssistantPairs(context.messages),
@@ -437,9 +426,6 @@ export function createContextCompactionPrepareTurn(
iteration: context.iteration,
triggerTokens: targetState.triggerTokens,
maxInputTokens,
ceilingTokens: compactionBudget.ceilingTokens,
outputReserveTokens: compactionBudget.outputReserveTokens,
ceilingSource: compactionBudget.ceilingSource,
},
);
@@ -481,10 +467,6 @@ export function createContextCompactionPrepareTurn(
severity: "info",
strategy: strategy,
maxInputTokens,
ceilingTokens: compactionBudget.ceilingTokens,
outputReserveTokens: compactionBudget.outputReserveTokens,
ceilingSource: compactionBudget.ceilingSource,
reserveCapped: compactionBudget.reserveCapped,
inputTokens,
afterTokens,
tokensSaved: inputTokens - afterTokens,
@@ -4,26 +4,6 @@ import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { createEditorExecutor } from "./editor";
const context = {
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
};
async function withTempFile(
content: string,
run: (filePath: string, dir: string) => Promise<void>,
): Promise<void> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
const filePath = path.join(dir, "example.txt");
await fs.writeFile(filePath, content, "utf-8");
try {
await run(filePath, dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("createEditorExecutor", () => {
it("creates a missing file when edit is used", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
@@ -100,99 +80,6 @@ describe("createEditorExecutor", () => {
}
});
it("emits a minimal diff for an in-place single-line edit", async () => {
await withTempFile("a\nb\nc", async (filePath, dir) => {
const editor = createEditorExecutor();
const result = await editor(
{ path: filePath, old_text: "b", new_text: "B" },
dir,
context,
);
expect(result).toBe(
`Edited ${filePath}\n\`\`\`diff\n-2: b\n+2: B\n\`\`\``,
);
});
});
it("only emits the changed region when the edit changes the line count", async () => {
await withTempFile("a\nb\nc\nd\ne\nf", async (filePath, dir) => {
const editor = createEditorExecutor();
const result = await editor(
{ path: filePath, old_text: "b\nc\nd", new_text: "B" },
dir,
context,
);
// The trailing unchanged lines (e, f) must not be mispaired into
// the diff even though their positions shifted.
expect(result).toBe(
`Edited ${filePath}\n\`\`\`diff\n-2: b\n-3: c\n-4: d\n+2: B\n\`\`\``,
);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(
"a\nB\ne\nf",
);
});
});
it("emits only additions for a pure insertion via str_replace", async () => {
await withTempFile("a\nb\nc", async (filePath, dir) => {
const editor = createEditorExecutor();
const result = await editor(
{ path: filePath, old_text: "a\nb", new_text: "a\nnew\nb" },
dir,
context,
);
expect(result).toBe(
`Edited ${filePath}\n\`\`\`diff\n+2: new\n\`\`\``,
);
});
});
it("truncates long diffs at maxDiffLines while keeping both sides visible", async () => {
const oldLines = Array.from({ length: 10 }, (_, i) => `old-${i}`);
await withTempFile(oldLines.join("\n"), async (filePath, dir) => {
const editor = createEditorExecutor({ maxDiffLines: 3 });
const result = await editor(
{
path: filePath,
old_text: oldLines.join("\n"),
new_text: "replaced",
},
dir,
context,
);
expect(result).toBe(
`Edited ${filePath}\n\`\`\`diff\n-1: old-0\n-2: old-1\n+1: replaced\n... diff truncated (8 more removed, 0 more added lines) ...\n\`\`\``,
);
});
});
it("does not drop additions when removals alone exhaust maxDiffLines", async () => {
const oldLines = Array.from({ length: 6 }, (_, i) => `old-${i}`);
const newLines = Array.from({ length: 4 }, (_, i) => `new-${i}`);
await withTempFile(oldLines.join("\n"), async (filePath, dir) => {
const editor = createEditorExecutor({ maxDiffLines: 6 });
const result = await editor(
{
path: filePath,
old_text: oldLines.join("\n"),
new_text: newLines.join("\n"),
},
dir,
context,
);
// Budget splits 3/3 instead of removals consuming all 6 lines and
// reporting +0 additions.
expect(result).toBe(
`Edited ${filePath}\n\`\`\`diff\n-1: old-0\n-2: old-1\n-3: old-2\n+1: new-0\n+2: new-1\n+3: new-2\n... diff truncated (3 more removed, 1 more added lines) ...\n\`\`\``,
);
});
});
it("rejects insert_line 0 with the valid one-based boundary range", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
const filePath = path.join(dir, "example.txt");
@@ -71,57 +71,32 @@ function createLineDiff(
): string {
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
// Trim the common prefix and suffix so only the changed region is emitted;
// a naive positional compare would mispair every line after an edit that
// changes the line count.
let start = 0;
while (
start < oldLines.length &&
start < newLines.length &&
oldLines[start] === newLines[start]
) {
start++;
}
let oldEnd = oldLines.length;
let newEnd = newLines.length;
while (
oldEnd > start &&
newEnd > start &&
oldLines[oldEnd - 1] === newLines[newEnd - 1]
) {
oldEnd--;
newEnd--;
}
// Split the line budget between removals and additions so neither side is
// silently dropped when the other alone would exhaust maxLines.
const removedCount = oldEnd - start;
const addedCount = newEnd - start;
let removedBudget = removedCount;
let addedBudget = addedCount;
if (removedCount + addedCount > maxLines) {
removedBudget = Math.min(
removedCount,
Math.max(Math.ceil(maxLines / 2), maxLines - addedCount),
);
addedBudget = Math.min(addedCount, maxLines - removedBudget);
}
const max = Math.max(oldLines.length, newLines.length);
const out: string[] = ["```diff"];
for (let i = start; i < start + removedBudget; i++) {
out.push(`-${i + 1}: ${oldLines[i]}`);
}
for (let i = start; i < start + addedBudget; i++) {
out.push(`+${i + 1}: ${newLines[i]}`);
}
let emitted = 0;
const omittedRemoved = removedCount - removedBudget;
const omittedAdded = addedCount - addedBudget;
if (omittedRemoved > 0 || omittedAdded > 0) {
out.push(
`... diff truncated (${omittedRemoved} more removed, ${omittedAdded} more added lines) ...`,
);
for (let i = 0; i < max; i++) {
if (emitted >= maxLines) {
out.push("... diff truncated ...");
break;
}
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) {
continue;
}
const lineNo = i + 1;
if (oldLine !== undefined) {
out.push(`-${lineNo}: ${oldLine}`);
emitted++;
}
if (newLine !== undefined && emitted < maxLines) {
out.push(`+${lineNo}: ${newLine}`);
emitted++;
}
}
out.push("```");
@@ -27,8 +27,6 @@ export type DelegatedAgentConnectionConfig = Pick<
| "providerConfig"
| "knownModels"
| "thinking"
| "reasoningEffort"
| "thinkingBudgetTokens"
| "maxTokensPerTurn"
>;
@@ -90,8 +88,6 @@ export function createDelegatedAgentConfigProvider(
providerConfig: runtimeConfig.providerConfig,
knownModels: runtimeConfig.knownModels,
thinking: runtimeConfig.thinking,
reasoningEffort: runtimeConfig.reasoningEffort,
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
maxTokensPerTurn: runtimeConfig.maxTokensPerTurn,
}),
updateConnectionDefaults: (overrides) => {
@@ -35,7 +35,6 @@ import type {
RuntimeHostSubscribeOptions,
SendSessionInput,
SessionAccumulatedUsage,
SessionConnectionUpdate,
SessionUsageSummary,
StartSessionInput,
StartSessionResult,
@@ -1291,27 +1290,6 @@ export class HubRuntimeHost implements RuntimeHost {
return { updated: reply.ok };
}
async updateSessionConnection(
sessionId: string,
updates: SessionConnectionUpdate,
): Promise<void> {
const target = sessionId.trim();
if (!target) {
return;
}
const reply = await this.client.command(
"session.update_connection",
{
sessionId: target,
updates,
},
target,
);
if (!reply.ok) {
throw new Error(hubReplyErrorMessage(reply, "session.update_connection"));
}
}
async updateSessionCompactionState(
sessionId: string,
state: SessionCompactionState,
@@ -12,7 +12,6 @@ import { createSessionId } from "@cline/shared";
import type {
PendingPromptsRuntimeService,
RuntimeHost,
SessionConnectionRuntimeService,
SessionUsageRuntimeService,
} from "../../../runtime/host/runtime-host";
import {
@@ -53,11 +52,7 @@ export interface HubTransportContext {
readonly suppressNextTerminalEventBySession: Map<string, string>;
readonly telemetry?: ITelemetryService;
readonly sessionHost: RuntimeHost &
Partial<
PendingPromptsRuntimeService &
SessionUsageRuntimeService &
SessionConnectionRuntimeService
>;
Partial<PendingPromptsRuntimeService & SessionUsageRuntimeService>;
publish(event: HubEventEnvelope): void;
buildEvent(
event: HubEventEnvelope["event"],
@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { readSessionConnectionUpdate } from "./session-handlers";
describe("readSessionConnectionUpdate", () => {
it("enables thinking when a positive budget is supplied without thinking", () => {
expect(readSessionConnectionUpdate({ thinkingBudgetTokens: 2048 })).toEqual(
{
thinking: true,
thinkingBudgetTokens: 2048,
},
);
});
it("lets explicit thinking disable override reasoning fields", () => {
const updates = readSessionConnectionUpdate({
thinking: false,
reasoningEffort: "high",
thinkingBudgetTokens: 2048,
});
expect(updates.thinking).toBe(false);
expect(Object.hasOwn(updates, "reasoningEffort")).toBe(true);
expect(updates.reasoningEffort).toBeUndefined();
expect(Object.hasOwn(updates, "thinkingBudgetTokens")).toBe(true);
expect(updates.thinkingBudgetTokens).toBeUndefined();
});
});
@@ -5,11 +5,7 @@ import type {
ToolApprovalRequest,
} from "@cline/shared";
import { createSessionId, parseRuntimeConfigExtensions } from "@cline/shared";
import { normalizeConnectionUpdate } from "../../../runtime/config/connection-update";
import type {
RuntimeSessionConfig,
SessionConnectionUpdate,
} from "../../../runtime/host/runtime-host";
import type { RuntimeSessionConfig } from "../../../runtime/host/runtime-host";
import { parseSessionCompactionState } from "../../../session/models/session-compaction";
import {
SessionVersioningError,
@@ -36,74 +32,6 @@ import {
const CAPABILITY_OWNER_METADATA_KEY = "hubCapabilityOwnerClientId";
function readConnectionString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function readConnectionReasoningEffort(
value: unknown,
): SessionConnectionUpdate["reasoningEffort"] | undefined {
if (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh" ||
value === null
) {
return value;
}
return undefined;
}
export function readSessionConnectionUpdate(
value: unknown,
): SessionConnectionUpdate {
const record = asPlainRecord(value) ?? {};
const updates: SessionConnectionUpdate = {};
const providerId = readConnectionString(record.providerId);
if (providerId) updates.providerId = providerId;
const modelId = readConnectionString(record.modelId);
if (modelId) updates.modelId = modelId;
const apiKey = readConnectionString(record.apiKey);
if (apiKey !== undefined) updates.apiKey = apiKey;
const baseUrl = readConnectionString(record.baseUrl);
if (baseUrl !== undefined) updates.baseUrl = baseUrl;
if (record.headers && typeof record.headers === "object") {
updates.headers = record.headers as Record<string, string>;
}
if (record.providerConfig && typeof record.providerConfig === "object") {
updates.providerConfig =
record.providerConfig as unknown as SessionConnectionUpdate["providerConfig"];
}
if (Object.hasOwn(record, "thinking")) {
if (typeof record.thinking === "boolean" || record.thinking === null) {
updates.thinking = record.thinking;
}
}
if (Object.hasOwn(record, "reasoningEffort")) {
const reasoningEffort = readConnectionReasoningEffort(
record.reasoningEffort,
);
if (reasoningEffort !== undefined) {
updates.reasoningEffort = reasoningEffort;
}
}
if (Object.hasOwn(record, "thinkingBudgetTokens")) {
if (
typeof record.thinkingBudgetTokens === "number" &&
Number.isFinite(record.thinkingBudgetTokens) &&
record.thinkingBudgetTokens > 0
) {
updates.thinkingBudgetTokens = Math.trunc(record.thinkingBudgetTokens);
} else if (record.thinkingBudgetTokens === null) {
updates.thinkingBudgetTokens = null;
}
}
return normalizeConnectionUpdate(updates);
}
function getCapabilityOwnerClientId(
ctx: HubTransportContext,
sessionId: string,
@@ -892,32 +820,6 @@ export async function handleSessionUpdate(
};
}
export async function handleSessionUpdateConnection(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
if (!sessionId) {
return errorReply(
envelope,
"invalid_session_update_connection",
"session.update_connection requires a session id",
);
}
const updateSessionConnection = ctx.sessionHost.updateSessionConnection;
if (!updateSessionConnection) {
return errorReply(
envelope,
"unsupported_session_update_connection",
"runtime host does not support session connection updates",
);
}
const payload = asPlainRecord(envelope.payload);
const updates = readSessionConnectionUpdate(payload?.updates);
await updateSessionConnection.call(ctx.sessionHost, sessionId, updates);
return okReply(envelope, { sessionId, updated: true });
}
export async function handleSessionCompactionUpdate(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
@@ -69,7 +69,6 @@ import {
handleSessionRemovePendingPrompt,
handleSessionRestore,
handleSessionUpdate,
handleSessionUpdateConnection,
handleSessionUpdatePendingPrompt,
} from "./handlers/session-handlers";
import { eventNameForScheduleCommand } from "./hub-schedule-events";
@@ -370,8 +369,6 @@ export class HubServerTransport implements NativeHubTransport {
return await handleSessionList(this.ctx, envelope);
case "session.update":
return await handleSessionUpdate(this.ctx, envelope);
case "session.update_connection":
return await handleSessionUpdateConnection(this.ctx, envelope);
case "session.compaction.update":
return await handleSessionCompactionUpdate(this.ctx, envelope);
case "session.pending_prompts":
-1
View File
@@ -569,7 +569,6 @@ export {
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
loginLocalProvider,
markLocalProviderEnabled,
normalizeOAuthProvider,
refreshProviderModelsFromSource,
resolveLocalClineAuthToken,
@@ -1,44 +0,0 @@
import type { CoreSessionConfig } from "../../types/config";
export interface ConnectionUpdate {
providerId?: string;
modelId?: string;
apiKey?: string;
baseUrl?: string;
headers?: Record<string, string>;
providerConfig?: CoreSessionConfig["providerConfig"];
reasoningEffort?: CoreSessionConfig["reasoningEffort"] | null;
thinking?: CoreSessionConfig["thinking"] | null;
thinkingBudgetTokens?: CoreSessionConfig["thinkingBudgetTokens"] | null;
}
export function normalizeConnectionUpdate(
updates: ConnectionUpdate,
): ConnectionUpdate {
const normalized: ConnectionUpdate = { ...updates };
const hasThinking = Object.hasOwn(updates, "thinking");
const hasThinkingBudgetTokens = Object.hasOwn(
updates,
"thinkingBudgetTokens",
);
const disablesThinking =
updates.thinking === false || updates.thinking === null;
if (disablesThinking) {
normalized.reasoningEffort = undefined;
normalized.thinkingBudgetTokens = undefined;
return normalized;
}
if (
!hasThinking &&
hasThinkingBudgetTokens &&
typeof updates.thinkingBudgetTokens === "number" &&
Number.isFinite(updates.thinkingBudgetTokens) &&
updates.thinkingBudgetTokens > 0
) {
normalized.thinking = true;
}
return normalized;
}
@@ -362,110 +362,6 @@ describe("LocalRuntimeHost", () => {
);
});
it("persists thinking budget token connection updates", async () => {
const sessionId = "sess-thinking-budget-update";
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 runtimeBuilder = {
build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }),
};
const agent = {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
getMessages: vi.fn().mockReturnValue([]),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
updateConnection: vi.fn(),
canStartRun: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const createAgent = vi.fn(() => agent as never);
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: runtimeBuilder as never,
createAgent,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({
sessionId,
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
}),
prompt: "hello",
interactive: true,
}),
);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
}),
);
await manager.updateSessionConnection(sessionId, {
thinkingBudgetTokens: 2048,
});
const getSessionOrThrow = Reflect.get(
manager as object,
"getSessionOrThrow",
) as (sessionId: string) => { config: CoreSessionConfig };
const session = Reflect.apply(getSessionOrThrow, manager, [sessionId]) as {
config: CoreSessionConfig;
};
expect(session.config.thinking).toBe(true);
expect(session.config.thinkingBudgetTokens).toBe(2048);
expect(agent.updateConnection).toHaveBeenLastCalledWith({
thinking: true,
thinkingBudgetTokens: 2048,
});
await manager.updateSessionConnection(sessionId, {
thinking: false,
reasoningEffort: "high",
thinkingBudgetTokens: 4096,
});
expect(session.config.thinking).toBe(false);
expect(session.config.reasoningEffort).toBeUndefined();
expect(session.config.thinkingBudgetTokens).toBeUndefined();
expect(agent.updateConnection).toHaveBeenLastCalledWith({
thinking: false,
reasoningEffort: undefined,
thinkingBudgetTokens: undefined,
});
await manager.updateSessionConnection(sessionId, {
thinking: null,
reasoningEffort: null,
thinkingBudgetTokens: null,
});
expect(session.config.thinking).toBeUndefined();
expect(session.config.reasoningEffort).toBeUndefined();
expect(session.config.thinkingBudgetTokens).toBeUndefined();
});
it("captures active session lookup misses as handled telemetry", async () => {
const adapter = {
name: "test",
@@ -4641,9 +4537,7 @@ describe("LocalRuntimeHost", () => {
await expect(
manager.updateSessionCompactionState(sessionId, incoming),
).resolves.toEqual({ updated: false });
expect(
sessionService.persistSessionCompactionState,
).not.toHaveBeenCalled();
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
} finally {
rmSync(tempCwd, { recursive: true, force: true });
}
@@ -81,7 +81,6 @@ import type { ActiveSession, PreparedTurnInput } from "../../types/session";
import type { SessionRecord } from "../../types/sessions";
import type { RuntimeCapabilities } from "../capabilities";
import { normalizeRuntimeCapabilities } from "../capabilities";
import { normalizeConnectionUpdate } from "../config/connection-update";
import { DefaultRuntimeBuilder } from "../orchestration/runtime-builder";
import {
OAuthReauthRequiredError,
@@ -116,7 +115,6 @@ import type {
RuntimeHostSubscribeOptions,
SendSessionInput,
SessionAccumulatedUsage,
SessionConnectionUpdate,
SessionUsageSummary,
StartSessionInput,
StartSessionResult,
@@ -541,8 +539,8 @@ export class LocalRuntimeHost implements RuntimeHost {
});
}
},
})
: undefined;
})
: undefined;
const agentConfig = {
sessionId,
@@ -556,7 +554,6 @@ export class LocalRuntimeHost implements RuntimeHost {
thinking: configWithProvider.thinking,
reasoningEffort:
configWithProvider.reasoningEffort ?? providerConfig.reasoningEffort,
thinkingBudgetTokens: configWithProvider.thinkingBudgetTokens,
maxTokensPerTurn: configWithProvider.maxTokensPerTurn,
systemPrompt: configWithProvider.systemPrompt,
maxIterations: configWithProvider.maxIterations,
@@ -1232,72 +1229,12 @@ export class LocalRuntimeHost implements RuntimeHost {
}
async updateSessionModel(sessionId: string, modelId: string): Promise<void> {
await this.updateSessionConnection(sessionId, { modelId });
}
async updateSessionConnection(
sessionId: string,
rawUpdates: SessionConnectionUpdate,
): Promise<void> {
const updates = normalizeConnectionUpdate(rawUpdates);
const session = this.getSessionOrThrow(sessionId);
if (updates.providerId !== undefined)
session.config.providerId = updates.providerId;
if (updates.modelId !== undefined) session.config.modelId = updates.modelId;
if (updates.apiKey !== undefined) session.config.apiKey = updates.apiKey;
if (updates.baseUrl !== undefined) session.config.baseUrl = updates.baseUrl;
if (updates.headers !== undefined) session.config.headers = updates.headers;
if (updates.providerConfig !== undefined)
session.config.providerConfig = updates.providerConfig;
if (Object.hasOwn(updates, "reasoningEffort")) {
session.config.reasoningEffort = updates.reasoningEffort ?? undefined;
}
if (Object.hasOwn(updates, "thinkingBudgetTokens")) {
session.config.thinkingBudgetTokens =
updates.thinkingBudgetTokens ?? undefined;
}
if (Object.hasOwn(updates, "thinking")) {
session.config.thinking = updates.thinking ?? undefined;
if (updates.thinking === false || updates.thinking === null) {
session.config.reasoningEffort = undefined;
session.config.thinkingBudgetTokens = undefined;
}
}
const delegatedUpdates = {
...(updates.providerId !== undefined
? { providerId: updates.providerId }
: {}),
...(updates.modelId !== undefined ? { modelId: updates.modelId } : {}),
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
...(updates.baseUrl !== undefined ? { baseUrl: updates.baseUrl } : {}),
...(updates.headers !== undefined ? { headers: updates.headers } : {}),
...(updates.providerConfig !== undefined
? { providerConfig: updates.providerConfig }
: {}),
...(Object.hasOwn(updates, "reasoningEffort")
? { reasoningEffort: updates.reasoningEffort ?? undefined }
: {}),
...(Object.hasOwn(updates, "thinking")
? { thinking: updates.thinking ?? undefined }
: {}),
...(Object.hasOwn(updates, "thinkingBudgetTokens")
? { thinkingBudgetTokens: updates.thinkingBudgetTokens ?? undefined }
: {}),
};
if (updates.thinking === false || updates.thinking === null) {
delegatedUpdates.reasoningEffort = undefined;
delegatedUpdates.thinkingBudgetTokens = undefined;
}
const teammateUpdates = {
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
...(updates.baseUrl !== undefined ? { baseUrl: updates.baseUrl } : {}),
...(updates.headers !== undefined ? { headers: updates.headers } : {}),
};
session.runtime.delegatedAgentConfigProvider?.updateConnectionDefaults(
delegatedUpdates,
);
session.agent.updateConnection(updates);
session.runtime.teamRuntime?.updateTeammateConnections(teammateUpdates);
session.config.modelId = modelId;
session.runtime.delegatedAgentConfigProvider?.updateConnectionDefaults({
modelId,
});
session.agent.updateConnection({ modelId });
}
// Retained for unit tests that reach in via Reflect.
@@ -17,7 +17,6 @@ import type {
} from "../../types/events";
import type { SessionRecord } from "../../types/sessions";
import type { RuntimeCapabilities } from "../capabilities";
import type { ConnectionUpdate } from "../config/connection-update";
export const SESSION_NOT_FOUND_ERROR_CODE = "session_not_found";
@@ -260,19 +259,10 @@ export interface SessionUsageRuntimeService {
): Promise<SessionUsageSummary | undefined>;
}
export type SessionConnectionUpdate = ConnectionUpdate;
export interface SessionModelRuntimeService {
updateSessionModel(sessionId: string, modelId: string): Promise<void>;
}
export interface SessionConnectionRuntimeService {
updateSessionConnection(
sessionId: string,
updates: SessionConnectionUpdate,
): Promise<void>;
}
export interface RuntimeHostSubscribeOptions {
sessionId?: string;
}
@@ -485,8 +485,6 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
providerConfig: config.providerConfig,
knownModels: config.knownModels,
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
thinkingBudgetTokens: config.thinkingBudgetTokens,
maxTokensPerTurn: config.maxTokensPerTurn,
maxIterations: config.maxIterations,
hooks,
@@ -1210,55 +1210,6 @@ describe("SessionRuntime.addTools / updateConnection / clearHistory / restore",
expect(calls.run).toHaveLength(1);
});
it("updateConnection clears stale reasoning fields for next run", async () => {
const { deps, configs } = withCapturingFakeRuntime();
const session = new SessionRuntime(
makeAgentConfig({
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
}),
deps,
);
session.updateConnection({
thinking: null,
reasoningEffort: null,
thinkingBudgetTokens: null,
});
await session.run("go");
expect(configs[0]?.modelOptions).toBeUndefined();
});
it("updateConnection lets explicit thinking disable override a simultaneous budget", async () => {
const { deps, configs } = withCapturingFakeRuntime();
const session = new SessionRuntime(
makeAgentConfig({
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: 1024,
}),
deps,
);
session.updateConnection({
thinking: false,
reasoningEffort: "high",
thinkingBudgetTokens: 2048,
});
await session.run("go");
expect(configs[0]?.modelOptions).toEqual({ thinking: false });
});
it("updateConnection enables thinking when a positive budget is supplied", async () => {
const { deps, configs } = withCapturingFakeRuntime();
const session = new SessionRuntime(makeAgentConfig(), deps);
session.updateConnection({ thinkingBudgetTokens: 2048 });
await session.run("go");
expect(configs[0]?.modelOptions).toEqual({
thinking: true,
thinkingBudgetTokens: 2048,
});
});
it("clearHistory resets the conversation store", () => {
const session = new SessionRuntime(
makeAgentConfig({
@@ -64,10 +64,6 @@ import {
messagesToAgentMessages,
} from "../config/agent-message-codec";
import { createAgentRuntimeConfig } from "../config/agent-runtime-config-builder";
import {
type ConnectionUpdate,
normalizeConnectionUpdate,
} from "../config/connection-update";
import { LoopDetectionTracker } from "../safety/loop-detection";
import { MistakeTracker } from "../safety/mistake-tracker";
import { RuntimeEventAdapter } from "./runtime-event-adapter";
@@ -260,7 +256,17 @@ export interface SessionRuntimeOrchestratorDeps {
}
/** Connection overrides applied via `updateConnection`. */
export type ConnectionOverrides = ConnectionUpdate;
export interface ConnectionOverrides {
providerId?: string;
modelId?: string;
apiKey?: string;
baseUrl?: string;
headers?: Record<string, string>;
providerConfig?: unknown;
reasoningEffort?: AgentConfig["reasoningEffort"];
thinking?: boolean;
thinkingBudgetTokens?: number;
}
// =============================================================================
// SessionRuntime orchestrator
@@ -479,28 +485,20 @@ export class SessionRuntime {
/** Mutate provider / reasoning fields for subsequent runs. */
updateConnection(overrides: ConnectionOverrides): void {
const updates = normalizeConnectionUpdate(overrides);
const next: AgentConfig = { ...this.config };
if (updates.providerId !== undefined) next.providerId = updates.providerId;
if (updates.modelId !== undefined) next.modelId = updates.modelId;
if (updates.apiKey !== undefined) next.apiKey = updates.apiKey;
if (updates.baseUrl !== undefined) next.baseUrl = updates.baseUrl;
if (updates.headers !== undefined) next.headers = updates.headers;
if (updates.providerConfig !== undefined)
next.providerConfig = updates.providerConfig;
if (Object.hasOwn(updates, "reasoningEffort")) {
next.reasoningEffort = updates.reasoningEffort ?? undefined;
}
if (Object.hasOwn(updates, "thinkingBudgetTokens")) {
next.thinkingBudgetTokens = updates.thinkingBudgetTokens ?? undefined;
}
if (Object.hasOwn(updates, "thinking")) {
next.thinking = updates.thinking ?? undefined;
if (updates.thinking === false || updates.thinking === null) {
next.reasoningEffort = undefined;
next.thinkingBudgetTokens = undefined;
}
}
if (overrides.providerId !== undefined)
next.providerId = overrides.providerId;
if (overrides.modelId !== undefined) next.modelId = overrides.modelId;
if (overrides.apiKey !== undefined) next.apiKey = overrides.apiKey;
if (overrides.baseUrl !== undefined) next.baseUrl = overrides.baseUrl;
if (overrides.headers !== undefined) next.headers = overrides.headers;
if (overrides.providerConfig !== undefined)
next.providerConfig = overrides.providerConfig;
if (overrides.reasoningEffort !== undefined)
next.reasoningEffort = overrides.reasoningEffort;
if (overrides.thinking !== undefined) next.thinking = overrides.thinking;
if (overrides.thinkingBudgetTokens !== undefined)
next.thinkingBudgetTokens = overrides.thinkingBudgetTokens;
this.config = next;
}
@@ -216,8 +216,8 @@ describe("resolveProviderConfig", () => {
family: "gpt",
release_date: "2027-01-01",
},
"gpt-5.3-live": {
name: "GPT-5.3 Live",
"gpt-5.4-live": {
name: "GPT-5.4 Live",
tool_call: true,
reasoning: true,
family: "gpt",
@@ -256,7 +256,7 @@ describe("resolveProviderConfig", () => {
});
expect(resolved?.knownModels?.["gpt-5.6-live"]?.name).toBe("GPT-5.6 Live");
expect(resolved?.knownModels?.["gpt-5.3-live"]).toBeUndefined();
expect(resolved?.knownModels?.["gpt-5.4-live"]).toBeUndefined();
expect(resolved?.knownModels?.["gpt-5.4-nano"]).toBeUndefined();
expect(resolved?.knownModels?.["o-live"]).toBeUndefined();
});
@@ -481,8 +481,9 @@ describe("resolveProviderConfig", () => {
const openAiResolved = await resolveProviderConfig("openai-native");
const modelIds = Object.keys(resolved?.knownModels ?? {});
expect(modelIds).toEqual(expect.arrayContaining(["gpt-5.5", "gpt-5.4"]));
expect(modelIds).not.toContain("gpt-5.5-pro");
expect(modelIds).toEqual(
expect.arrayContaining(["gpt-5.5", "gpt-5.5-pro", "gpt-5.4"]),
);
expect(modelIds).not.toContain("gpt-5.1-codex-max");
expect(modelIds).not.toContain("gpt-5.2-codex");
expect(modelIds).not.toContain("gpt-5.4-nano");
@@ -491,10 +492,8 @@ describe("resolveProviderConfig", () => {
expect(resolved?.knownModels?.["gpt-5.5"]).toEqual(
expect.objectContaining({
...openAiResolved?.knownModels?.["gpt-5.5"],
// ChatGPT/Codex backend caps: 272K input at the 95% effective budget
maxInputTokens: 272_000 * 0.95,
maxInputTokens: 272_000,
contextWindow: 400_000,
maxTokens: 128_000,
}),
);
});
@@ -517,8 +516,7 @@ describe("resolveProviderConfig", () => {
expect(resolved?.knownModels?.["gpt-5.4-mini"]).toEqual(
expect.objectContaining({
name: "GPT-5.4 mini",
// catalog input cap scaled to the 95% effective Codex budget
maxInputTokens: 272_000 * 0.95,
maxInputTokens: 272_000,
contextWindow: 400_000,
}),
);
@@ -16,7 +16,6 @@ import {
deleteLocalProvider,
getLocalProviderModels,
listLocalProviders,
markLocalProviderEnabled,
normalizeOAuthProvider,
refreshProviderModelsFromSource,
resolveLocalClineAuthToken,
@@ -1238,51 +1237,6 @@ describe("listLocalProviders", () => {
expect(p?.enabled).toBe(true);
});
it("marks alias providers enabled without copying shared OAuth credentials", async () => {
manager.saveProviderSettings(
{
provider: "cline",
auth: {
accessToken: "shared-token",
refreshToken: "shared-refresh",
},
},
{ setLastUsed: false, tokenSource: "oauth" },
);
markLocalProviderEnabled(manager, "cline-pass", { tokenSource: "oauth" });
const state = manager.read();
expect(state.providers["cline-pass"]?.settings).toEqual({
provider: "cline-pass",
});
expect(state.providers["cline-pass"]?.tokenSource).toBe("oauth");
});
it("resolves shared OAuth metadata for ClinePass catalog entries", async () => {
manager.saveProviderSettings(
{
provider: "cline",
auth: {
accessToken: "shared-token",
refreshToken: "shared-refresh",
},
},
{ setLastUsed: false, tokenSource: "oauth" },
);
markLocalProviderEnabled(manager, "cline-pass", { tokenSource: "oauth" });
const { providers } = await listLocalProviders(manager, {
isClinePassEnabled: true,
});
const clinePass = providers.find((provider) => provider.id === "cline-pass");
expect(clinePass).toMatchObject({
enabled: true,
oauthAccessTokenPresent: true,
});
});
it("exposes model count", async () => {
await addLocalProvider(manager, {
providerId: "count-provider",
@@ -24,7 +24,6 @@ import type {
ProviderProtocol,
ProviderSettings,
} from "../../services/llms/provider-settings";
import type { ProviderTokenSource } from "../../types/provider-settings";
import type { ProviderSettingsManager } from "../storage/provider-settings-manager";
import {
readModelsFile,
@@ -653,26 +652,6 @@ export async function deleteLocalProvider(
};
}
export function markLocalProviderEnabled(
manager: ProviderSettingsManager,
providerId: string,
options: { tokenSource?: ProviderTokenSource } = {},
): { providerId: string; enabled: true; settingsPath: string } {
const id = providerId.trim();
if (!id) throw new Error("providerId is required");
const directSettings = manager.read().providers[id]?.settings;
manager.saveProviderSettings(
{
...(directSettings ?? {}),
provider: id,
},
{ setLastUsed: false, tokenSource: options.tokenSource },
);
return { providerId: id, enabled: true, settingsPath: manager.getFilePath() };
}
export async function listLocalProviders(
manager: ProviderSettingsManager,
options: ListLocalProvidersOptions = {},
@@ -688,8 +667,7 @@ export async function listLocalProviders(
LlmsModels.getModelsForProvider(id),
]);
const modelList = toSortedProviderModels(registeredModels);
const directSettings = state.providers[id]?.settings;
const persistedSettings = manager.getProviderSettings(id);
const persistedSettings = state.providers[id]?.settings;
const name = info?.name ?? titleCaseFromId(id);
const capabilities = resolveProviderCapabilities(
info?.capabilities,
@@ -705,7 +683,7 @@ export async function listLocalProviders(
models: modelList.length,
color: stableColor(id),
letter: createLetter(name),
enabled: Boolean(directSettings),
enabled: Boolean(persistedSettings),
apiKey: persistedSettings
? resolveVisibleApiKey(persistedSettings)
: undefined,
+7 -12
View File
@@ -37,10 +37,6 @@ export interface CoreModelConfig {
* Explicit reasoning effort override for capable models.
*/
reasoningEffort?: ProviderConfig["reasoningEffort"];
/**
* Explicit thinking/reasoning token budget for capable models.
*/
thinkingBudgetTokens?: number;
/**
* Maximum output tokens per API call.
*/
@@ -66,17 +62,9 @@ export interface CoreCompactionContext {
provider: string;
info?: ModelInfo;
};
/**
* Usable input budget after reserving any shared context window space needed
* for model output.
*/
maxInputTokens: number;
triggerTokens: number;
targetTokens?: number;
/**
* Effective trigger point as a fraction of the usable input budget
* (`triggerTokens / maxInputTokens`).
*/
thresholdRatio: number;
utilizationRatio: number;
}
@@ -91,6 +79,13 @@ export interface CoreCompactionSummarizerConfig {
apiKey?: string;
baseUrl?: string;
headers?: Record<string, string>;
/**
* Optional pre-resolved model metadata for the summarizer. Supplying either
* this or `knownModels` lets agentic compaction budget summary input against
* the summarizer model's actual context window instead of falling back to the
* active model's window.
*/
modelInfo?: ModelInfo;
knownModels?: Record<string, ModelInfo>;
providerConfig?: ProviderConfig;
maxOutputTokens?: number;
-1
View File
@@ -7,7 +7,6 @@ export type {
ProviderInfo,
} from "./models";
export {
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
filterOpenAICodexModels,
getAllProviders,
getGeneratedModelsForProvider,
-1
View File
@@ -9,7 +9,6 @@ export type {
ProviderProtocol,
} from "./models";
export {
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
fetchLiveProviderModels,
fetchModelsDevProviderModels,
filterOpenAICodexModels,
+1 -4
View File
@@ -35,7 +35,4 @@ export {
resetRegistry,
unregisterProvider,
} from "./providers/model-registry";
export {
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
filterOpenAICodexModels,
} from "./providers/openai-codex-models";
export { filterOpenAICodexModels } from "./providers/openai-codex-models";
@@ -120,9 +120,13 @@ describe("built-in provider metadata", () => {
const modelIds = Object.keys(chatGptModels);
expect(modelIds).toEqual(
expect.arrayContaining(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]),
expect.arrayContaining([
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-mini",
]),
);
expect(modelIds).not.toContain("gpt-5.5-pro");
expect(modelIds).not.toContain("gpt-5.1-codex-max");
expect(modelIds).not.toContain("gpt-5.2");
expect(modelIds).not.toContain("gpt-5.2-codex");
@@ -133,10 +137,8 @@ describe("built-in provider metadata", () => {
expect(chatGptModels["gpt-5.5"]).toEqual(
expect.objectContaining({
...openAiModels["gpt-5.5"],
// ChatGPT/Codex backend caps: 272K input at the 95% effective budget
maxInputTokens: 272_000 * 0.95,
maxInputTokens: 272_000,
contextWindow: 400_000,
maxTokens: 128_000,
}),
);
expect(chatGptModels["gpt-5.4"]).toEqual(
@@ -1,122 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ModelInfo } from "../catalog/types";
import {
CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
filterOpenAICodexModels,
} from "./openai-codex-models";
function makeModel(id: string, overrides: Partial<ModelInfo> = {}): ModelInfo {
return {
id,
contextWindow: 400_000,
maxInputTokens: 300_000,
maxTokens: 100_000,
family: id.replace(/^gpt-/, "gpt"),
...overrides,
};
}
function filterOne(
id: string,
overrides: Partial<ModelInfo> = {},
): ModelInfo | undefined {
return filterOpenAICodexModels({ [id]: makeModel(id, overrides) })[id];
}
describe("filterOpenAICodexModels", () => {
describe("model eligibility", () => {
it.each([
["gpt-5.4", true],
["gpt-5.5", true],
["gpt-5.5-codex", true],
["gpt-6.0", true],
["gpt-10.1", true],
])("allows %s (newer than 5.3)", (id, allowed) => {
expect(filterOne(id) !== undefined).toBe(allowed);
});
it.each([
["gpt-5.3", "at the 5.3 cutoff"],
["gpt-5.1", "older than 5.3"],
["gpt-4.1", "older major version"],
["gpt-5", "no minor version"],
["chatgpt-5.5", "id does not start with gpt-"],
["davinci", "not a gpt model"],
])("rejects %s (%s)", (id) => {
expect(filterOne(id)).toBeUndefined();
});
it.each([
["o-series", "o4"],
["pro variant", "gpt5.5-pro"],
["nano variant", "gpt5.5-nano"],
])("rejects %s families regardless of id version", (_label, family) => {
expect(filterOne("gpt-6.0", { family })).toBeUndefined();
});
it("falls back to the id version check when family is missing", () => {
expect(filterOne("gpt-6.0", { family: undefined })).toBeDefined();
expect(filterOne("gpt-5.0", { family: undefined })).toBeUndefined();
});
});
describe("context window adjustment", () => {
it.each(["gpt-5.4", "gpt-5.4-mini", "gpt-6.0"])(
"scales %s maxInputTokens down to the effective Codex budget — the backend cap applies to every model, not just gpt-5.5",
(id) => {
const maxInputTokens = 200_000;
const result = filterOne(id, { maxInputTokens });
expect(result?.maxInputTokens).toBe(
maxInputTokens * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
);
},
);
it("leaves other limits untouched for non-5.5 models", () => {
const result = filterOne("gpt-6.0", {
contextWindow: 500_000,
maxTokens: 64_000,
});
expect(result?.contextWindow).toBe(500_000);
expect(result?.maxTokens).toBe(64_000);
});
it("preserves an undefined maxInputTokens instead of producing NaN", () => {
const result = filterOne("gpt-6.0", { maxInputTokens: undefined });
expect(result?.maxInputTokens).toBeUndefined();
});
it("overrides gpt-5.5 limits with the ChatGPT backend caps", () => {
const result = filterOne("gpt-5.5-codex", {
contextWindow: 1_000_000,
maxInputTokens: 900_000,
maxTokens: 900_000,
});
expect(result).toMatchObject({
contextWindow: 400_000,
maxInputTokens: 272_000 * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
maxTokens: 128_000,
});
});
it("does not mutate the input models", () => {
const model = makeModel("gpt-6.0");
const snapshot = structuredClone(model);
filterOpenAICodexModels({ "gpt-6.0": model });
expect(model).toEqual(snapshot);
});
});
it("keeps allowed models and drops disallowed ones from a mixed catalog", () => {
const models: Record<string, ModelInfo> = {
"gpt-5.5": makeModel("gpt-5.5"),
"gpt-6.0": makeModel("gpt-6.0"),
"gpt-5.1": makeModel("gpt-5.1"),
"o4-mini": makeModel("o4-mini", { family: "o4" }),
};
expect(Object.keys(filterOpenAICodexModels(models)).sort()).toEqual([
"gpt-5.5",
"gpt-6.0",
]);
});
});
@@ -1,63 +1,35 @@
import type { ModelInfo } from "../catalog/types";
/**
* The ChatGPT/Codex backend starts rejecting requests around 95% of a
* model's advertised input cap, so every model exposed through this
* provider gets its maxInputTokens scaled down to the effective budget.
*
* REF: https://github.com/openai/codex/issues/19319
*/
export const CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 0.95;
const OPENAI_CODEX_ALLOWED_MODELS = new Set([
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
]);
const GPT_VERSION_REGEX = /^gpt-(\d+\.\d+)/;
function isOpenAICodexAllowedModel(id: string, model: ModelInfo): boolean {
// O, pro, and nano variants are not supported
const family = model.family;
if (
family &&
(family.startsWith("o") ||
family.includes("pro") ||
family.includes("nano"))
) {
return false;
}
// Must be newer than 5.3
const match = id.match(GPT_VERSION_REGEX);
return match ? Number.parseFloat(match[1]) > 5.3 : false;
function isOpenAICodexAllowedModel(id: string): boolean {
if (OPENAI_CODEX_ALLOWED_MODELS.has(id)) return true;
const match = id.match(/^gpt-(\d+\.\d+)/);
return match ? Number.parseFloat(match[1]) > 5.4 : false;
}
/**
* Applies the effective input budget to every allowed model. GPT-5.5
* additionally gets hardcoded limits because the ChatGPT/Codex backend
* enforces a 272K input / 128K output cap that is lower than what the
* generated OpenAI API catalog reports.
*/
function toOpenAICodexModel(id: string, model: ModelInfo): ModelInfo {
if (id.includes("gpt-5.5")) {
return {
...model,
contextWindow: 400_000,
maxInputTokens: 272_000 * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
maxTokens: 128_000,
};
if (!id.includes("gpt-5.5")) {
return model;
}
return {
...model,
maxInputTokens: model.maxInputTokens
? model.maxInputTokens * CODEX_EFFECTIVE_CONTEXT_WINDOW_PERCENT
: model.maxInputTokens,
contextWindow: 400_000,
maxInputTokens: 272_000,
maxTokens: 128_000,
};
}
export function filterOpenAICodexModels(
models: Record<string, ModelInfo>,
): Record<string, ModelInfo> {
const result: Record<string, ModelInfo> = {};
for (const [id, model] of Object.entries(models)) {
if (isOpenAICodexAllowedModel(id, model)) {
result[id] = toOpenAICodexModel(id, model);
}
}
return result;
return Object.fromEntries(
Object.entries(models)
.filter(([id]) => isOpenAICodexAllowedModel(id))
.map(([id, model]) => [id, toOpenAICodexModel(id, model)]),
);
}
-2
View File
@@ -384,7 +384,6 @@ export type HubCommandName =
| "session.restore"
| "session.delete"
| "session.update"
| "session.update_connection"
| "session.compaction.get"
| "session.compaction.update"
| "session.pending_prompts"
@@ -666,7 +665,6 @@ export interface HubSessionRuntimeOptions {
timeoutSeconds?: number;
thinking?: boolean;
reasoningEffort?: ReasoningEffort;
thinkingBudgetTokens?: number;
checkpointEnabled?: boolean;
enableTools?: boolean;
enableSpawn?: boolean;