Merge remote-tracking branch 'origin/main' into bee/keybindings

# Conflicts:
#	apps/examples/desktop-app/package.json
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/app/page.tsx
This commit is contained in:
Saoud Rizwan
2026-10-22 23:50:50 +00:00
62 changed files with 2125 additions and 202 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ The skill should guide the user through one release preparation flow, then offer
- Local publish helper: `bun release cli`.
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag. It is manual dispatch only; there is no cron.
- Every CI publish (CLI main and nightly, SDK, UI) waits for a reviewer to approve the run in the `PublishNPM` GitHub environment, and the npm trusted publishers require that environment. The run sits in `waiting` until approved; that is not a hang. Hand the user the run URL so they can click "Review deployments"; never approve it via the API.
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
- Always ask before pushing commits or tags.
+2 -2
View File
@@ -22,8 +22,8 @@ Release `@cline/ui` independently from the Cline SDK runtime packages.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- The publish job and npm trust relationship use the protected `PublishNPM`
environment; the run waits for a reviewer to approve it before publishing.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
+2 -2
View File
@@ -1,2 +1,2 @@
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
/README.md @saoudrizwan @juanpflores
/.github/ @saoudrizwan
/README.md @saoudrizwan
+15 -10
View File
@@ -1,8 +1,8 @@
name: cli-publish
# Manual dispatch only, nightly included. Every publish waits for approval on
# the PublishNPM environment, so a cron run would just sit waiting every day.
on:
schedule:
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
publish_target:
@@ -48,6 +48,11 @@ jobs:
github.event.inputs.publish_target == 'main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
# npm trusted publishing for every CLI package requires this environment,
# so a publish only happens after a PublishNPM reviewer approves the run.
# The environment's deployment-branch policy (main only) is the enforced
# branch gate; the `if` above is advisory.
environment: PublishNPM
runs-on: ubuntu-latest
steps:
@@ -305,13 +310,13 @@ jobs:
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
(
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'nightly'
)
)
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'nightly'
# npm trusted publishing for every CLI package requires this environment,
# so a publish only happens after a PublishNPM reviewer approves the run.
# The environment's deployment-branch policy (main only) is the enforced
# branch gate; the `if` above is advisory.
environment: PublishNPM
runs-on: ubuntu-latest
steps:
@@ -323,7 +328,7 @@ jobs:
- name: Check for recent commits
id: check_commits
env:
FORCE_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
FORCE_PUBLISH: ${{ github.event.inputs.force_nightly_publish == 'true' }}
run: |
if [ "$FORCE_PUBLISH" = "true" ]; then
echo "force_nightly_publish enabled, proceeding with publish"
+3 -3
View File
@@ -86,12 +86,12 @@ jobs:
shell: pwsh
run: |
$zip = Join-Path $env:RUNNER_TEMP 'nsis.zip'
Invoke-WebRequest 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip' -OutFile $zip
if ((Get-FileHash $zip -Algorithm SHA1).Hash -ne 'EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D') {
Invoke-WebRequest 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.13/nsis-3.13.zip' -OutFile $zip
if ((Get-FileHash $zip -Algorithm SHA1).Hash -ne 'DB14F8AF2EA346B786C6AE19343F61AAF5E09B8E') {
throw 'NSIS distribution checksum mismatch'
}
Expand-Archive $zip -DestinationPath $env:RUNNER_TEMP
"MAKENSIS_PATH=$env:RUNNER_TEMP/nsis-3.11/makensis.exe" >> $env:GITHUB_ENV
"MAKENSIS_PATH=$env:RUNNER_TEMP/nsis-3.13/makensis.exe" >> $env:GITHUB_ENV
- name: Test install and uninstall process cleanup
run: bun run test:windows-installer
+6 -11
View File
@@ -1,5 +1,7 @@
name: sdk-publish
# Manual dispatch only. Every publish (nightly included) waits for approval on
# the PublishNPM environment, so a cron run would just sit waiting every day.
on:
workflow_dispatch:
inputs:
@@ -20,9 +22,6 @@ on:
description: 'Required when channel=latest. Type "publish" to confirm release publish.'
required: false
type: string
schedule:
# Run nightly at 2:00 AM UTC
- cron: "0 2 * * *"
defaults:
run:
@@ -51,6 +50,9 @@ jobs:
!endsWith(github.actor, '[bot]')
)
)
# npm trusted publishing for the SDK packages requires this environment,
# so a publish only happens after a PublishNPM reviewer approves the run.
environment: PublishNPM
runs-on: ubuntu-latest
steps:
@@ -60,15 +62,8 @@ jobs:
- name: Determine publish channel
id: channel
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_CHANNEL: ${{ inputs.channel }}
run: |
# Default to nightly for scheduled runs
if [ "$EVENT_NAME" = "schedule" ]; then
echo "channel=nightly" >> $GITHUB_OUTPUT
else
echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
fi
run: echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
- name: Check for recent commits
id: check_commits
+3
View File
@@ -105,6 +105,9 @@ jobs:
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
# npm trusted publishing for @cline/ui requires this environment, so a
# publish only happens after a PublishNPM reviewer approves the run.
environment: PublishNPM
runs-on: ubuntu-latest
permissions:
contents: read
+1 -1
View File
@@ -5,7 +5,7 @@
<h1 align="center">Cline</h1>
<p align="center">
The open source coding agent in your IDE, terminal, & desktop.
The open source coding agent in your IDE, terminal, & desktop!
</p>
<div align="center">
+3 -2
View File
@@ -129,10 +129,11 @@ The GitHub workflow at `.github/workflows/cli-publish.yml` automates publishing:
- Main releases are manual. Select `publish_target=main` and set `confirm_publish=publish`.
- Main releases require `git_tag=cli-vX.Y.Z`, check out that tag, verify it matches `apps/cli/package.json`, run tests, build all platform packages, publish to npm with the `latest` dist-tag using trusted publishing, create a GitHub release, and post to Slack.
- Nightly releases run on a schedule or manually with `publish_target=nightly`.
- Nightly releases are manual only, with `publish_target=nightly`. There is no schedule.
- Every run, main or nightly, waits for a reviewer to approve it in the `PublishNPM` GitHub environment.
- Nightly releases publish `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skip if there were no commits in the last 24 hours unless forced.
CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package before relying on the workflow.
CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package, with the environment set to `PublishNPM`, before relying on the workflow.
## How It Works Under the Hood
+30 -79
View File
@@ -14,6 +14,7 @@ import type {
PromptRequest,
PromptResponse,
SessionConfigOption,
SessionConfigSelectOption,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
@@ -47,11 +48,8 @@ import {
authenticateAcpProvider,
isAcpAuthMethodId,
} from "./auth";
import {
AUTO_APPROVE_CONFIG_ID,
buildAutoApproveConfigOption,
parseAutoApproveValue,
} from "./auto-approve";
import { AUTO_APPROVE_CONFIG_ID, parseAutoApproveValue } from "./auto-approve";
import { buildConfigOptions } from "./config-options";
import {
buildOrganizationConfigOption,
fetchClineOrganizations,
@@ -236,13 +234,15 @@ export class AcpAgent implements Agent {
availableModels,
currentModelId: defaultModelId,
},
configOptions: [
await buildProviderConfigOption(providerId),
buildModelConfigOption(defaultModelId, providerModels),
buildModeConfigOption(defaultMode),
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
...(organizationOption ? [organizationOption] : []),
],
configOptions: buildConfigOptions({
currentProviderId: providerId,
providerOptions: await getProviderOptions(),
currentModelId: defaultModelId,
providerModels,
currentMode: defaultMode,
autoApproveTools: this.defaultAutoApproveTools,
organizationOption,
}),
};
}
@@ -562,13 +562,13 @@ export class AcpAgent implements Agent {
);
}
const configOptions = await buildAllConfigOptions(session);
const organizationOption = await this.getOrganizationConfigOption(
session.currentProviderId,
);
if (organizationOption) {
configOptions.push(organizationOption);
}
const configOptions = await buildAllConfigOptions(
session,
organizationOption,
);
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
return { configOptions };
}
@@ -857,10 +857,8 @@ function toAcpPromptError(error: Error): RequestError {
: RequestError.internalError({ message }, message);
}
async function buildProviderConfigOption(
currentProviderId: string,
): Promise<SessionConfigOption> {
const options = await Promise.all(
async function getProviderOptions(): Promise<SessionConfigSelectOption[]> {
return Promise.all(
ACP_AUTH_METHODS.map(async (m) => {
const provider = await Llms.getProvider(m.id);
return {
@@ -869,75 +867,28 @@ async function buildProviderConfigOption(
};
}),
);
return {
type: "select",
id: "provider",
name: "Provider",
description: "The authentication provider to use",
category: "model",
currentValue: currentProviderId,
options,
};
}
function buildModelConfigOption(
currentModelId: string,
providerModels: Record<string, { name?: string; description?: string }>,
): SessionConfigOption {
return {
type: "select",
id: "model",
name: "Model",
category: "model",
currentValue: currentModelId,
options: Object.entries(providerModels).map(([modelId, info]) => ({
value: modelId,
name: info.name ?? modelId,
description: info.description,
})),
};
}
function buildModeConfigOption(currentMode: string): SessionConfigOption {
return {
type: "select",
id: "mode",
name: "Session Mode",
description: "Controls whether the agent can modify files",
category: "mode",
currentValue: currentMode,
options: [
{
value: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
value: "act",
name: "Act",
description: "Make changes to the codebase",
},
],
};
}
async function buildAllConfigOptions(
session: SessionState,
organizationOption?: SessionConfigOption,
): Promise<SessionConfigOption[]> {
const [providerOption, providerModels] = await Promise.all([
buildProviderConfigOption(session.currentProviderId),
const [providerOptions, providerModels] = await Promise.all([
getProviderOptions(),
Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
),
]);
return [
providerOption,
buildModelConfigOption(session.currentModelId, providerModels),
buildModeConfigOption(session.currentMode),
buildAutoApproveConfigOption(session.autoApproveTools),
];
return buildConfigOptions({
currentProviderId: session.currentProviderId,
providerOptions,
currentModelId: session.currentModelId,
providerModels,
currentMode: session.currentMode,
autoApproveTools: session.autoApproveTools,
organizationOption,
});
}
function extractTextFromContentBlocks(blocks: ContentBlock[]): string {
+110
View File
@@ -0,0 +1,110 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { describe, expect, it } from "vitest";
import {
buildConfigOptions,
buildProviderConfigOption,
MODEL_CONFIG_CATEGORY,
PROVIDER_CONFIG_CATEGORY,
} from "./config-options";
// ACP reserves every category name that does not start with `_` for the spec.
const ACP_RESERVED_CATEGORIES = [
"mode",
"model",
"model_config",
"thought_level",
];
const providerModels = {
"anthropic/claude-sonnet-5.5": { name: "Claude Sonnet 5.5" },
"openrouter/free": { name: "Free" },
};
const providerOptions = [
{ value: "cline", name: "Cline" },
{ value: "openrouter", name: "OpenRouter" },
];
const organizationOption: SessionConfigOption = {
type: "select",
id: "organization",
name: "Account",
category: "_account",
currentValue: "personal",
options: [{ value: "personal", name: "Personal" }],
};
describe("buildProviderConfigOption", () => {
it("builds the provider selector from the current provider", () => {
const option = buildProviderConfigOption("cline", providerOptions);
expect(option).toMatchObject({
type: "select",
id: "provider",
currentValue: "cline",
options: providerOptions,
});
});
it("uses a custom category instead of a spec-reserved one", () => {
const option = buildProviderConfigOption("cline", providerOptions);
expect(option.category).toBe(PROVIDER_CONFIG_CATEGORY);
expect(ACP_RESERVED_CATEGORIES).not.toContain(option.category);
expect(option.category).toMatch(/^_/);
});
});
describe.each([
false,
true,
])("ACP config options (organization: %s)", (includeOrganization) => {
const options = buildConfigOptions({
currentProviderId: "cline",
providerOptions,
currentModelId: "anthropic/claude-sonnet-5.5",
providerModels,
currentMode: "act",
autoApproveTools: false,
organizationOption: includeOrganization ? organizationOption : undefined,
});
it("tags only the model selector with the model category", () => {
expect(
options
.filter((option) => option.category === MODEL_CONFIG_CATEGORY)
.map((option) => option.id),
).toEqual(["model"]);
});
it("uses reserved or custom category names for every option", () => {
for (const { category } of options) {
expect(
category === undefined ||
category === null ||
ACP_RESERVED_CATEGORIES.includes(category) ||
category.startsWith("_"),
).toBe(true);
}
});
it("does not repeat reserved categories in the assembled list", () => {
const categories = options
.map((option) => option.category)
.filter(
(category) => category && ACP_RESERVED_CATEGORIES.includes(category),
);
expect(new Set(categories).size).toBe(categories.length);
});
it("preserves the option order", () => {
expect(options.map((option) => option.id)).toEqual([
"provider",
"model",
"mode",
"auto_approve",
...(includeOrganization ? ["organization"] : []),
]);
});
});
+95
View File
@@ -0,0 +1,95 @@
import type {
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk";
import { buildAutoApproveConfigOption } from "./auto-approve";
// Clients break category ties by array order, so the provider must not claim
// the model selector's category when it comes first.
export const PROVIDER_CONFIG_CATEGORY = "_provider";
export const MODEL_CONFIG_CATEGORY = "model";
interface ConfigOptionsInput {
currentProviderId: string;
providerOptions: SessionConfigSelectOption[];
currentModelId: string;
providerModels: Record<string, { name?: string; description?: string }>;
currentMode: string;
autoApproveTools: boolean;
organizationOption?: SessionConfigOption;
}
export function buildProviderConfigOption(
currentProviderId: string,
options: SessionConfigSelectOption[],
): SessionConfigOption {
return {
type: "select",
id: "provider",
name: "Provider",
description: "The authentication provider to use",
category: PROVIDER_CONFIG_CATEGORY,
currentValue: currentProviderId,
options,
};
}
export function buildModelConfigOption(
currentModelId: string,
providerModels: Record<string, { name?: string; description?: string }>,
): SessionConfigOption {
return {
type: "select",
id: "model",
name: "Model",
category: MODEL_CONFIG_CATEGORY,
currentValue: currentModelId,
options: Object.entries(providerModels).map(([modelId, info]) => ({
value: modelId,
name: info.name ?? modelId,
description: info.description,
})),
};
}
function buildModeConfigOption(currentMode: string): SessionConfigOption {
return {
type: "select",
id: "mode",
name: "Session Mode",
description: "Controls whether the agent can modify files",
category: "mode",
currentValue: currentMode,
options: [
{
value: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
value: "act",
name: "Act",
description: "Make changes to the codebase",
},
],
};
}
export function buildConfigOptions({
currentProviderId,
providerOptions,
currentModelId,
providerModels,
currentMode,
autoApproveTools,
organizationOption,
}: ConfigOptionsInput): SessionConfigOption[] {
return [
buildProviderConfigOption(currentProviderId, providerOptions),
buildModelConfigOption(currentModelId, providerModels),
buildModeConfigOption(currentMode),
buildAutoApproveConfigOption(autoApproveTools),
...(organizationOption ? [organizationOption] : []),
];
}
+1
View File
@@ -28,6 +28,7 @@ describe("buildOrganizationConfigOption", () => {
activeOrganizationId: "org-2",
});
expect(option.id).toBe("organization");
expect(option.category).toBe("_account");
if (option.type !== "select") {
throw new Error(`expected a select option, got ${option.type}`);
}
+1 -1
View File
@@ -94,7 +94,7 @@ export function buildOrganizationConfigOption(
name: "Account",
description:
"The Cline account usage is billed to — your personal account or an organization",
category: "account",
category: "_account",
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
options: [
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
+2 -2
View File
@@ -32,7 +32,7 @@
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx webview/components/quit-hold-handler.test.tsx webview/lib/keybindings.test.ts --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -110,7 +110,7 @@
"devDependencies": {
"@cline/sdk": "workspace:*",
"@tailwindcss/postcss": "^4.2.0",
"@tauri-apps/cli": "^2.0.0",
"@tauri-apps/cli": "^2.12.3",
"@types/bun": "^1.4.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
@@ -145,6 +145,29 @@ function processCommandLine(pid: number): string | undefined {
return ps.status === 0 ? ps.stdout.trim() : undefined;
}
function waitForHubStartup(wsEndpoint: string): Promise<string> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsEndpoint);
const timer = setTimeout(() => {
socket.close();
reject(new Error("Hub startup did not settle within 30 seconds"));
}, 30_000);
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
const state = message.event?.payload?.state;
if (message.event?.name === "hub_status" && state !== "starting") {
clearTimeout(timer);
socket.close();
resolve(state);
}
};
socket.onerror = () => {
clearTimeout(timer);
reject(new Error(`Could not connect to ${wsEndpoint}`));
};
});
}
async function runStartupScenario(
extraEnv: Record<string, string>,
options: { hubAlreadyRunning: boolean; discoveryContents?: string },
@@ -231,12 +254,16 @@ async function runStartupScenario(
},
);
let endpoint: string | undefined;
let wsEndpoint: string | undefined;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
try {
const message = JSON.parse(line);
if (message.type === "ready") endpoint = message.endpoint;
if (message.type === "ready") {
endpoint = message.endpoint;
wsEndpoint = message.wsEndpoint;
}
} catch {
/* Ignore other output and incomplete lines. */
}
@@ -253,6 +280,11 @@ async function runStartupScenario(
});
expect(health.ok).toBe(true);
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
// The backend publishes its endpoint before starting the Hub and reports
// Hub startup over the transport, replaying the current state on connect.
expect(await waitForHubStartup(String(wsEndpoint)), stderrPath).toBe(
"ready",
);
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
if (existingHub) {
@@ -28,8 +28,8 @@ const lockfile = path.resolve(import.meta.dir, "../../../../bun.lock");
// of the locked @tauri-apps/cli), and desktop-test.yml downloads that same
// archive by hand. Pin both ends here so a Tauri CLI bump, or an edit to the
// workflow's download, fails this suite until the pair is re-checked together.
const TAURI_CLI_VERSION = "2.11.4";
const NSIS_VERSION = "v3.11";
const TAURI_CLI_VERSION = "2.12.3";
const NSIS_VERSION = "v3.13";
const children: ChildProcess[] = [];
let root: string;
let fixture: string;
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { handleCommand } from "./commands";
import { HubStartup } from "./hub-startup";
import type { SidecarContext, SidecarWebSocketClient } from "./types";
function contextWith(hubStartup: HubStartup): SidecarContext {
return {
localWorkspaceRoot: "/workspace",
wsClients: new Set(),
runtimeBindings: new Map(),
hubBuildMismatch: null,
hubStartup,
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
}
describe("commands during Hub startup", () => {
it("answers retry without waiting for the Hub", async () => {
const startup = new HubStartup(
() => new Promise(() => {}),
vi.fn(),
vi.fn(),
);
startup.start();
const ctx = contextWith(startup);
await expect(handleCommand(ctx, "retry_hub")).resolves.toEqual({
state: "starting",
});
});
it("holds Hub-backed commands until startup settles", async () => {
let finishSetup!: () => void;
const startup = new HubStartup(
() =>
new Promise<void>((resolve) => {
finishSetup = resolve;
}),
vi.fn(),
vi.fn(),
);
startup.start();
let settled = false;
const pending = handleCommand(
contextWith(startup),
"no_such_command",
).finally(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
finishSetup();
await expect(pending).rejects.toThrow();
expect(settled).toBe(true);
});
it("drops a waiting command whose connection closed during startup", async () => {
let finishSetup!: () => void;
const startup = new HubStartup(
() =>
new Promise<void>((resolve) => {
finishSetup = resolve;
}),
vi.fn(),
vi.fn(),
);
startup.start();
const ctx = contextWith(startup);
const connection = {} as SidecarWebSocketClient;
ctx.wsClients.add(connection);
const pending = handleCommand(
ctx,
"run_provider_oauth_login",
{ providerId: "cline" },
{ connection },
);
ctx.wsClients.delete(connection);
finishSetup();
await expect(pending).rejects.toThrow(
"The connection closed while Cline Hub was starting.",
);
});
});
@@ -133,6 +133,7 @@ import {
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { getHubStatus, restartHub } from "./hub-admin";
import { isHubIndependentCommand } from "./hub-startup";
import {
readKeybindings,
resolveKeybindingsPath,
@@ -1882,6 +1883,20 @@ export async function handleCommand(
args?: Record<string, unknown>,
options?: { connection?: SidecarWebSocketClient },
): Promise<unknown> {
if (command === "retry_hub") {
ctx.hubStartup?.start();
return ctx.hubStartup?.status ?? null;
}
// The transport is published before the Hub is ready. Hold Hub-backed
// commands until startup settles, as they waited for the transport before.
if (ctx.hubStartup && !isHubIndependentCommand(command)) {
await ctx.hubStartup.waitUntilSettled();
// Its close cleanup already ran, so nothing could cancel work (such as a
// browser sign-in) started for a connection that left while waiting.
if (options?.connection && !ctx.wsClients.has(options.connection)) {
throw new Error("The connection closed while Cline Hub was starting.");
}
}
const explicitEnvironment = requestedEnvironmentId(args);
if (explicitEnvironment) {
ctx = getEnvironmentContext(ctx, explicitEnvironment);
@@ -158,6 +158,23 @@ describe("Code sidecar runtime capabilities", () => {
);
});
it("reuses the installed session manager when a retried startup only failed its readiness sync", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
updateCapabilitiesMock.mockRejectedValueOnce(new Error("hub busy"));
await expect(initializeSessionManager(ctx)).rejects.toThrow("hub busy");
const installed = ctx.runtimeBindings.get("local");
expect(installed).toBeDefined();
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledTimes(1);
expect(ctx.runtimeBindings.get("local")).toBe(installed);
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(2);
});
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -1296,6 +1296,12 @@ async function handleHubApprovalRequest(
export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
// A retried startup must not replace (and leak) a manager that an earlier
// attempt installed before its final readiness sync failed.
if (ctx.runtimeBindings.has(LOCAL_ENVIRONMENT_ID)) {
await syncSidecarApprovalReadiness(ctx);
return;
}
setHomeDirIfUnset(homedir());
const sessionManager = await ClineCore.create({
clientName: "cline-code",
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { HubStartup, type HubStartupStatus } from "./hub-startup";
function deferred() {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
afterEach(() => {
vi.useRealTimers();
});
describe("HubStartup", () => {
it("reports ready when setup succeeds and releases waiting commands", async () => {
const setup = deferred();
const statuses: HubStartupStatus[] = [];
const startup = new HubStartup(
() => setup.promise,
(status) => statuses.push(status),
vi.fn(),
);
startup.start();
const waiting = startup.waitUntilSettled();
setup.resolve();
await waiting;
expect(statuses.map((status) => status.state)).toEqual([
"starting",
"ready",
]);
});
it("reports a fixed message and hands the raw error to the failure hook", async () => {
const error = new Error(
"hub exploded at ws://127.0.0.1:25463?token=secret",
);
const onFailure = vi.fn();
const startup = new HubStartup(
() => Promise.reject(error),
vi.fn(),
onFailure,
);
startup.start();
await startup.waitUntilSettled();
expect(onFailure).toHaveBeenCalledWith(error);
expect(startup.status.state).toBe("failed");
expect(startup.status.error).not.toContain("secret");
});
it("fails after the timeout but still becomes ready if the slow setup finishes", async () => {
vi.useFakeTimers();
const setup = deferred();
const onFailure = vi.fn();
const startup = new HubStartup(
() => setup.promise,
vi.fn(),
onFailure,
1_000,
);
startup.start();
await vi.advanceTimersByTimeAsync(1_000);
expect(startup.status).toEqual({
state: "failed",
error: "Cline Hub did not respond within 30 seconds.",
});
expect(onFailure).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("1000ms") }),
);
setup.resolve();
await vi.runAllTimersAsync();
expect(startup.status.state).toBe("ready");
});
it("reuses a pending attempt on retry and starts a new one after it fails", async () => {
vi.useFakeTimers();
const first = deferred();
const run = vi
.fn<() => Promise<void>>()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(undefined);
const startup = new HubStartup(run, vi.fn(), vi.fn(), 1_000);
startup.start();
await vi.advanceTimersByTimeAsync(1_000);
startup.start();
expect(startup.status.state).toBe("starting");
expect(run).toHaveBeenCalledTimes(1);
first.reject(new Error("still broken"));
await vi.runAllTimersAsync();
expect(startup.status.state).toBe("failed");
startup.start();
await vi.runAllTimersAsync();
expect(run).toHaveBeenCalledTimes(2);
expect(startup.status.state).toBe("ready");
});
});
@@ -0,0 +1,84 @@
import type { DesktopHubStatus } from "../webview/lib/desktop-transport";
export type HubStartupStatus = DesktopHubStatus;
const HUB_STARTUP_TIMEOUT_MS = 30_000;
/** Commands the startup screen needs before the Hub is ready. */
const HUB_INDEPENDENT_COMMANDS = new Set(["retry_hub", "export_diagnostics"]);
export function isHubIndependentCommand(command: string): boolean {
return HUB_INDEPENDENT_COMMANDS.has(command);
}
/**
* Runs local Hub setup after the sidecar is already reachable, so the webview
* can show progress, a failure, Retry, and diagnostics export instead of
* waiting on a backend that never comes up.
*
* Only one setup attempt runs at a time. A timeout reports failure but lets
* the attempt keep going: if a slow Hub answers later, startup still
* completes. Retry while an attempt is pending waits on that same attempt.
*/
export class HubStartup {
status: HubStartupStatus = { state: "starting" };
private attempt?: Promise<void>;
private timer?: ReturnType<typeof setTimeout>;
private waiters: Array<() => void> = [];
constructor(
private readonly run: () => Promise<void>,
private readonly onChange: (status: HubStartupStatus) => void,
private readonly onFailure: (error: unknown) => void,
private readonly timeoutMs = HUB_STARTUP_TIMEOUT_MS,
) {}
start(): void {
if (this.status.state === "ready") return;
this.setStatus({ state: "starting" });
clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (this.status.state === "starting") {
this.onFailure(
new Error(
`Local Hub startup did not finish within ${this.timeoutMs}ms; still waiting in the background`,
),
);
this.setStatus({
state: "failed",
error: "Cline Hub did not respond within 30 seconds.",
});
}
}, this.timeoutMs);
if (this.attempt) return;
this.attempt = this.run()
.then(
() => this.setStatus({ state: "ready" }),
(error) => {
this.onFailure(error);
this.setStatus({
state: "failed",
error: "Cline couldn't start or connect to its Hub.",
});
},
)
.finally(() => {
this.attempt = undefined;
});
}
/** Resolves once startup is no longer in progress (ready or failed). */
waitUntilSettled(): Promise<void> {
if (this.status.state !== "starting") return Promise.resolve();
return new Promise((resolve) => this.waiters.push(resolve));
}
private setStatus(status: HubStartupStatus): void {
this.status = status;
if (status.state !== "starting") {
clearTimeout(this.timer);
for (const resolve of this.waiters.splice(0)) resolve();
}
this.onChange(status);
}
}
+60 -35
View File
@@ -25,6 +25,7 @@ import {
disposeSidecarContext,
initializeSessionManager,
} from "./context";
import { HubStartup } from "./hub-startup";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
@@ -90,23 +91,43 @@ async function main(desktopCliPath?: string) {
}
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
// The packaged app hands Hub startup to its bundled CLI: it reuses a
// compatible healthy Hub or starts one, and the session manager attaches.
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
// the session manager still starts the Hub through the same CLI launcher.
if (desktopCliPath) {
try {
const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot);
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
} catch (error) {
observability.logger.error?.("Cline CLI hub ensure failed", { error });
const setUpLocalHub = async (): Promise<void> => {
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
// The packaged app hands Hub startup to its bundled CLI: it reuses a
// compatible healthy Hub or starts one, and the session manager attaches.
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
// the session manager still starts the Hub through the same CLI launcher.
if (desktopCliPath) {
try {
const hub = await ensureHubWithDesktopCli(
desktopCliPath,
workspaceRoot,
);
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
} catch (error) {
observability.logger.error?.("Cline CLI hub ensure failed", { error });
}
}
}
await initializeSessionManager(ctx);
await initializeSessionManager(ctx);
checkStartupHubBuildMismatch();
};
ctx.hubStartup = new HubStartup(
setUpLocalHub,
(status) => broadcastEvent(ctx, "hub_status", status),
(error) => {
observability.logger.error?.("Local Hub startup failed", { error });
captureSdkError(observability.telemetry, {
component: "desktop",
operation: "sidecar.hub_startup",
error,
handled: true,
});
},
);
let shuttingDown = false;
let handlingFatalError = false;
@@ -184,16 +205,35 @@ async function main(desktopCliPath?: string) {
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
},
});
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
const endpoint = `http://${dialHost}:${port}`;
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
wsEndpoint.searchParams.set("approval_token", approvalToken);
process.stdout.write(
`${JSON.stringify({
type: "ready",
endpoint,
wsEndpoint: wsEndpoint.toString(),
pid: process.pid,
mode: SIDECAR_MODE,
})}\n`,
);
// The webview is reachable now, so it can show Hub startup progress and
// failures instead of commands timing out against an unpublished backend.
ctx.hubStartup.start();
// The watcher's first check only runs after its interval, but a mismatch
// that already exists at startup - an older Hub this app attached to
// because it is still serving other clients' sessions - must prompt
// before the user starts working, not half a minute in. Session-manager
// init has already settled the hub state, so check once right away. The
// broadcast reaches webviews that are already connected; the replay in
// before the user starts working, not half a minute in. Hub setup has
// settled the hub state by the time this runs. The broadcast reaches
// webviews that are already connected; the replay in
// createWebSocketHandler covers ones that connect later. Skipped when
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
// hosts keep protocol-only compatibility and must not show update prompts.
if (!process.env.CLINE_HUB_PORT?.trim()) {
function checkStartupHubBuildMismatch(): void {
if (process.env.CLINE_HUB_PORT?.trim()) return;
void checkManagedHubBuildMismatch()
.then((mismatch) => {
if (!mismatch || ctx.hubBuildMismatch) {
@@ -212,21 +252,6 @@ async function main(desktopCliPath?: string) {
})
.catch(() => undefined);
}
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
const endpoint = `http://${dialHost}:${port}`;
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
wsEndpoint.searchParams.set("approval_token", approvalToken);
process.stdout.write(
`${JSON.stringify({
type: "ready",
endpoint,
wsEndpoint: wsEndpoint.toString(),
pid: process.pid,
mode: SIDECAR_MODE,
})}\n`,
);
}
/**
@@ -364,6 +364,9 @@ export function createWebSocketHandler(ctx: SidecarContext) {
pid: process.pid,
mode: SIDECAR_MODE,
});
if (ctx.hubStartup) {
ws.send(encodeSidecarEvent("hub_status", ctx.hubStartup.status));
}
// Replay a pending mismatch so webviews that connect (or reload)
// after detection still prompt the user to update and restart.
if (ctx.hubBuildMismatch) {
@@ -11,6 +11,7 @@ import type {
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type { UserContext } from "@cline/shared";
import type { HubStartup } from "./hub-startup";
export const LOCAL_ENVIRONMENT_ID = "local";
@@ -166,6 +167,8 @@ export type SidecarContext = {
* replayed to webviews that connect after the event fired.
*/
hubBuildMismatch: ManagedHubBuildMismatchEvent | null;
/** Local Hub setup, which runs after the transport is published. */
hubStartup?: HubStartup;
};
export type BunRuntimeApi = {
serve: (options: unknown) => { port: number; stop?: () => void };
+51 -31
View File
@@ -8,6 +8,7 @@ import { AttachmentDropZone } from "@cline/ui";
import { Loader2, LoaderCircle } from "lucide-react";
import dynamic from "next/dynamic";
import {
Activity,
useCallback,
useEffect,
useMemo,
@@ -17,6 +18,7 @@ import {
} from "react";
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
import { HubStartupScreen } from "@/components/hub-startup-screen";
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
import { QuitHoldHandler } from "@/components/quit-hold-handler";
import { SessionCommandBar } from "@/components/session-command-bar";
@@ -55,11 +57,12 @@ import {
import { AccountProvider, useAccount } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import type { ProcessContext } from "@/hooks/chat-session/types";
import { checkForUpdateAndNotify, useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
import { useHubStartup } from "@/hooks/use-hub-startup";
import { usePendingAttachments } from "@/hooks/use-pending-attachments";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
import { useRemoteEnvironmentBootstrap } from "@/hooks/use-remote-environment-bootstrap";
import { useSessionAgents } from "@/hooks/use-session-agents";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
@@ -147,10 +150,7 @@ import {
type WorkIn,
writeWorkInToWindow,
} from "@/lib/work-in-selection";
import {
type RemoteWorkspaceEnvironment,
remoteWorkspaceEnvironmentFromContext,
} from "@/lib/workspace-environment";
import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
import {
filterWorkspacePaths,
isTaskWorktreePath,
@@ -305,6 +305,32 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
const startup = useHubStartup();
const hubReady =
startup.transport === "connected" && startup.hub.state === "ready";
// Mount the app once the Hub is first ready, then hide it with Activity
// whenever the Hub is not ready. Hidden Activity content keeps its state
// (open conversations) but unmounts effects, so its dialogs, including
// portaled ones, release focus traps and body pointer-events.
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (hubReady) setLoaded(true);
}, [hubReady]);
return (
<>
{loaded ? (
<Activity mode={hubReady ? "visible" : "hidden"}>
<App />
</Activity>
) : null}
{hubReady ? null : <HubStartupScreen {...startup} />}
{/* Outside the gate: the startup mismatch check can fire before App mounts. */}
<HubUpdateRequiredDialog />
</>
);
}
function App() {
const [initialThreadId] = useState(makeThreadId);
// Outlive keyed chat panes without re-rendering the app on every keystroke.
const { current: promptDrafts } = useRef(new Map<string, string>());
@@ -411,32 +437,27 @@ export default function Home() {
void syncDesktopWindowTitle();
}, []);
useEffect(() => {
let cancelled = false;
const revision = environmentSelectionRevision.current;
desktopClient
.invoke<ProcessContext>("get_process_context")
.then((context) => {
if (!cancelled && revision === environmentSelectionRevision.current) {
const remoteEnvironment =
remoteWorkspaceEnvironmentFromContext(context);
setActiveRemoteEnvironment(remoteEnvironment);
if (remoteEnvironment) {
dispatchApp({
type: "select-environment-draft",
threadId: makeThreadId(),
environmentId: remoteEnvironment.id,
});
}
useRemoteEnvironmentBootstrap(
environmentSelectionRevision,
useCallback(
(
remoteEnvironment: RemoteWorkspaceEnvironment | null,
isFirstLoad: boolean,
) => {
setActiveRemoteEnvironment(remoteEnvironment);
// Later loads follow a reconnect; a new draft would replace the
// conversation that is still open.
if (isFirstLoad && remoteEnvironment) {
dispatchApp({
type: "select-environment-draft",
threadId: makeThreadId(),
environmentId: remoteEnvironment.id,
});
}
})
.catch(() => {
// The chat bootstrap reports backend availability separately.
});
return () => {
cancelled = true;
};
}, []);
},
[],
),
);
useEffect(() => {
if (view !== "chat") return;
@@ -1071,7 +1092,6 @@ export default function Home() {
onOpenChange={setExportDiagnosticsOpen}
open={exportDiagnosticsOpen}
/>
<HubUpdateRequiredDialog />
<QuitHoldHandler bindings={keybindings} />
{whatsNew ? (
<WhatsNewDialog
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { Activity, act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SpeechInput } from "./speech-input";
@@ -487,6 +487,44 @@ describe("SpeechInput", () => {
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("returns to inactive after an Activity hides it mid-recording", async () => {
const cancel = vi.fn();
const onActiveChange = vi.fn();
const render = (mode: "visible" | "hidden") =>
act(async () => {
root.render(
<Activity mode={mode}>
<SpeechInput
onActiveChange={onActiveChange}
onStartStreaming={async () => ({
done: new Promise<void>(() => {}),
stop: vi.fn(),
cancel,
})}
recordingMode="streaming"
/>
</Activity>,
);
});
await render("visible");
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
await render("hidden");
await render("visible");
expect(cancel).toHaveBeenCalledOnce();
expect(onActiveChange).toHaveBeenLastCalledWith(false);
expect(button?.disabled).toBe(false);
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("returns to inactive when MediaRecorder rejects stop", async () => {
const stopError = new DOMException("Already stopped", "InvalidStateError");
const onActiveChange = vi.fn();
@@ -297,6 +297,10 @@ export function SpeechInput({
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop();
}
// A hidden Activity keeps this state; the cancelled recording can no
// longer reset it, which would leave the composer read-only.
setIsListening(false);
setIsProcessing(false);
};
}, []);
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { HubStartupScreen } from "./hub-startup-screen";
const invoke = vi.hoisted(() => vi.fn());
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
isTauriAvailable: () => false,
}));
const container = document.createElement("div");
document.body.append(container);
let root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
act(() => root.unmount());
root = createRoot(container);
vi.useRealTimers();
vi.clearAllMocks();
});
function buttons(): string[] {
return [...container.querySelectorAll("button")].map(
(button) => button.textContent ?? "",
);
}
it("shows only a spinner while the Hub is starting", () => {
act(() =>
root.render(
<HubStartupScreen transport="connected" hub={{ state: "starting" }} />,
),
);
expect(container.textContent).toContain("Starting Cline…");
expect(buttons()).toEqual([]);
// The window stays draggable (and closable on frameless Windows) without the app.
expect(
container.querySelector('[data-slot="window-title-bar"]'),
).not.toBeNull();
});
it("offers Retry and diagnostics export when the Hub fails", async () => {
invoke.mockResolvedValue({ path: "/tmp/cline-diagnostics.txt" });
act(() =>
root.render(
<HubStartupScreen
transport="connected"
hub={{ state: "failed", error: "Cline couldn't start its Hub." }}
/>,
),
);
expect(container.textContent).toContain("Cline couldn't connect to its Hub");
expect(buttons()).toEqual(["Retry", "Export diagnostics"]);
const [retry, exportButton] = container.querySelectorAll("button");
act(() => retry.click());
expect(invoke).toHaveBeenCalledWith("retry_hub");
await act(async () => exportButton.click());
expect(invoke).toHaveBeenCalledWith("export_diagnostics");
expect(container.textContent).toContain(
"Diagnostics saved to /tmp/cline-diagnostics.txt",
);
});
it("calls out an unreachable desktop backend after 30 seconds", () => {
vi.useFakeTimers();
act(() =>
root.render(
<HubStartupScreen transport="reconnecting" hub={{ state: "starting" }} />,
),
);
expect(container.textContent).toContain("Starting Cline…");
act(() => vi.advanceTimersByTime(30_000));
expect(container.textContent).toContain(
"Cline's desktop backend isn't responding",
);
expect(buttons()).toEqual(["Reload"]);
});
@@ -0,0 +1,117 @@
"use client";
import { Loader2 } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
WindowTitleBar,
WindowTitleBarProvider,
} from "@/components/window-title-bar";
import type { useHubStartup } from "@/hooks/use-hub-startup";
import { desktopClient } from "@/lib/desktop-client";
const BACKEND_UNRESPONSIVE_MS = 30_000;
/**
* Shown instead of the app while local Hub startup is in progress or has failed, so the
* user gets Retry and a diagnostics export instead of Hub errors from
* whichever feature they tried first.
*/
export function HubStartupScreen({
transport,
hub,
}: ReturnType<typeof useHubStartup>) {
const connected = transport === "connected";
const [backendUnresponsive, setBackendUnresponsive] = useState(false);
const [exportStatus, setExportStatus] = useState<string | null>(null);
// The client keeps reconnecting on its own; only call it out once it has
// been unreachable for a while.
useEffect(() => {
setBackendUnresponsive(false);
if (connected) return;
const timer = setTimeout(
() => setBackendUnresponsive(true),
BACKEND_UNRESPONSIVE_MS,
);
return () => clearTimeout(timer);
}, [connected]);
const exportDiagnostics = async () => {
setExportStatus("Exporting diagnostics…");
try {
const result = await desktopClient.invoke<{ path: string }>(
"export_diagnostics",
);
setExportStatus(`Diagnostics saved to ${result.path}`);
} catch (error) {
setExportStatus(error instanceof Error ? error.message : String(error));
}
};
let content: ReactNode;
if (!connected && backendUnresponsive) {
content = (
<>
<h1 className="text-base font-medium">
Cline's desktop backend isn't responding
</h1>
<p className="text-sm text-muted-foreground">
Cline keeps trying in the background. Reload to try again now.
</p>
<Button onClick={() => window.location.reload()}>Reload</Button>
</>
);
} else if (connected && hub.state === "failed") {
content = (
<>
<h1 className="text-base font-medium">
Cline couldn't connect to its Hub
</h1>
<p className="text-sm text-muted-foreground">
{hub.error} Retry, or export diagnostics and share the file with the
Cline team if this keeps happening.
</p>
<div className="flex gap-2">
<Button onClick={() => void desktopClient.invoke("retry_hub")}>
Retry
</Button>
<Button variant="outline" onClick={() => void exportDiagnostics()}>
Export diagnostics
</Button>
</div>
{exportStatus ? (
<p className="break-all text-xs text-muted-foreground">
{exportStatus}
</p>
) : null}
</>
);
} else {
content = (
<>
<Loader2
aria-hidden="true"
className="size-6 animate-spin text-muted-foreground"
/>
<output className="text-sm text-muted-foreground">
Starting Cline…
</output>
</>
);
}
// Below dialogs (z-50) so the Hub update prompt stays on top. The title bar
// keeps the drag region and, on frameless Windows, the window controls.
return (
<main className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<WindowTitleBarProvider contentEnabled={false}>
<WindowTitleBar
className="absolute inset-x-0 top-0"
hostContent={false}
/>
</WindowTitleBarProvider>
<div className="flex max-w-md flex-col items-center gap-4">{content}</div>
</main>
);
}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, StrictMode } from "react";
import { Activity, act, StrictMode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -433,6 +433,84 @@ afterEach(async () => {
});
describe("useChatSession", () => {
it("keeps flushing streamed text after the app is hidden and restored", async () => {
vi.useFakeTimers();
try {
const sendResponse = deferred<unknown>();
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (command === "get_process_context") {
return {
environmentId: "local",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
taskWorktreeRoot: "/home/host/cline-dir/worktrees",
};
}
if (command === "chat_session_command") {
if (request?.action === "start")
return { sessionId: request.config?.sessionId };
if (request?.action === "send") return await sendResponse.promise;
return { ok: true, prompts: [] };
}
return [];
},
);
const renderIn = (mode: "visible" | "hidden") =>
act(async () =>
root.render(
<Activity mode={mode}>
<HookHarness />
</Activity>,
),
);
const latestChatHandler = () =>
subscribeMock.mock.calls
.filter(([eventName]) => eventName === "chat_event")
.at(-1)?.[1] as (payload: unknown) => void;
await renderIn("visible");
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
void current.sendPrompt("Say hello");
for (let i = 0; i < 5; i += 1) await Promise.resolve();
});
const sessionId = current.sessionId;
await act(async () => {
latestChatHandler()({
sessionId,
stream: "chat_text",
chunk: "Hel",
ts: 1,
index: 1,
});
});
// Hiding runs effect cleanups while the pending flush timer is armed.
await renderIn("hidden");
await renderIn("visible");
await act(async () => {
latestChatHandler()({
sessionId,
stream: "chat_text",
chunk: "lo",
ts: 2,
index: 2,
});
await vi.advanceTimersByTimeAsync(200);
});
expect(
current.messages.filter((message) => message.role === "assistant"),
).toEqual([expect.objectContaining({ content: "Hello" })]);
} finally {
vi.useRealTimers();
}
});
it("keeps a remounted pane's newer draft when the original session start rejects", async () => {
const drafts = new Map<string, string>();
let draft!: ReturnType<typeof usePromptDraft>;
@@ -1074,6 +1074,7 @@ export function useChatSession(environmentId: string) {
return () => {
if (turnEndReconcileTimerRef.current !== null) {
clearTimeout(turnEndReconcileTimerRef.current);
turnEndReconcileTimerRef.current = null;
}
};
}, []);
@@ -1419,6 +1420,9 @@ export function useChatSession(environmentId: string) {
return () => {
if (streamFlushTimerRef.current !== null) {
clearTimeout(streamFlushTimerRef.current);
// The app can be hidden and restored (Activity) with refs intact; a
// stale id would make schedulePendingStreamFlush skip every flush.
streamFlushTimerRef.current = null;
}
};
}, []);
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { useHubStartup } from "./use-hub-startup";
const mocks = vi.hoisted(() => ({
transportHandler: undefined as ((state: string) => void) | undefined,
hubHandler: undefined as ((payload: unknown) => void) | undefined,
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: {
getTransportState: () => "connected",
subscribeTransportState: (handler: (state: string) => void) => {
mocks.transportHandler = handler;
return () => {};
},
subscribe: (_name: string, handler: (payload: unknown) => void) => {
mocks.hubHandler = handler;
return () => {};
},
},
}));
const container = document.createElement("div");
let root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
act(() => root.unmount());
root = createRoot(container);
});
function Probe() {
const { transport, hub } = useHubStartup();
return <span>{`${transport}:${hub.state}`}</span>;
}
it("forgets the previous sidecar's ready status when the transport drops", () => {
act(() => root.render(<Probe />));
act(() => mocks.hubHandler?.({ state: "ready" }));
expect(container.textContent).toBe("connected:ready");
act(() => mocks.transportHandler?.("reconnecting"));
expect(container.textContent).toBe("reconnecting:starting");
// The new sidecar has not replayed its status yet.
act(() => mocks.transportHandler?.("connected"));
expect(container.textContent).toBe("connected:starting");
act(() => mocks.hubHandler?.({ state: "ready" }));
expect(container.textContent).toBe("connected:ready");
});
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { desktopClient } from "@/lib/desktop-client";
import type {
DesktopHubStatus,
DesktopTransportState,
} from "@/lib/desktop-transport";
/**
* The sidecar sends `hub_status` whenever local Hub startup changes and
* replays it to every new connection, so subscribing is enough.
*/
export function useHubStartup() {
const [transport, setTransport] = useState<DesktopTransportState>(
desktopClient.getTransportState(),
);
const [hub, setHub] = useState<DesktopHubStatus>({ state: "starting" });
useEffect(
() =>
desktopClient.subscribeTransportState((next) => {
setTransport(next);
// A restarted sidecar replays its own status on connect; never reuse
// the previous sidecar's "ready".
if (next !== "connected") setHub({ state: "starting" });
}),
[],
);
useEffect(
() =>
desktopClient.subscribe("hub_status", (payload) => {
if (payload && typeof payload === "object" && "state" in payload) {
setHub(payload as DesktopHubStatus);
}
}),
[],
);
return { transport, hub };
}
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import {
Activity,
act,
useCallback,
useReducer,
useRef,
useState,
} from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import {
createDesktopAppState,
type DesktopAppAction,
desktopAppReducer,
} from "@/lib/desktop-app-state";
import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
import { useRemoteEnvironmentBootstrap } from "./use-remote-environment-bootstrap";
const invoke = vi.hoisted(() => vi.fn());
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
const container = document.createElement("div");
let root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
act(() => root.unmount());
root = createRoot(container);
vi.clearAllMocks();
nextDraft = 0;
});
let nextDraft = 0;
let dispatchApp: (action: DesktopAppAction<string>) => void;
function Probe() {
const revision = useRef(0);
const [state, dispatch] = useReducer(
desktopAppReducer<string>,
createDesktopAppState("draft-0", "General", "local"),
);
dispatchApp = dispatch;
const [environment, setEnvironment] =
useState<RemoteWorkspaceEnvironment | null>(null);
useRemoteEnvironmentBootstrap(
revision,
useCallback(
(remote: RemoteWorkspaceEnvironment | null, isFirstLoad: boolean) => {
setEnvironment(remote);
if (isFirstLoad && remote) {
dispatch({
type: "select-environment-draft",
threadId: `draft-${++nextDraft}`,
environmentId: remote.id,
});
}
},
[],
),
);
return (
<span>{`${environment?.id ?? "local"}:${state.navigation.current.activeThreadId}`}</span>
);
}
function render(mode: "visible" | "hidden") {
return act(async () =>
root.render(
<Activity mode={mode}>
<Probe />
</Activity>,
),
);
}
it("keeps an open remote conversation when the app is shown again", async () => {
invoke.mockResolvedValue({
remoteEnvironment: { id: "ssh-1" },
homeDir: "/home/me",
});
await render("visible");
expect(container.textContent).toBe("ssh-1:draft-1");
act(() =>
dispatchApp({
type: "thread-started",
threadId: "draft-1",
sessionId: "session-1",
}),
);
await render("hidden");
await render("visible");
expect(invoke).toHaveBeenCalledTimes(2);
expect(container.textContent).toBe("ssh-1:draft-1");
});
it("bootstraps navigation on the first load that succeeds", async () => {
invoke.mockRejectedValueOnce(new Error("Hub starting")).mockResolvedValue({
remoteEnvironment: { id: "ssh-1" },
homeDir: "/home/me",
});
await render("visible");
expect(container.textContent).toBe("local:draft-0");
await render("hidden");
await render("visible");
expect(container.textContent).toBe("ssh-1:draft-1");
});
@@ -0,0 +1,43 @@
"use client";
import { type RefObject, useEffect, useRef } from "react";
import type { ProcessContext } from "@/hooks/chat-session/types";
import { desktopClient } from "@/lib/desktop-client";
import {
type RemoteWorkspaceEnvironment,
remoteWorkspaceEnvironmentFromContext,
} from "@/lib/workspace-environment";
/**
* Reads the remote environment the sidecar is connected to. The app is hidden
* with Activity while the Hub restarts, and showing it again re-runs this
* effect, so `isFirstLoad` lets the caller refresh the environment on every
* load but only bootstrap navigation once.
*/
export function useRemoteEnvironmentBootstrap(
selectionRevision: RefObject<number>,
onLoad: (
environment: RemoteWorkspaceEnvironment | null,
isFirstLoad: boolean,
) => void,
): void {
const loadedRef = useRef(false);
useEffect(() => {
let cancelled = false;
const revision = selectionRevision.current;
desktopClient
.invoke<ProcessContext>("get_process_context")
.then((context) => {
if (cancelled || revision !== selectionRevision.current) return;
const isFirstLoad = !loadedRef.current;
loadedRef.current = true;
onLoad(remoteWorkspaceEnvironmentFromContext(context), isFirstLoad);
})
.catch(() => {
// The chat bootstrap reports backend availability separately.
});
return () => {
cancelled = true;
};
}, [selectionRevision, onLoad]);
}
@@ -33,6 +33,13 @@ export type DesktopTransportMessage =
| DesktopTransportResponse
| DesktopTransportEvent;
/** Local Hub startup, sent as the `hub_status` event. */
export type DesktopHubStatus = {
state: "starting" | "ready" | "failed";
/** User-facing summary. Raw errors go to the log and diagnostics report. */
error?: string;
};
export type DesktopTransportState =
| "connecting"
| "reconnecting"
+13 -13
View File
@@ -239,7 +239,7 @@
"devDependencies": {
"@cline/sdk": "workspace:*",
"@tailwindcss/postcss": "^4.2.0",
"@tauri-apps/cli": "^2.0.0",
"@tauri-apps/cli": "^2.12.3",
"@types/bun": "^1.4.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
@@ -2453,29 +2453,29 @@
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.3", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.3", "@tauri-apps/cli-darwin-x64": "2.12.3", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", "@tauri-apps/cli-linux-arm64-musl": "2.12.3", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-musl": "2.12.3", "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", "@tauri-apps/cli-win32-x64-msvc": "2.12.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.3", "", { "os": "linux", "cpu": "arm" }, "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.3", "", { "os": "linux", "cpu": "none" }, "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.3", "", { "os": "win32", "cpu": "x64" }, "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ=="],
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
+4
View File
@@ -861,6 +861,10 @@ Do not move these concerns into `@cline/agents`:
- host-specific approvals
- remote-config policy caching
Diagnostics derived from a run (for example run stall telemetry, see `DOC.md`)
belong in a `core` observer of runtime events. `agents` only emits the events and
merges host-supplied properties through `runEndTelemetryProperties`.
### Keep `core` Generic
Do not make `@cline/core` organization- or provider-specific.
+2 -2
View File
@@ -143,7 +143,7 @@ Nightly release:
gh workflow run cli-publish.yml -f publish_target=nightly
```
Nightly also runs on a schedule. It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
Nightly is manual only (no schedule). It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
### Manual SDK Publish
@@ -185,7 +185,7 @@ bun pm ls @cline/core @cline/agents @cline/llms
### CI
The CI publish workflow (`.github/workflows/sdk-publish.yml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
The CI publish workflow (`.github/workflows/sdk-publish.yml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch only. Every CLI and SDK publish job waits for a reviewer to approve the run in the `PublishNPM` GitHub environment.
### Root Automation Scope
+25
View File
@@ -471,3 +471,28 @@ malformed replies throw. It is optional on `McpManager` and
tool calls on an already connected server. A read that starts while the server
is being disconnected or re-registered can fail with a disconnect error; retry
it.
## Run stall telemetry
`@cline/core` measures stalls with a `RunHealthObserver`
(`packages/core/src/services/telemetry/run-health.ts`). `SessionRuntime`
creates one per run, for root agents and subagents, when telemetry is
configured. It watches only the run's runtime events and is disposed when the
run returns.
- `task.run_idle` fires at most once per run, while it is still running and has
emitted no runtime event for 60s.
- When the run ends, `AgentRuntime` calls the optional
`AgentRuntimeConfig.runEndTelemetryProperties()` once, while capturing
`agent.run-finished` or `agent.run-failed`, and merges the result into that
event. Core uses it to add `maxIdleMs`, `maxIdleAfter`, `msSinceLastEvent`,
`lastEventType`, `finishReason`, `idleReported`, `endedOn` and
`textSignature`. The observer stops its idle check at that point.
- `tool-call-delta` is a runtime event emitted for each streamed tool-call
argument chunk. It carries no arguments and exists so that a slow tool-call
stream is not mistaken for a silent one. It is not mirrored to telemetry and
is not forwarded as hub hook traffic.
No message text leaves the client: `textSignature` and `session.input_sent`'s
`bumpKind` are enums computed locally
(`packages/core/src/services/telemetry/stall-signals.ts`).
@@ -3321,6 +3321,49 @@ describe("AgentRuntime", () => {
}
});
it("reports tool-argument streaming as progress and merges host run-end properties", async () => {
const { capture, telemetry } = createTelemetryMock();
const tool = createEchoTool();
const model = new ScriptedModel([
() => [
{
type: "tool-call-delta",
toolCallId: "call-1",
toolName: "echo",
inputText: '{"text":',
},
{ type: "tool-call-delta", toolCallId: "call-1", inputText: '"hi"}' },
{ type: "finish", reason: "tool-calls" },
],
() => [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
],
]);
const runtime = new AgentRuntime({
model,
tools: [tool],
telemetry,
runEndTelemetryProperties: () => ({ maxIdleMs: 42 }),
});
const eventTypes: string[] = [];
runtime.subscribe((event) => eventTypes.push(event.type));
await runtime.run("Hi");
// Two chunks within a second report progress once.
expect(eventTypes.filter((type) => type === "tool-call-delta")).toHaveLength(
1,
);
const events = capture.mock.calls.map(([input]) => input);
expect(events.some((input) => input.event === "agent.tool-call-delta")).toBe(
false,
);
expect(
events.find((input) => input.event === "agent.run-finished")?.properties,
).toEqual(expect.objectContaining({ maxIdleMs: 42, status: "completed" }));
});
it("captures task lifecycle telemetry around a successful model stream", async () => {
const { capture, telemetry } = createTelemetryMock();
const model = new ScriptedModel([
+23
View File
@@ -1795,6 +1795,7 @@ export class AgentRuntime {
const content: AgentMessagePart[] = [];
const toolAssemblies = new Map<string, PendingToolAssembly>();
const modelToolActivities = new Map<string, AgentModelToolActivity>();
let lastToolCallDeltaAt = 0;
const invalidToolCalls: InvalidToolCall[] = [];
const sequence: Array<
{ type: "tool"; key: string } | { type: "part"; part: AgentMessagePart }
@@ -1933,6 +1934,17 @@ export class AgentRuntime {
event.inputText,
);
}
// Only a progress signal: at most once a second, since each event
// copies the whole conversation and a large file write streams
// thousands of argument chunks.
if (Date.now() - lastToolCallDeltaAt >= 1_000) {
lastToolCallDeltaAt = Date.now();
await this.emit({
type: "tool-call-delta",
snapshot: this.snapshot(),
iteration: this.state.iteration,
});
}
break;
}
case "tool-result": {
@@ -2757,8 +2769,19 @@ export class AgentRuntime {
case "assistant-text-delta":
case "assistant-reasoning-delta":
case "assistant-media":
case "tool-call-delta":
case "tool-updated":
break;
case "run-finished":
case "run-failed":
this.config.telemetry?.capture({
event: `agent.${event.type}`,
properties: {
...(metadata as TelemetryProperties),
...this.config.runEndTelemetryProperties?.(),
},
});
break;
default:
this.config.telemetry?.capture({
event: `agent.${event.type}`,
@@ -0,0 +1,38 @@
import { zodToJsonSchema } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { normalizeRunCommandsInput } from "./helpers";
import { RunCommandsInputSchema } from "./schemas";
describe("normalizeRunCommandsInput", () => {
it("accepts the documented shapes", () => {
expect(normalizeRunCommandsInput({ commands: ["ls", "pwd"] })).toEqual([
"ls",
"pwd",
]);
expect(normalizeRunCommandsInput({ commands: "ls" })).toEqual(["ls"]);
expect(normalizeRunCommandsInput(["ls"])).toEqual(["ls"]);
expect(normalizeRunCommandsInput("ls")).toEqual(["ls"]);
expect(normalizeRunCommandsInput({ command: "ls" })).toEqual(["ls"]);
expect(
normalizeRunCommandsInput({ command: "git", args: ["status"] }),
).toEqual([{ command: "git", args: ["status"] }]);
});
it.each([
{ label: "an object with an empty commands list", input: { commands: [] } },
{ label: "a bare empty list", input: [] },
])("rejects $label instead of reporting an empty success", ({ input }) => {
expect(() => normalizeRunCommandsInput(input)).toThrow(
"run_commands requires at least one command",
);
});
});
describe("RunCommandsInputSchema", () => {
it("advertises the command list as non-empty", () => {
const schema = zodToJsonSchema(RunCommandsInputSchema) as {
properties: { commands: { minItems?: number } };
};
expect(schema.properties.commands.minItems).toBe(1);
});
});
@@ -1,4 +1,5 @@
import { validateWithZod } from "@cline/shared";
import type { z } from "zod";
import {
type EditFileInput,
INPUT_ARG_CHAR_LIMIT,
@@ -137,8 +138,24 @@ export function coalesceOrphanReadRanges(input: unknown): unknown {
export function normalizeRunCommandsInput(
input: unknown,
): Array<string | StructuredCommandInput> {
const validate = validateWithZod(RunCommandsInputUnionSchema, input);
const commands = collectRunCommands(
validateWithZod(RunCommandsInputUnionSchema, input),
);
// An empty list is well-formed but never a real request: nothing would
// run and the call would still report success. Fail it so the model sees
// that no command was given instead of an empty result it has to read
// into.
if (commands.length === 0) {
throw new Error(
"run_commands requires at least one command; the commands list was empty.",
);
}
return commands;
}
function collectRunCommands(
validate: z.infer<typeof RunCommandsInputUnionSchema>,
): Array<string | StructuredCommandInput> {
if (typeof validate === "string") {
return [validate];
}
@@ -149,6 +149,10 @@ export const StructuredCommandEntrySchema = z.union([
export const RunCommandsInputSchema = z.object({
commands: z
.array(CommandInputSchema)
// Advertised to the model as minItems: 1. The lenient union below still
// accepts an empty list so the normalizer can reject it with one clear
// message instead of a union error.
.min(1)
.describe("Array of complete shell command strings to execute."),
});
@@ -508,6 +508,7 @@ function createToolProxies(
const STREAMING_EVENT_TYPES = new Set<AgentRuntimeEvent["type"]>([
"assistant-text-delta",
"assistant-reasoning-delta",
"tool-call-delta",
"tool-updated",
]);
@@ -67,6 +67,8 @@ export interface CreateAgentRuntimeConfigInput {
| Promise<AgentRuntimePrepareTurnResult | undefined>
| AgentRuntimePrepareTurnResult
| undefined;
/** Host-derived diagnostics merged into the run-end telemetry. */
readonly runEndTelemetryProperties?: AgentRuntimeConfig["runEndTelemetryProperties"];
/** Seed messages (usually `session.conversation.getMessages()`). */
readonly initialMessages?: readonly AgentMessage[];
/**
@@ -118,6 +120,7 @@ export function createAgentRuntimeConfig(
plugins: input.plugins,
logger: input.logger ?? agentConfig.logger,
telemetry: input.telemetry ?? agentConfig.telemetry,
runEndTelemetryProperties: input.runEndTelemetryProperties,
initialMessages: input.initialMessages,
completionPolicy:
input.completionPolicy === null
@@ -566,6 +566,80 @@ describe("LocalRuntimeHost", () => {
);
});
it("labels prompts sent during a queued run but not the queued replay", async () => {
const sessionId = "sess-queued-input";
const capture = vi.fn();
const telemetry = { capture, captureRequired: vi.fn() } as never;
let busy = false;
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(() => {}),
canStartRun: vi.fn(() => !busy),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: createManifest(sessionId),
}),
persistSessionMessages: vi.fn(),
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
} as never,
runtimeBuilder: {
build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }),
} as never,
createAgent: () => agent as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({ telemetry, sessionId }),
interactive: true,
}),
);
const session = (
manager as unknown as {
sessions: Map<
string,
{ drainingPendingPrompts: boolean; replayingQueuedPrompt?: boolean }
>;
}
).sessions.get(sessionId);
if (!session) throw new Error("session not started");
const inputSent = () =>
capture.mock.calls
.map(([e]) => e)
.filter((e) => e.event === "session.input_sent")
.map((e) => e.properties);
// The drain replays a queued prompt: only that input is unlabelled.
session.drainingPendingPrompts = true;
session.replayingQueuedPrompt = true;
await manager.runTurn({ sessionId, prompt: "continue" });
// The user reacts while that queued run is busy, and again just as
// it goes idle: both are fresh input and stay labelled.
busy = true;
await manager.runTurn({ sessionId, prompt: "are you stuck?" });
busy = false;
await manager.runTurn({ sessionId, prompt: "hello?" });
const [replay, whileBusy, whileIdle] = inputSent();
expect(replay).not.toHaveProperty("bumpKind");
expect(whileBusy).toMatchObject({ agentBusy: true, bumpKind: "probe" });
expect(whileIdle).toHaveProperty("bumpKind");
});
it("resolves OAuth credentials before creating the agent", async () => {
const sessionId = "sess-oauth-bootstrap";
const manifest = createManifest(sessionId);
@@ -61,6 +61,7 @@ import {
captureTaskCompleted,
} from "../../services/telemetry/core-events";
import { resolveCoreDistinctId } from "../../services/telemetry/distinct-id";
import { classifyBumpText } from "../../services/telemetry/stall-signals";
import {
accumulateUsageTotals,
createInitialAccumulatedUsage,
@@ -1220,6 +1221,10 @@ export class LocalRuntimeHost implements RuntimeHost {
async runTurn(input: SendSessionInput): Promise<AgentResult | undefined> {
const session = this.getSessionOrThrow(input.sessionId);
// Only the input the queue drain replays carries this marker; any
// other input during a queued run is fresh user input.
const replayingQueuedPrompt = session.replayingQueuedPrompt === true;
session.replayingQueuedPrompt = false;
const canStartRun = session.agent.canStartRun();
const delivery =
input.delivery ??
@@ -1232,6 +1237,19 @@ export class LocalRuntimeHost implements RuntimeHost {
userImageCount: input.userImages?.length ?? 0,
userFileCount: input.userFiles?.length ?? 0,
delivery: delivery ?? "immediate",
// A prompt sent into a busy run, or right after one ended, is how
// users react to a stall; agent.run-finished carries the run's side.
// A queued prompt replayed by the drain was labelled when the user
// sent it, so its replay carries no stall fields.
...(replayingQueuedPrompt
? {}
: {
agentBusy: !canStartRun,
bumpKind: classifyBumpText(input.prompt),
...(session.lastTurnEndedAt === undefined
? {}
: { msSinceTurnEnd: Date.now() - session.lastTurnEndedAt }),
}),
},
});
if (delivery === "queue" || delivery === "steer") {
@@ -1935,6 +1953,7 @@ export class LocalRuntimeHost implements RuntimeHost {
return result;
} finally {
session.lastTurnEndedAt = Date.now();
await this.refreshActiveSessionGitMetadata(session);
}
}
@@ -230,6 +230,8 @@ export class RuntimeEventAdapter {
return this.translateAssistantMessage(event.message);
case "tool-started":
return this.translateToolStarted(event);
case "tool-call-delta":
return [];
case "tool-updated":
return [
{
@@ -62,6 +62,7 @@ import {
captureMistakeLimitReached,
captureSessionErrorRecorded,
} from "../../services/telemetry/core-events";
import { RunHealthObserver } from "../../services/telemetry/run-health";
import { toPersistedToolResultContent } from "../../session/persisted-tool-result-content";
import {
DEFAULT_MAX_TOOL_RESULT_CHARS,
@@ -1004,6 +1005,14 @@ export class SessionRuntime {
.filter((tool) => tool.resultPolicy === "cache-oversized")
.map((tool) => tool.name.toLowerCase()),
);
const runHealth =
this.telemetry &&
new RunHealthObserver(this.telemetry, {
sessionId: this.config.sessionId,
isSubagent: Boolean(this.parentAgentId),
providerId: this.config.providerId,
modelId: this.config.modelId,
});
const runtimeConfig = createAgentRuntimeConfig({
agentConfig: this.config,
sessionId: this.config.sessionId,
@@ -1013,6 +1022,9 @@ export class SessionRuntime {
model: agentModel,
logger: this.logger,
telemetry: this.telemetry,
runEndTelemetryProperties: runHealth
? () => runHealth.endProperties()
: undefined,
tools,
toolContextMetadata: {
modelSupportsImages: modelSupportsImageInput(modelInfo ?? {}),
@@ -1037,6 +1049,7 @@ export class SessionRuntime {
if (event.type === "run-started" && this.abortRequested) {
runtime.abort(this.abortReason);
}
runHealth?.observe(event);
this.handleRuntimeEvent(event);
});
if (this.config.abortSignal) {
@@ -1067,6 +1080,7 @@ export class SessionRuntime {
thrownError = error instanceof Error ? error : new Error(String(error));
} finally {
unsubscribe();
runHealth?.dispose();
this.config.abortSignal?.removeEventListener(
"abort",
this.handleExternalAbort,
@@ -340,6 +340,7 @@ export class PendingPromptsController {
session.drainingPendingPrompts = true;
let continueDrain = true;
try {
session.replayingQueuedPrompt = true;
const result = await this.deps.send({
sessionId,
prompt: next.prompt,
@@ -20,6 +20,7 @@ import {
captureMistakeLimitReached,
captureProviderConfigured,
captureRunCommandsTimeout,
captureRunIdle,
captureScheduleRun,
captureTaskCompleted,
captureTaskCreated,
@@ -393,6 +394,31 @@ describe("captureWorkspacePathResolved", () => {
});
});
describe("captureRunIdle", () => {
test("emits task.run_idle and drops a missing session id", () => {
const stub = createTelemetryStub();
captureRunIdle(stub.telemetry, {
ulid: undefined,
agentId: "agent-1",
runId: "run-1",
idleMs: 60_000,
lastEventType: "tool-started",
isSubagent: true,
});
expect(stub.captureRequired).not.toHaveBeenCalled();
expect(captureCallAt(stub, 0)).toEqual({
event: CORE_TELEMETRY_EVENTS.TASK.RUN_IDLE,
properties: {
agentId: "agent-1",
runId: "run-1",
idleMs: 60_000,
lastEventType: "tool-started",
isSubagent: true,
},
});
});
});
describe("captureMistakeLimitReached", () => {
const baseProps = {
ulid: "sess-1",
@@ -85,6 +85,7 @@ export const CORE_TELEMETRY_EVENTS = {
PROVIDER_STREAM_FAILED: TASK_PROVIDER_STREAM_FAILED_EVENT,
MAX_TOKENS_RECOVERY: TASK_MAX_TOKENS_RECOVERY_EVENT,
CANCELLED: TASK_CANCELLED_EVENT,
RUN_IDLE: "task.run_idle",
MENTION_USED: "task.mention_used",
MENTION_FAILED: "task.mention_failed",
MENTION_SEARCH_RESULTS: "task.mention_search_results",
@@ -645,6 +646,30 @@ export function captureMistakeLimitReached(
});
}
/**
* At most once per agent run, while it is still running and has emitted no
* runtime event (stream deltas included) for `idleMs`. Sent while stuck so
* hangs the user escapes by quitting the app are still counted.
*/
export function captureRunIdle(
telemetry: ITelemetryService | undefined,
properties: {
ulid?: string;
agentId: string;
runId: string;
idleMs: number;
/** Runtime event type that preceded the silence. */
lastEventType: string;
isSubagent: boolean;
},
): void {
emit(
telemetry,
CORE_TELEMETRY_EVENTS.TASK.RUN_IDLE,
stripUndefinedProperties(properties),
);
}
export function captureRunCommandsTimeout(
telemetry: ITelemetryService | undefined,
properties: RunCommandsTimeoutTelemetryProperties,
@@ -0,0 +1,144 @@
import type { AgentMessage, AgentRuntimeEvent } from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RUN_IDLE_REPORT_MS, RunHealthObserver } from "./run-health";
function event(type: string, extra: object = {}): AgentRuntimeEvent {
return {
type,
snapshot: { agentId: "agent-1", runId: "run-1" },
...extra,
} as unknown as AgentRuntimeEvent;
}
function assistantText(text: string): AgentRuntimeEvent {
const message = {
role: "assistant",
content: [{ type: "text", text }],
} as AgentMessage;
return event("assistant-message", { message, finishReason: "stop" });
}
describe("RunHealthObserver", () => {
let capture: ReturnType<typeof vi.fn>;
let observer: RunHealthObserver;
beforeEach(() => {
vi.useFakeTimers();
capture = vi.fn();
observer = new RunHealthObserver({ capture } as never, {
sessionId: "sess-1",
isSubagent: false,
providerId: "cline",
modelId: "deepseek-v4-flash",
});
observer.observe(event("run-started"));
observer.observe(event("turn-started"));
});
afterEach(() => {
observer.dispose();
vi.useRealTimers();
});
const idleEvents = () =>
capture.mock.calls.filter(([call]) => call.event === "task.run_idle");
it("reports a silent stream once while stuck and summarizes the run", () => {
observer.observe(
event("assistant-text-delta", { accumulatedText: "Let me check:" }),
);
vi.advanceTimersByTime(150_000);
observer.observe(assistantText("Let me check the file:"));
expect(idleEvents()).toEqual([
[
{
event: "task.run_idle",
properties: {
ulid: "sess-1",
agentId: "agent-1",
runId: "run-1",
idleMs: RUN_IDLE_REPORT_MS,
lastEventType: "assistant-text-delta",
isSubagent: false,
},
},
],
]);
expect(observer.endProperties()).toMatchObject({
sessionId: "sess-1",
isSubagent: false,
providerId: "cline",
modelId: "deepseek-v4-flash",
maxIdleMs: 150_000,
maxIdleAfter: "assistant-text-delta",
idleReported: true,
finishReason: "stop",
endedOn: "assistant_text",
textSignature: "intent_without_action",
});
});
it("treats streamed tool-call arguments as progress", () => {
for (let elapsed = 0; elapsed < 3 * RUN_IDLE_REPORT_MS; elapsed += 10_000) {
vi.advanceTimersByTime(10_000);
observer.observe(event("tool-call-delta"));
}
expect(idleEvents()).toEqual([]);
expect(observer.endProperties()).toMatchObject({
maxIdleMs: 10_000,
idleReported: false,
});
});
it("counts the silence before the run ends", () => {
observer.observe(assistantText("Done."));
vi.advanceTimersByTime(90_000);
expect(observer.endProperties()).toMatchObject({
maxIdleMs: 90_000,
maxIdleAfter: "assistant-message",
msSinceLastEvent: 90_000,
idleReported: true,
});
});
it("labels a turn rejected before reaching history as empty", () => {
observer.observe(assistantText("I'll run the tests now:"));
observer.observe(event("turn-started"));
observer.observe(event("run-failed"));
expect(observer.endProperties()).toMatchObject({
endedOn: "empty",
textSignature: "empty_turn",
});
});
it("does not sign a turn that failed mid-stream", () => {
observer.observe(event("assistant-text-delta"));
const properties = observer.endProperties();
expect(properties.endedOn).toBe("unfinished");
expect(properties).not.toHaveProperty("textSignature");
});
it("does not sign a media-only reply or carry an earlier turn's finish reason", () => {
observer.observe(assistantText("Calling a tool."));
observer.observe(event("turn-started"));
observer.observe(
event("assistant-message", {
message: { role: "assistant", content: [{ type: "media" }] },
finishReason: "stop",
}),
);
const media = observer.endProperties();
expect(media.endedOn).toBe("assistant_other");
expect(media).not.toHaveProperty("textSignature");
observer.observe(event("turn-started"));
expect(observer.endProperties().finishReason).toBeUndefined();
});
it("stops checking once the run ends", () => {
observer.endProperties();
vi.advanceTimersByTime(5 * RUN_IDLE_REPORT_MS);
expect(idleEvents()).toEqual([]);
});
});
@@ -0,0 +1,169 @@
import type {
AgentMessage,
AgentModelFinishReason,
AgentRuntimeEvent,
ITelemetryService,
TelemetryProperties,
} from "@cline/shared";
import { captureRunIdle } from "./core-events";
import { classifyFinalTurnText } from "./stall-signals";
/** Silence (no runtime event, stream deltas included) before a run reports `task.run_idle`. */
export const RUN_IDLE_REPORT_MS = 60_000;
/** Coarse shape of a message; never carries content. */
function messageShape(message: AgentMessage): string {
if (message.content.length === 0) {
return "empty";
}
const types = new Set(message.content.map((part) => part.type));
if (types.has("tool-call")) return "tool_call";
return `assistant_${types.has("text") ? "text" : "other"}`;
}
function messageText(message: AgentMessage): string {
return message.content
.map((part) => (part.type === "text" ? part.text : ""))
.join("");
}
/**
* Stall diagnostics for one agent run, derived only from its runtime events.
* `endProperties()` enriches the run's `agent.run-finished` /
* `agent.run-failed` telemetry; `task.run_idle` is sent while the run is
* still stuck.
*/
export class RunHealthObserver {
private lastEventAt = Date.now();
private lastEventType = "";
private maxIdleMs = 0;
private maxIdleAfter = "";
private idleReported = false;
private agentId = "";
private runId = "";
private finishReason?: AgentModelFinishReason;
private turnStarted = false;
/** The current turn streamed something but has no assistant message yet. */
private turnHadOutput = false;
/** The current turn's assistant message, once the runtime accepts one. */
private turnMessage?: AgentMessage;
private idleTimer?: ReturnType<typeof setTimeout>;
constructor(
private readonly telemetry: ITelemetryService,
private readonly context: {
sessionId?: string;
isSubagent: boolean;
providerId?: string;
modelId?: string;
},
) {
this.scheduleIdleCheck();
}
observe(event: AgentRuntimeEvent): void {
this.recordGap();
this.lastEventType = event.type;
this.agentId = event.snapshot.agentId;
this.runId = event.snapshot.runId ?? "";
switch (event.type) {
case "turn-started":
this.turnStarted = true;
this.turnHadOutput = false;
this.turnMessage = undefined;
this.finishReason = undefined;
break;
case "assistant-text-delta":
case "assistant-reasoning-delta":
case "assistant-media":
case "tool-call-delta":
this.turnHadOutput = true;
break;
case "assistant-message":
this.turnMessage = event.message;
this.finishReason = event.finishReason;
break;
}
}
/** Read once when the run ends; stops the idle check. */
endProperties(): TelemetryProperties {
this.dispose();
const msSinceLastEvent = Date.now() - this.lastEventAt;
this.recordGap();
// A turn the runtime rejected before it reached history (an empty
// response) still counts as the final turn, and as empty.
const endedOn = this.turnMessage
? messageShape(this.turnMessage)
: !this.turnStarted
? "none"
: this.turnHadOutput
? "unfinished"
: "empty";
// agent.* events carry no session or model of their own; these let a
// run be grouped by model and paired with the user's next message.
const { sessionId, isSubagent, providerId, modelId } = this.context;
return {
...(sessionId ? { sessionId } : {}),
isSubagent,
...(providerId ? { providerId } : {}),
...(modelId ? { modelId } : {}),
maxIdleMs: this.maxIdleMs,
maxIdleAfter: this.maxIdleAfter,
idleReported: this.idleReported,
msSinceLastEvent,
lastEventType: this.lastEventType,
finishReason: this.finishReason,
endedOn,
// Only a text or empty final turn has a text signature (detector
// parity); a media-only reply is not an empty one.
...(endedOn === "empty" || endedOn === "assistant_text"
? {
textSignature: classifyFinalTurnText(
this.turnMessage ? messageText(this.turnMessage) : "",
),
}
: {}),
};
}
dispose(): void {
clearTimeout(this.idleTimer);
this.idleTimer = undefined;
}
private recordGap(): void {
const now = Date.now();
if (now - this.lastEventAt > this.maxIdleMs) {
this.maxIdleMs = now - this.lastEventAt;
this.maxIdleAfter = this.lastEventType;
}
this.lastEventAt = now;
}
/** One pending timer per run, re-armed from the last event rather than per event. */
private scheduleIdleCheck(): void {
const delayMs = this.lastEventAt + RUN_IDLE_REPORT_MS - Date.now();
this.idleTimer = setTimeout(
() => {
const idleMs = Date.now() - this.lastEventAt;
if (idleMs < RUN_IDLE_REPORT_MS) {
this.scheduleIdleCheck();
return;
}
this.idleReported = true;
this.idleTimer = undefined;
captureRunIdle(this.telemetry, {
ulid: this.context.sessionId,
agentId: this.agentId,
runId: this.runId,
idleMs,
lastEventType: this.lastEventType,
isSubagent: this.context.isSubagent,
});
},
Math.max(delayMs, 0),
);
this.idleTimer.unref?.();
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { classifyBumpText, classifyFinalTurnText } from "./stall-signals";
// Expected labels are what the offline Python detector returns for the same text.
describe("stall signals", () => {
it.each([
["continue", "nudge"],
["ok continue", "nudge"],
["продолжай", "nudge"],
["继续", "nudge"],
["can you pick up where you left off?", "nudge"],
["are you stuck?", "probe"],
["did it finish?", "probe"],
["status?", "probe"],
["...", "probe"],
["还在吗", "probe"],
["hello", "greeting"],
[
"I'm stuck on page 1 of the docs, can you help me with the layout?",
"none",
],
["go ahead and refactor the payment module to use the new client", "none"],
["fix the failing test in auth.ts", "none"],
])("bump: %s → %s", (text, kind) => {
expect(classifyBumpText(text)).toBe(kind);
});
it.each([
["", "empty_turn"],
["API error: 529 overloaded_error", "api_error"],
["Here is the change:\n```ts\nconst x = 1", "truncated_text"],
["I updated the config and the", "truncated_text"],
["Now let me run the tests:", "intent_without_action"],
[
"Summary of changes.\n\nLet me run the linter to verify:",
"intent_without_action",
],
["I'll ping you when the build finishes.", "async_promise"],
["Should I proceed with the migration?", "none"],
["Let me know if you want me to continue.", "none"],
["Changes:\n- added the toggle\n- removed the old flag", "none"],
["The fix is in place 🎉", "none"],
])("final text: %j → %s", (text, signature) => {
expect(classifyFinalTurnText(text)).toBe(signature);
});
});
@@ -0,0 +1,94 @@
/**
* Stall signals ported from the offline (Python) stall detector, so the harness
* can label runs live and send only the verdict, never the text.
*
* Python's `\w`/`\b` match letters in any script; JS's match ASCII only, even
* with the `u` flag, so spots that meet non-ASCII words use `\p{L}` instead.
*/
export type StallBumpKind = "probe" | "nudge" | "greeting" | "none";
export type StallTextSignature =
| "empty_turn"
| "api_error"
| "truncated_text"
| "async_promise"
| "intent_without_action"
| "none";
const re = (source: string) => new RegExp(source, "iu");
// User reacting to a stall: "are you stuck?", "continue", "hello?"
const PROBE_RES = [
String.raw`\b(?:are|r)\s+(?:you|u)\s+(?:still\s+)?(?:stuck|dead|there|alive|ok(?:ay)?|frozen|hung|hanging|working|running|awake|on it|done|finished|thinking|doing anything|responding|back)\b`,
String.raw`\b(?:you|u)\s+(?:still\s+)?(?:stuck|dead|alive|frozen|hung)\b`,
String.raw`\b(?:did|has|have)\s+(?:you|it|this|that|the\s+\w+)(?:\s+\w+)?\s+(?:finish(?:ed)?|complete[d]?|stop(?:ped)?|die[d]?|hang|hung|crash(?:ed)?|freeze|froze|get\s+stuck|time[d]?\s*out)\b`,
String.raw`\b(?:is|was)\s+(?:it|this|that|the\s+\w+)\s+(?:still\s+)?(?:stuck|hung|frozen|running|done|finished|working)\b`,
String.raw`\bwhy\s+(?:did|have|has)\s+(?:you|it)\s+stop(?:ped)?\b|\b(?:you|it)\s+(?:just\s+)?stopped\b`,
String.raw`\b(?:seems?|appears?|looks?)\s+(?:to\s+be\s+|like\s+(?:you(?:'re| are)|it(?:'s| is))\s+)?(?:stuck|frozen|hung|dead)\b`,
String.raw`\bwhat(?:'s|\s+is)?\s+(?:happening|going\s+on)\b|\bwhat\s+happened\b|\bany\s+(?:update|progress)\b|^\s*(?:status|update|progress)\s*\?+\s*$`,
String.raw`\bprevious\s+(?:\w+\s+){0,4}(?:response|answer|reply|output)\s+(?:was|got)\s+(?:incomplete|cut\s+off|truncated|interrupted)|\bincomplete\s+or\s+timed\s+out\b`,
String.raw`\byou\s+(?:got\s+)?cut\s+off\b(?!\s+by\s+my)|\b(?:your\s+)?(?:response|answer|reply)\s+(?:got\s+|was\s+)?cut\s+off\b(?!\s+by\s+my)`,
String.raw`^(?:anyone\s+there|you\s+there|wake\s+up)\s*[?!.]*$`,
String.raw`^[??]+$|^\.{3,}$|^.{0,6}[??]{3,}$`,
String.raw`还在吗|在吗[??]?$|怎么不动|你怎么停|怎么停了|止まった|固まった|フリーズ|멈췄|завис|застрял|bist\s+du\s+(?:noch\s+)?da`,
].map(re);
const BARE_STATE_RE = re(
String.raw`\b(?:stuck|hung|hanging|frozen|unresponsive|not\s+responding|no\s+response)\b|卡住|卡了|没反应|不动了`,
);
const NUDGE_RE = re(
String.raw`^(?:(?:ok(?:ay)?|k|so|well|please|pls|now|yes|yeah)[,.!]?\s+)*` +
String.raw`(?:continue|contine|contiune|keep\s+going|keep\s+working|go\s+on|go\s+ahead|go|proceed|resume|carry\s+on|next|finish(?:\s+it|\s+up)?|do\s+it|try\s+again|retry|again|` +
String.raw`继续|繼續|続けて|계속|продолжа\p{L}*|дальше|давай|contin[uú]a|sigue|weiter|mach\s+weiter|continuez)` +
String.raw`(?:(?![\p{L}\p{N}_])|(?<=[぀-ヿ一-鿿]))[\s\p{L}\p{N}_,.!?()'’-]{0,40}$`,
);
const RESUME_REQUEST_RE = re(
String.raw`\b(?:pick\s+(?:it\s+)?(?:back\s+)?up|continue|carry\s+on|resume|keep\s+going)\s+(?:from\s+)?where\s+(?:you|we)\s+(?:left\s+off|stopped|were)\b|\bwhere\s+were\s+we\b`,
);
const GREETING_RE = re(
String.raw`^(?:hello+|hi+|hey+|ping|yo|你好|在吗)\s*[??!.]*$`,
);
// Final assistant turn that looks stalled: cut off, an error, or a promise not kept.
const API_ERROR_TEXT_RE = re(
String.raw`^\s*(?:API error|API Request Failed|Error:\s*(?:Request|Stream|Connection))|stream-first-event-timeout|Request timed out|socket hang up|ECONNRESET|overloaded_error|upstream connect error|Provider returned error|terminated due to timeout`,
);
const TERMINAL_RE = re(
String.raw`(?:[.!?:;)\]}"'\x60*>|_~。!?:)」』…]|[←-⯿\u{1F000}-\u{1FAFF}☀-➿️])\s*$`,
);
const LIST_TAIL_RE = re(String.raw`\n\s*(?:[-*•]|\d+[.)])\s+\S[^\n]*$`);
const ASYNC_PROMISE_RE = re(
String.raw`\b(?:will|I'll|I\s+will)\s+(?:ping|notify|report|update|let\s+you\s+know|check\s+back|get\s+back)\b[^\n]{0,80}\b(?:when|once|after|as\s+soon\s+as)\b|\b(?:wait(?:ing)?\s+for\s+(?:it|the\s+\w+)\s+to\s+(?:finish|complete|land))\b`,
);
const INTENT_RE = re(
String.raw`(?:^|[\n.!:]\s*)(?:now\s+|next[,:]?\s+|then\s+|first[,:]?\s+|ok(?:ay)?[,.]\s+|great[,.!]\s+)?(?:let\s+me|let's|i'll|i\s+will|i'm\s+going\s+to|i\s+am\s+going\s+to|i'm\s+now|going\s+to|launching|starting|running|proceeding|continuing|moving\s+on|time\s+to)\b[^\n]{0,240}[.:…]?\s*$`,
);
const ASKS_USER_RE = re(
String.raw`\?\s*$|\b(?:want\s+me\s+to|should\s+i|shall\s+i|would\s+you\s+like|do\s+you\s+want|let\s+me\s+know|approve|confirm|which\s+(?:option|one|approach)|ready\s+to\s+proceed|when\s+you(?:'re|\s+are)\s+ready|ping\s+me|once\s+you)\b`,
);
export function classifyBumpText(prompt: string): StallBumpKind {
const text = prompt.trim();
const length = Array.from(text).length;
if (!text || length > 200) return "none";
if (PROBE_RES.some((pattern) => pattern.test(text))) return "probe";
if (length <= 40 && BARE_STATE_RE.test(text)) return "probe";
if (length <= 80 && NUDGE_RE.test(text)) return "nudge";
if (RESUME_REQUEST_RE.test(text)) return "nudge";
if (GREETING_RE.test(text)) return "greeting";
return "none";
}
/** Signature of a run's final text-only assistant turn. */
export function classifyFinalTurnText(rawText: string): StallTextSignature {
const text = rawText.trimEnd();
if (!text) return "empty_turn";
if (text.length < 600 && API_ERROR_TEXT_RE.test(text)) return "api_error";
if ((text.split("```").length - 1) % 2 === 1) return "truncated_text";
if (!TERMINAL_RE.test(text) && !LIST_TAIL_RE.test(text))
return "truncated_text";
const lastPara = text.slice(text.lastIndexOf("\n\n") + 1);
if (ASYNC_PROMISE_RE.test(lastPara)) return "async_promise";
if (INTENT_RE.test(lastPara) && !ASKS_USER_RE.test(lastPara.slice(-300)))
return "intent_without_action";
return "none";
}
+4
View File
@@ -25,6 +25,8 @@ export type ActiveSession = {
agent: SessionRuntime;
started: boolean;
aborting: boolean;
/** Epoch ms the last turn ended; labels the next input for stall analysis. */
lastTurnEndedAt?: number;
interactive: boolean;
persistedMessages?: LlmsProviders.MessageWithMetadata[];
compactionState?: SessionCompactionState;
@@ -34,6 +36,8 @@ export type ActiveSession = {
teamRunWaiters: Array<() => void>;
pendingPrompts: PendingPrompt[];
drainingPendingPrompts: boolean;
/** Set by the queue drain on the one input it replays; consumed by runTurn. */
replayingQueuedPrompt?: boolean;
pluginSandboxShutdown?: () => Promise<void>;
pluginCommandError?: string;
pluginCommandTail?: Promise<unknown>;
+20 -1
View File
@@ -13,7 +13,10 @@ import type {
ToolPolicy,
} from "./llms/tools";
import type { BasicLogger } from "./logging/logger";
import type { ITelemetryService } from "./services/telemetry";
import type {
ITelemetryService,
TelemetryProperties,
} from "./services/telemetry";
// =============================================================================
// Lightweight telemetry surface used by AgentRuntime
@@ -579,6 +582,12 @@ export interface AgentRuntimeConfig {
plugins?: readonly AgentRuntimePlugin[];
logger?: BasicLogger;
telemetry?: ITelemetryService;
/**
* Extra properties for the `agent.run-finished` / `agent.run-failed`
* telemetry mirror, read once when the run ends. Lets the host attach
* diagnostics it derives from runtime events without the loop owning them.
*/
runEndTelemetryProperties?: () => TelemetryProperties;
initialMessages?: readonly AgentMessage[];
maxIterations?: number;
completionPolicy?: {
@@ -661,6 +670,16 @@ export type AgentRuntimeEvent =
message: AgentMessage;
finishReason: AgentModelFinishReason;
}
| {
/**
* Progress while the model streams a tool call's arguments. Carries
* no arguments: consumers use it only to tell a slow stream from a
* silent one.
*/
type: "tool-call-delta";
snapshot: AgentRuntimeStateSnapshot;
iteration: number;
}
| {
type: "tool-started";
snapshot: AgentRuntimeStateSnapshot;