Merge branch 'main' into send-auth-metadata-on-traces

This commit is contained in:
Tomás Barreiro
2026-10-22 18:57:34 -03:00
committed by GitHub
520 changed files with 57533 additions and 11916 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.
+5 -5
View File
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline
> Working directory: run every command below from the repository root.
Desktop releases ship three platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Linux: x64 `.deb` and `.rpm` packages (`<Product>_<version>_amd64.deb`, `<Product>_<version>_x86_64.rpm`) built on Ubuntu 22.04 in the `build-linux` job, deb + rpm only (no AppImage: linuxdeploy cannot process the Bun-compiled sidecar). Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
Desktop releases ship three platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Linux: x64 `.deb` and `.rpm` packages (`<Product>_<version>_amd64.deb`, `<Product>_<version>_x86_64.rpm`) built on Ubuntu 22.04 in the `build-linux` job, deb + rpm only (no AppImage: linuxdeploy cannot process the Bun-compiled Cline CLI the app bundles). Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
## Release contract
@@ -50,12 +50,12 @@ For a **beta** release, work on `desktop-experimental` (check out `origin/deskto
```sh
# stable (on main):
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml
# beta (on desktop-experimental):
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
The desktop backend and the bundled Cline CLI (which runs the Hub) are built from the monorepo's `@cline/core` and friends, so SDK and CLI changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
@@ -129,7 +129,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the bundled Cline CLI is lipo'd by `build-desktop-runtime.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
+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.
+11
View File
@@ -34,3 +34,14 @@ updates:
- "version-update:semver-major"
- "version-update:semver-minor"
- "version-update:semver-patch"
# Pinned skills CLI release (devDependency of @cline/core). `cline skill`
# and the marketplace skill fallback run this exact version through npx,
# so version updates are wanted here, unlike the entries above: each bump
# goes through CI and review before it reaches users.
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
allow:
- dependency-name: "skills"
+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"
+157 -103
View File
@@ -312,6 +312,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -335,17 +337,19 @@ jobs:
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
# this step and inlines these values into the binary via `--define`
# (scripts/telemetry-define-args.ts); a packaged app launched from
# Finder/the Dock has no runtime env, so build-time inlining is the
# only way the shipped sidecar can ever report telemetry.
# Telemetry config for the desktop backend and bundled Cline CLI.
# Tauri's beforeBuildCommand (`bun run build` -> build:runtime)
# builds both during this step and inlines these values via
# `--define` (scripts/telemetry-define-args.ts); a packaged app
# launched from Finder/the Dock has no runtime env, so build-time
# inlining is the only way the shipped backend can report telemetry.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -368,10 +372,11 @@ jobs:
# bare DMG. macOS runners have a GUI session, so opt out of the skip.
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
# Tauri lipos the main binary itself but sidecars are merged by our own
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
# carries both slices before anything is published. A single-arch
# sidecar would otherwise ship fine and only crash on the other arch.
# Tauri lipos the main binary itself but the bundled Cline CLI is merged
# by our own build-desktop-runtime.ts, so assert every Mach-O in the
# bundle really carries both slices before anything is published. A
# single-arch CLI would otherwise ship fine and only crash on the other
# arch.
- name: Verify bundle is a universal binary
working-directory: apps/examples/desktop-app
env:
@@ -450,49 +455,63 @@ jobs:
echo "Updater endpoint verified: ${WANT:-disabled}"
# Guardrail: assert the telemetry config actually made it into the
# compiled sidecar. Missing env on the build step (or a regression in
# bundled desktop backend. Missing env on the build step (or a regression in
# the --define inlining) would otherwise ship a release with telemetry
# silently disabled — exactly what happened for every release before
# this check existed. Being enabled is not enough on its own: an empty,
# malformed, or non-http(s) OTLP endpoint would still drop every event
# at runtime (the SDK exporters speak OTLP http/json only), so the
# selfcheck must also report a usable endpoint host.
- name: Verify sidecar telemetry config was inlined
- name: Verify CLI and desktop backend telemetry configs were inlined
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build, sign, and notarize desktop bundle' step and the"
echo "--define inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
echo "every event would be dropped at runtime. Check the"
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
for COMPONENT in cli backend; do
if [ "$COMPONENT" = cli ]; then
SELFCHECK=$(./src-tauri/bin/cline-cli-universal-apple-darwin --telemetry-selfcheck)
else
SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-universal-apple-darwin run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck)
fi
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged $COMPONENT reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build, sign, and notarize desktop bundle' step and the"
echo "--define inlining in scripts/build-desktop-runtime.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged $COMPONENT reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
echo "every event would be dropped at runtime. Check the"
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Boot the sidecar that will actually ship — built with the production
# --target, --minify and telemetry defines, on the OS it will run on —
# against an isolated Hub, and require its ready line plus a healthy
# /health. Desktop 0.0.31 shipped a sidecar whose Hub daemon loaded a
# Boot the Cline CLI and desktop backend that will actually ship — built
# with the production --target, --minify and telemetry defines, on the
# OS they will run on — against an isolated Hub started by
# `cline hub ensure`, and require the backend's ready line plus a
# healthy /health. Desktop 0.0.31 shipped a backend whose Hub daemon loaded a
# second copy of @cline/core with a different build id, so the app
# retired its own Hub on every launch and never got past "backend
# unavailable". PR CI runs this same test on ubuntu against a plain
# `bun build --compile`, which catches that resolution class but not a
# build from source, which catches that resolution class but not a
# break specific to the shipped flags or platform; this is the
# release-time half. The test isolates CLINE_DIR and the Hub discovery
# path, so it cannot reach anything else on the runner. Telemetry is
# inlined into the binary, so expect one activation event per publish.
- name: Smoke-test the packaged sidecar against an isolated Hub
# inlined into the backend, so expect one activation event per publish.
- name: Smoke-test the packaged desktop runtime against an isolated Hub
working-directory: apps/examples/desktop-app
env:
CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-universal-apple-darwin
CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-universal-apple-darwin
CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js
run: bun test scripts/desktop-startup.test.ts
- name: Collect artifacts
@@ -615,7 +634,7 @@ jobs:
with:
bun-version: "1.4.2"
# build-sidecar-bin.ts cross-compiles the Linux SSH remote helpers on
# build-desktop-runtime.ts cross-compiles the Linux SSH remote CLIs on
# every platform, and the Windows installer ships them (they are Tauri
# resources, bundle.resources in tauri.conf.json). Bun cross-compiles by
# downloading the target's release zip and extracting the executable
@@ -627,13 +646,13 @@ jobs:
# with the same runtimes from npm (@oven/bun-<target>, byte-identical to
# the zip payload) and the build never enters the broken path. The macOS
# job does not need this; the extractor works there.
- name: Seed Bun cache with Linux remote-helper runtimes
- name: Seed Bun cache with Linux remote CLI runtimes
shell: bash
run: |
BUN_VERSION="$(bun --version)"
CACHE_DIR="${RUNNER_TEMP//\\//}/bun-install-cache"
mkdir -p "$CACHE_DIR"
# Keep these runtimes aligned with build-sidecar-bin.ts, including
# Keep these runtimes aligned with build-desktop-runtime.ts, including
# the baseline x64 runtime needed by SSH hosts without AVX2.
for target in linux-x64-baseline linux-aarch64; do
dest="$CACHE_DIR/bun-$target-v$BUN_VERSION"
@@ -672,6 +691,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -697,7 +718,7 @@ jobs:
- name: Prime Azure Trusted Signing token
run: az account get-access-token --resource https://codesigning.azure.net --query expiresOn --output tsv
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
# Tauri invokes signCommand once per staged binary (main exe, Cline CLI,
# NSIS uninstaller, and the installer itself). The overlay is generated
# here rather than committed because signCommand needs an absolute path
# to the signing script on this runner.
@@ -728,12 +749,14 @@ jobs:
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
# Telemetry inlined into the desktop backend at build time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -794,33 +817,46 @@ jobs:
fi
echo "Updater endpoint verified: ${WANT:-disabled}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
# Same guardrail as the macOS job, run natively on the Windows build.
- name: Verify CLI and desktop backend telemetry configs were inlined
shell: bash
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
for COMPONENT in cli backend; do
if [ "$COMPONENT" = cli ]; then
SELFCHECK=$(./src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
else
SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck)
fi
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged $COMPONENT reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-desktop-runtime.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged $COMPONENT reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Same release-time gate as the macOS job; see the comment there.
- name: Smoke-test the packaged sidecar against an isolated Hub
- name: Smoke-test the packaged desktop runtime against an isolated Hub
shell: bash
working-directory: apps/examples/desktop-app
env:
CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe
CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe
CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js
run: bun test scripts/desktop-startup.test.ts
- name: Collect artifacts
@@ -860,11 +896,11 @@ jobs:
shell: pwsh
working-directory: apps/examples/desktop-app
run: |
# The Tauri bundler signs the sidecar in place, so check it here too;
# a WDAC-locked machine blocks the app at runtime if the sidecar it
# spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
# The Tauri bundler signs the bundled Cline CLI in place, so check it
# here too; a WDAC-locked machine blocks the app at runtime if the
# CLI it spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/cline-cli-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the Cline CLI to verify" }
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
@@ -900,7 +936,7 @@ jobs:
rpm_arch: x86_64
# Native arm64 runner rather than cross-compiling: WebKitGTK and the
# other GTK build deps have no usable cross toolchain in apt, and a
# native build lets the sidecar smoke test below run the real binary.
# native build lets the runtime smoke test below run the real binary.
- arch: arm64
runner: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
@@ -992,6 +1028,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -999,18 +1037,20 @@ jobs:
- name: Build desktop bundles
working-directory: apps/examples/desktop-app
# deb and rpm only. The AppImage target is excluded because linuxdeploy
# cannot process the Bun-compiled sidecar (ldd fails on it and patchelf
# cannot process the Bun-compiled Cline CLI (ldd fails on it and patchelf
# corrupts it), which aborts the bundle step. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles deb,rpm $CONFIG_ARGS
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
# Telemetry inlined into the desktop backend at build time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -1057,59 +1097,73 @@ jobs:
fi
echo "Updater endpoint verified: ${WANT:-disabled}"
# Same guardrail as the other build jobs, run on the Linux sidecar.
- name: Verify sidecar telemetry config was inlined
# Same guardrail as the other build jobs, run on the Linux build.
- name: Verify CLI and desktop backend telemetry configs were inlined
working-directory: apps/examples/desktop-app
env:
TARGET: ${{ matrix.target }}
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-${TARGET} --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build desktop bundles' step and the --define inlining in"
echo "scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
for COMPONENT in cli backend; do
if [ "$COMPONENT" = cli ]; then
SELFCHECK=$(./src-tauri/bin/cline-cli-${TARGET} --telemetry-selfcheck)
else
SELFCHECK=$(BUN_BE_BUN=1 ./src-tauri/bin/cline-cli-${TARGET} run --no-env-file src-tauri/bin/desktop-backend/index.js --telemetry-selfcheck)
fi
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged $COMPONENT reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build desktop bundles' step and the --define inlining in"
echo "scripts/build-desktop-runtime.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged $COMPONENT reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Same release-time gate as the macOS job; see the comment there.
- name: Smoke-test the packaged sidecar against an isolated Hub
- name: Smoke-test the packaged desktop runtime against an isolated Hub
working-directory: apps/examples/desktop-app
env:
CLINE_TEST_SIDECAR_BIN: src-tauri/bin/code-sidecar-${{ matrix.target }}
CLINE_TEST_DESKTOP_CLI_BIN: src-tauri/bin/cline-cli-${{ matrix.target }}
CLINE_TEST_DESKTOP_BACKEND_BUNDLE: src-tauri/bin/desktop-backend/index.js
run: bun test scripts/desktop-startup.test.ts
# Run the SSH remote helper this job bundles (same arch as the runner)
# through the exact command the desktop sends over SSH, then stop the
# Hub it started. Desktop 0.0.38 through 0.0.44 shipped helpers that
# printed --help fine but crashed on every --remote-hub-ensure, because
# UPX packing broke the Bun 1.4 runtime's lazy reads of its own payload
# Run the Linux Cline CLI this job bundles for SSH remotes (same arch as
# the runner) through the exact `hub ensure` / `hub stop` the desktop
# sends over SSH. Desktop 0.0.38 through 0.0.44 shipped remote binaries
# that printed --help fine but crashed on every ensure, because UPX
# packing broke the Bun 1.4 runtime's lazy reads of its own payload
# (#14815, #14781); nothing exercised that path before release. Isolated
# CLINE_DIR and discovery record, as in the sidecar test above. Only the
# CLINE_DIR and discovery record, as in the runtime test above. Only the
# exit status is checked; the ensure output carries the Hub auth token.
- name: Smoke-test the bundled SSH remote helper
- name: Smoke-test the bundled SSH remote CLI
working-directory: apps/examples/desktop-app
env:
HELPER: src-tauri/bin/remote-helpers/cline-remote-helper-${{ matrix.target }}
REMOTE_CLI: src-tauri/bin/remote-helpers/cline-${{ matrix.target }}
run: |
export CLINE_DIR="$(mktemp -d)"
export CLINE_DATA_DIR="$CLINE_DIR/data"
export CLINE_NO_AUTO_UPDATE=1
DISCOVERY="$CLINE_DIR/remote-hub.json"
trap 'timeout 30 "$HELPER" --remote-hub-stop --discovery-path "$DISCOVERY" >/dev/null 2>&1 || true' EXIT
if ! timeout 60 "$HELPER" --remote-hub-ensure --cwd "$CLINE_DIR" --discovery-path "$DISCOVERY" >/dev/null; then
echo "Bundled remote helper failed --remote-hub-ensure; SSH remotes"
echo "would not start from this build. Check scripts/build-sidecar-bin.ts."
trap 'timeout 30 "$REMOTE_CLI" hub stop --discovery-path "$DISCOVERY" >/dev/null 2>&1 || true' EXIT
if ! timeout 60 "$REMOTE_CLI" hub ensure --json --allow-port-fallback --no-connectors --cwd "$CLINE_DIR" --host 127.0.0.1 --port 0 --pathname /hub --discovery-path "$DISCOVERY" >/dev/null; then
echo "Bundled remote CLI failed hub ensure; SSH remotes would not"
echo "start from this build. Check scripts/build-desktop-runtime.ts."
exit 1
fi
timeout 30 "$HELPER" --remote-hub-stop --discovery-path "$DISCOVERY"
echo "Remote helper started and stopped an isolated Hub."
timeout 30 "$REMOTE_CLI" hub stop --discovery-path "$DISCOVERY"
echo "Remote CLI started and stopped an isolated Hub."
- name: Collect artifacts
working-directory: apps/examples/desktop-app
@@ -1373,7 +1427,7 @@ jobs:
# Nightlies have no changelog, so the announcement lists the commits
# since the nearest desktop tag - the previous nightly or the last
# release, whichever is closer - scoped to what ships in the app
# (the sidecar bundles sdk/packages). Runs before this build is
# (the app bundles sdk/packages and the Cline CLI). Runs before this build is
# tagged below so it never compares against itself.
STAMP=${VERSION#*-nightly.}
TAG="desktop-nightly-${STAMP%%.*}"
@@ -1385,7 +1439,7 @@ jobs:
if [ -z "$PREV_TAG" ]; then
BODY="No previous nightly or desktop release tag found to compare against."
else
COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges --format='%s' -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml)
COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges --format='%s' -- apps/examples/desktop-app apps/cli sdk/packages .github/workflows/desktop-publish.yml)
TOTAL=$(printf "%s" "$COMMITS" | grep -c . || true)
COMPARE="https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...$(git rev-parse --short HEAD)"
if [ "$TOTAL" -eq 0 ]; then
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
run: bun run test:dmg-background
windows-installer:
name: Test x86 installer against x64 sidecars
name: Test x86 installer against x64 app executables
runs-on: windows-latest
timeout-minutes: 10
defaults:
@@ -78,7 +78,7 @@ jobs:
no-cache: true
# Use the same NSIS distribution as the locked Tauri CLI. No workspace
# install or full desktop build: the test compiles a disposable sidecar.
# install or full desktop build: the test compiles a disposable fixture executable.
# The test asserts makensis reports NSIS_VERSION and that bun.lock still
# resolves TAURI_CLI_VERSION, so changing this download or bumping the
# CLI without updating windows-installer.test.ts fails the job.
+8
View File
@@ -211,6 +211,14 @@ jobs:
exit 1
fi
- name: Cloud session boundary (local Hub)
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
working-directory: .
run: |
bun run build:sdk
cd apps/vscode
bun run test:cloud
- name: Unit Tests (bun) - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
+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
+4
View File
@@ -104,6 +104,10 @@ jobs:
bun -F @cline/code test:sidecar
bun -F @cline/code test:sidecar-startup
- name: Run Windows compiled plugin resource tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F @cline/code test:sidecar-startup --test-name-pattern "standalone CLI"
- name: Run Desktop Settings UI Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
run: bun -F @cline/code test:settings-ui
+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
+41
View File
@@ -1,5 +1,46 @@
# Changelog
## [4.1.25]
### Added
- Cline Cloud sessions, rolling out gradually: run a task in a hosted sandbox on a GitHub repo and branch you pick. Turn it on in Settings. Cloud tasks show in Recent and History with their status, notify you when they finish, and keep running after you close your editor.
### Changed
- With no model chosen, the Cline provider now uses Gemini 4 Argon, the new first model in Cline's recommended list. OpenRouter stays on Claude Sonnet 5.5.
- Refreshed the model catalog. Adds the Boundless and Standard Compute providers, and SambaNova now comes from the catalog with MiniMax M3 as its default. The Cline recommended list adds Gemini 4 Argon and drops Grok 4.7, and the Cline free list replaces Step 5 Preview with Nemotron 3.5 Super VL. OpenCode Go no longer lists MiniMax M2.7. Default models change for Cortecs, CrossModel, Eden AI, Google Gemini, GitHub Copilot, Google Vertex AI, LLM Gateway, Ofox, OpenCode Go, and Prime Inference (Gemini 4 Argon), Fireworks AI, Nebius Token Factory, and Requesty (Muse Spark Open), CoreWeave, DigitalOcean, NanoGPT, and Nvidia (Nemotron 3.5 Super VL), Aixy and Neon (Claude Haiku 5.5), Agnes AI, Ambient, IteraCompute, Poe, Scaleway, and SenseNova. If you use one of those providers without pinning a model, expect a different default.
### Fixed
- Long requests to local models (LM Studio, Ollama) no longer fail after 5 minutes of silence with `terminated: BodyTimeoutError` while the server is still processing a large prompt.
- OpenAI Compatible base URLs are used exactly as entered. A host-only URL like `https://abc.com` used to get `/v1` added silently. Host-only URLs saved by earlier versions get `/v1` added once, so existing setups keep working.
- Images and files attached to an answer to one of Cline's questions now reach the model instead of being dropped.
- When a provider error interrupts a response mid-stream, Cline retries it up to 3 times before failing the turn.
- In JetBrains, the Run task card stays above the Auto-approve bar.
- Auto Compact and its strategy setting sit together again in Feature settings.
## [4.1.24]
### Changed
- With no model chosen, the Cline and OpenRouter providers now use Claude Sonnet 5.5. For Cline, that's the first model in Cline's recommended list.
- Selecting ai& shows its own API key and model picker, instead of the generic OpenAI-compatible form with base URL and custom headers.
- Refreshed the model catalog. Adds Claude Haiku 5.5 and the AI/ML API, LowRouter, and Prime Inference providers. The Cline free list adds Step 5 Preview. The v0 provider now offers only its default model, `v0-1.5-md`, and OpenCode Go uses its new `https://opencode.ai/inference/go/v1` endpoint. Default models change for 302.AI, Cortecs, CrossModel, DevPass, DigitalOcean, Eden AI, GitHub Copilot, Google Vertex AI, LLM Gateway, Meta, Moark, NanoGPT, NEAR AI Cloud, Neuralwatt, Novita AI, Ofox, OpenCode Go, Requesty, Vivgrid, and a few others (most now Claude Haiku 5.5 or Muse Wonder 1.0). If you use one of those providers without pinning a model, expect a different default.
### Fixed
- Turning reasoning off no longer fails with a 400 error on GPT-6 Astra, GPT-6.1 Sol, Claude Fable 5 or Claude Opus 5.5, and now works on every provider. Level-only Gemini models no longer reject the request with a thinking-budget error.
- An MCP server or hook that exits before reading all of its input no longer crashes the extension host about 3 seconds later.
- A 404 from a stale or retired model id is no longer shown as a sign-in problem. Cline shows the real error instead of the logged-out card.
- Errors in the Cline output log keep their details. Production builds used to drop them, so a line like "Cline OAuth login failed:" showed no reason.
- Deleting a slash command from the input keeps the spacing of the text around it.
- Screenshots and other images no longer inflate the token estimate, which made auto-compaction run long before the conversation was near its limit.
- Auto-compaction uses the selected model's context window when the catalog entry has none.
- Rule files that are symlinks now load, and skills in `.claude/skills` are now discovered.
- The editor tool no longer receives null in place of the text to replace from some models.
- Settings migrated from older versions no longer add an empty SAP AI Core provider.
## [4.1.23]
### Changed
+2 -2
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">
@@ -126,7 +126,7 @@ npm install @cline/sdk
|---------|------------|--------------|--------------|
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`apps/vscode/`](https://github.com/cline/cline/tree/main/apps/vscode) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
+32
View File
@@ -1,5 +1,37 @@
# Cline CLI Changelog
## 3.0.73
- GPT-6 models (such as `gpt-6-sol` and `gpt-6.1-sol`) work on OpenAI and Azure OpenAI-compatible endpoints. Before, every request was rejected because Cline sent `max_tokens` instead of `max_completion_tokens`
- With no model chosen, the Cline provider uses Claude Sonnet 5.5 again, which is back at the top of Cline's recommended list
- Refreshed the model catalog. Adds the CommandCode and Reflection providers. Default models change for 302.AI (Gemini 4 Argon), Eden AI and Prime Inference (Muse Spark Open), CoreWeave (Reflection Beam 1.0), NanoGPT, Pioneer (Nemotron 3.5 Super VL), and Scaleway (DeepSeek V4.1 Flash)
## 3.0.72
- Plugins now run in a worker instead of directly inside the hub. If this build can't run plugins, Cline says so in `cline config tools`, `cline hub plugins`, and the session-start warnings instead of quietly running them inside the hub. `CLINE_PLUGIN_MODE=in_process` is no longer supported; use `worker` (the default) or `sandbox`
- When a provider error interrupts a response mid-stream, Cline retries it up to 3 times before failing the turn
- Failed model requests now log the provider's request id, which makes support tickets easier to trace
- `cline hub ensure` and `cline hub start` gain `--json` (prints the hub URL and auth token), `--allow-port-fallback`, `--no-connectors`, and `--login-shell-path`. `cline hub` accepts `--discovery-path` to use a dedicated discovery record
- `cline hub` commands no longer count as a CLI activation in usage stats
- With no model chosen, the Cline provider now uses Gemini 4 Argon, the new first model in Cline's recommended list
- Refreshed the model catalog. Adds the Boundless and Standard Compute providers, and SambaNova now comes from the catalog with MiniMax M3 as its default. The Cline recommended list adds Gemini 4 Argon and drops Grok 4.7, and the Cline free list replaces Step 5 Preview with Nemotron 3.5 Super VL. OpenCode Go no longer lists MiniMax M2.7. Default models change for Cortecs, CrossModel, Eden AI, Google Gemini, GitHub Copilot, Google Vertex AI, LLM Gateway, Ofox, OpenCode Go, and Prime Inference (Gemini 4 Argon), Fireworks AI, Nebius Token Factory, and Requesty (Muse Spark Open), CoreWeave, DigitalOcean, NanoGPT, and Nvidia (Nemotron 3.5 Super VL), Aixy and Neon (Claude Haiku 5.5), Agnes AI, Ambient, IteraCompute, Poe, Scaleway, and SenseNova
## 3.0.71
- Plugins now load inside the hub instead of a separate node or bun process, so the CLI no longer needs a JavaScript runtime on your PATH to run plugins. A plugin that fails to load no longer silently loses its tools: Cline prints a warning naming the plugin and the error, `cline config tools --json` lists it as a `plugin_failure` entry, and the new `cline hub plugins` shows each plugin's status and last error (`--reload <plugin>` re-imports one without restarting the hub). Set `CLINE_PLUGIN_MODE=sandbox` to keep the old subprocess behavior
- `--thinking` now applies in ACP mode (`--acp`). Before, ACP sessions always ran with reasoning off
- `cline hook` accepts `agent_error` events. Before, it exited with an error and no response
- A provider API key set in the environment (such as `OPENROUTER_API_KEY`) now counts as configured at startup, for providers that read a single environment variable. Before, the TUI asked you to set up the provider anyway
- With no model chosen, the Cline provider now uses the first model in Cline's recommended list (Claude Sonnet 5.5)
- Turning reasoning off now works on every provider. Models that can't turn it off keep their default, and level-only Gemini models no longer reject the request with a thinking-budget error
- Screenshots and other images no longer inflate the token estimate, which made auto-compaction run long before the conversation was near its limit
- Auto-compaction uses the selected model's context window when the catalog entry has none
- Scheduled runs: queued runs now start instead of staying pending forever, a run whose model call fails (for example, a retired model) is marked failed with the error, and after downtime only the latest missed occurrence of a schedule runs instead of every one back to back
- Rule files that are symlinks now load
- Skills in `.claude/skills` are now discovered
- The editor tool no longer receives null in place of the text to replace from some models
- Refreshed the model catalog. Adds the AI/ML API, LowRouter, and Prime Inference providers. The v0 provider now offers only its default model, `v0-1.5-md`, and OpenCode Go uses its new `https://opencode.ai/inference/go/v1` endpoint. The Cline free list adds Step 5 Preview. Default models change for 302.AI, CrossModel, Cortecs, DigitalOcean, NEAR AI Cloud, and Ofox (Claude Haiku 5.5), DevPass (LLM Gateway), LLM Gateway, and Meta (Muse Wonder 1.0), Aixy (GPT-6.1 Sol), CloudFerro Sherlock and Moark (GLM 5.3), Ambient, NanoGPT, Neuralwatt, and Novita AI
## 3.0.70
- Commit message generation and tasks with reasoning off no longer fail with a 400 error on GPT-6 Astra, GPT-6.1 Sol, Claude Fable 5 or Claude Opus 5.5
+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
+6
View File
@@ -291,6 +291,12 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
- `cline hub` - Manage the local hub daemon
- `cline kanban` - Run the external `kanban` app, installing it first when needed
Compiled CLI binaries embed the plugin bootstrap, SDK modules, and dashboard
assets. They can start the Hub, load SDK plugins, and serve the dashboard without
a separate Node/Bun installation or adjacent runtime files. Resources are read directly from the binary’s read-only Bun filesystem; no
runtime cache is extracted. `--plugin-sandbox` is an internal subprocess dispatch
flag, not a user command.
## Hub dashboard
Run `cline hub` to list hub subcommands. To start the browser dashboard:
+6 -9
View File
@@ -1,9 +1,9 @@
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join } from "node:path";
@@ -136,16 +136,13 @@ if (result.logs.length > 0) {
}
}
const coreBootstrapPath = join(
const corePluginRuntimePath = join(
rootDir,
"../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
"../../sdk/packages/core/dist/plugin-runtime",
);
const cliBootstrapPath = join(
rootDir,
"./dist/extensions/plugin-sandbox-bootstrap.js",
);
mkdirSync(dirname(cliBootstrapPath), { recursive: true });
copyFileSync(coreBootstrapPath, cliBootstrapPath);
const cliPluginRuntimePath = join(rootDir, "dist/plugin-runtime");
rmSync(cliPluginRuntimePath, { recursive: true, force: true });
cpSync(corePluginRuntimePath, cliPluginRuntimePath, { recursive: true });
if (existsSync(hubWebviewDistPath)) {
mkdirSync(dirname(cliHubWebviewDistPath), { recursive: true });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.70",
"version": "3.0.73",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+27 -176
View File
@@ -1,57 +1,24 @@
#!/usr/bin/env bun
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
} from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { type AddressInfo, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join, relative, resolve } from "node:path";
import { join, resolve } from "node:path";
import { $ } from "bun";
import {
parseBuildOptions,
shouldInstallNativeVariants,
validateBuildOptions,
} from "./build-options";
import {
compileCliBinary,
installOpenTuiNativeVariants,
} from "./compile-binary";
const cliDir = resolve(import.meta.dir, "..");
const rootDir = resolve(cliDir, "../..");
process.chdir(cliDir);
// Telemetry / OTEL environment variables that should be baked into the
// compiled binary at build time. Mirrors the list of secrets injected by the
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
// so the CLI ships with the production telemetry configuration without
// requiring the end user to set any env vars.
const BUILD_TIME_INLINED_ENV_VARS = [
"TELEMETRY_SERVICE_API_KEY",
"ERROR_SERVICE_API_KEY",
"OTEL_TELEMETRY_ENABLED",
"OTEL_LOGS_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_TRACES_EXPORTER",
"CLINE_TRACE_SAMPLE_PERCENT",
"CLINE_TRACE_RECORD_CONTENT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
] as const;
function buildInlinedEnvDefines(): Record<string, string> {
const defines: Record<string, string> = {};
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
}
return defines;
}
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
const version: string = pkg.version;
const repository: unknown = pkg.repository;
@@ -89,16 +56,13 @@ if (optionsError) {
process.exit(1);
}
await $`rm -rf dist`;
rmSync(join(cliDir, "dist"), { recursive: true, force: true });
// Pre-install all platform variants of native packages so cross-compilation
// can resolve them. Without this, Bun only has the host platform's native
// binary and cross-compiled builds fail to resolve @opentui/core's FFI layer.
if (shouldInstallNativeVariants({ options: buildOptions, opentuiVersion })) {
console.log(
`Installing all platform variants of @opentui/core@${opentuiVersion}...`,
);
await $`bun install --os="*" --cpu="*" @opentui/core@${opentuiVersion}`;
await installOpenTuiNativeVariants();
}
// Build the SDK first (the CLI bundles workspace packages)
@@ -110,63 +74,8 @@ if (!buildOptions.skipSdkBuild) {
await $`bun -F @cline/cli build`.cwd(rootDir);
}
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
const hubWebviewIndex = join(hubWebviewDist, "index.html");
function newestFileMtimeMs(dir: string): number {
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestFileMtimeMs(path));
} else if (entry.isFile()) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function shouldBuildHubWebview(): boolean {
if (!existsSync(hubWebviewIndex)) {
return true;
}
try {
return (
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
);
} catch {
return true;
}
}
if (shouldBuildHubWebview()) {
console.log("Building Cline Hub webview...");
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
}
const binaries: Record<string, string> = {};
function findOpenTuiParserWorker(): string {
const localPath = resolve(
cliDir,
"node_modules/@opentui/core/parser.worker.js",
);
const rootPath = resolve(
rootDir,
"node_modules/@opentui/core/parser.worker.js",
);
const parserWorkerPath = existsSync(localPath) ? localPath : rootPath;
return realpathSync(parserWorkerPath);
}
function findFreePort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const server = createServer();
@@ -185,61 +94,6 @@ function getBunTarget(
return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget;
}
async function buildCompiledBinary(input: {
bunTarget: Bun.Build.CompileTarget;
dirName: string;
outfile: string;
}): Promise<void> {
const parserWorker = findOpenTuiParserWorker();
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
const parserWorkerPath = relative(rootDir, parserWorker).replaceAll(
"\\",
"/",
);
// Build to /tmp first so Bun's temp-file rename stays on one filesystem
// layer in containerized environments (virtiofs, overlayfs).
const entrypoint = join(cliDir, "src/index.ts");
const tmpDir = join("/tmp", `cline-build-${input.dirName}`);
const tmpOutfile = join(
tmpDir,
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
);
mkdirSync(tmpDir, { recursive: true });
process.chdir("/tmp");
const result = await Bun.build({
entrypoints: [entrypoint, parserWorker],
splitting: true,
compile: {
target: input.bunTarget,
outfile: tmpOutfile,
},
minify: true,
external: ["@anthropic-ai/vertex-sdk"],
define: {
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
// Inline telemetry/OTEL env vars at build time so the compiled
// binary ships with production telemetry configuration baked in.
...buildInlinedEnvDefines(),
},
throw: false,
});
process.chdir(cliDir);
if (!result.success) {
console.error(`Build failed for ${input.dirName}:`);
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
await $`cp ${tmpOutfile} ${input.outfile} && chmod 755 ${input.outfile}`;
await $`rm -rf ${tmpDir}`;
}
for (const item of targets) {
// npm treats "win32" specially in os field, but for package naming use "windows"
const displayOs = item.os === "win32" ? "windows" : item.os;
@@ -254,7 +108,12 @@ for (const item of targets) {
const outfile = join(outDir, binaryName);
await buildCompiledBinary({ bunTarget, dirName, outfile });
try {
await compileCliBinary({ bunTarget, outfile });
} catch (error) {
console.error(`Build failed for ${dirName}:`, error);
process.exit(1);
}
// Smoke test: only run on current platform
if (item.os === process.platform && item.arch === process.arch) {
@@ -301,7 +160,19 @@ for (const item of targets) {
console.log(` Passed: hub core ${status.coreVersion}`);
} finally {
await $`${outfile} hub stop`.env(smokeEnv).quiet().nothrow();
rmSync(smokeHome, { recursive: true, force: true });
try {
rmSync(smokeHome, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
} catch (cleanupError) {
console.warn(
` Warning: failed to remove smoke test dir ${smokeHome}:`,
cleanupError,
);
}
}
} catch (e) {
console.error(` Smoke test FAILED for ${name}:`, e);
@@ -309,26 +180,6 @@ for (const item of targets) {
}
}
// Copy plugin sandbox bootstrap if it exists
const bootstrapSrc = join(
rootDir,
"sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
);
if (existsSync(bootstrapSrc)) {
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
mkdirSync(bootstrapDir, { recursive: true });
const content = readFileSync(bootstrapSrc);
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
}
if (existsSync(hubWebviewDist)) {
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
recursive: true,
});
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
}
// Generate platform package.json
await Bun.write(
join(cliDir, `dist/${dirName}/package.json`),
+171
View File
@@ -0,0 +1,171 @@
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { $ } from "bun";
import { buildDashboardRuntimeDirectory } from "./dashboard-runtime";
const cliDir = resolve(import.meta.dir, "..");
const rootDir = resolve(cliDir, "../..");
// Telemetry / OTEL environment variables that should be baked into the
// compiled binary at build time. Mirrors the list of secrets injected by the
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
// so the CLI ships with the production telemetry configuration without
// requiring the end user to set any env vars.
const BUILD_TIME_INLINED_ENV_VARS = [
"TELEMETRY_SERVICE_API_KEY",
"ERROR_SERVICE_API_KEY",
"OTEL_TELEMETRY_ENABLED",
"OTEL_LOGS_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_TRACES_EXPORTER",
"CLINE_TRACE_SAMPLE_PERCENT",
"CLINE_TRACE_RECORD_CONTENT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
] as const;
function buildInlinedEnvDefines(): Record<string, string> {
const defines: Record<string, string> = {};
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
}
return defines;
}
function findOpenTuiParserWorker(): string {
const localPath = resolve(
cliDir,
"node_modules/@opentui/core/parser.worker.js",
);
const rootPath = resolve(
rootDir,
"node_modules/@opentui/core/parser.worker.js",
);
const parserWorkerPath = existsSync(localPath) ? localPath : rootPath;
return realpathSync(parserWorkerPath);
}
export function readOpenTuiVersion(): string | undefined {
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
return pkg.dependencies?.["@opentui/core"];
}
/**
* Bun only has the host platform's OpenTUI native package by default, and a
* cross-compiled build cannot resolve the FFI layer without the target's.
* Installs every platform variant of the pinned version.
*/
export async function installOpenTuiNativeVariants(): Promise<void> {
const opentuiVersion = readOpenTuiVersion();
if (!opentuiVersion) {
return;
}
console.log(
`Installing all platform variants of @opentui/core@${opentuiVersion}...`,
);
await $`bun install --no-save --os="*" --cpu="*" @opentui/core@${opentuiVersion}`.cwd(
cliDir,
);
}
export interface CompileCliBinaryOptions {
bunTarget: Bun.Build.CompileTarget;
outfile: string;
/** Runtime flags baked into the executable, e.g. `--use-system-ca`. */
execArgv?: string[];
/**
* Read `.env` / `bunfig.toml` from the launch directory before the CLI
* starts. Bun's default; binaries launched in directories the user did not
* pick (an SSH home, a GUI app's cwd) should turn this off.
*/
autoloadLaunchDirectoryConfig?: boolean;
}
/** Compiles the CLI entrypoint into one self-contained executable. */
export async function compileCliBinary(
input: CompileCliBinaryOptions,
): Promise<void> {
const pluginRuntime = join(rootDir, "sdk/packages/core/dist/plugin-runtime");
const dashboardRuntime = await buildDashboardRuntimeDirectory();
const parserWorker = findOpenTuiParserWorker();
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
const parserWorkerPath = relative(rootDir, parserWorker).replaceAll(
"\\",
"/",
);
// Build to /tmp first so Bun's temp-file rename stays on one filesystem
// layer in containerized environments (virtiofs, overlayfs).
const entrypoint = join(cliDir, "src/index.ts");
const scratchRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const tmpDir = mkdtempSync(
join(scratchRoot, `cline-build-${input.bunTarget}-`),
);
const tmpOutfile = join(
tmpDir,
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
);
const previousCwd = process.cwd();
process.chdir(scratchRoot);
let result: Awaited<ReturnType<typeof Bun.build>>;
try {
const autoload = input.autoloadLaunchDirectoryConfig !== false;
result = await Bun.build({
entrypoints: [entrypoint, parserWorker],
root: rootDir,
splitting: true,
compile: {
target: input.bunTarget,
outfile: tmpOutfile,
assets: [pluginRuntime, dashboardRuntime],
// Plugins import SDK packages from the runtime tree; let Bun resolve
// their package exports without falling back to transpiling the SDK.
autoloadPackageJson: true,
...(input.execArgv?.length ? { execArgv: input.execArgv } : {}),
...(autoload ? {} : { autoloadDotenv: false, autoloadBunfig: false }),
},
minify: true,
external: ["@anthropic-ai/vertex-sdk"],
define: {
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
// compile.assets keeps each directory tree under its basename.
__CLINE_PLUGIN_RUNTIME_DIR__: JSON.stringify(
`${bunfsRoot}plugin-runtime`,
),
__CLINE_DASHBOARD_RUNTIME_DIR__: JSON.stringify(`${bunfsRoot}webview`),
// Inline telemetry/OTEL env vars at build time so the compiled
// binary ships with production telemetry configuration baked in.
...buildInlinedEnvDefines(),
},
throw: false,
});
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
throw new Error(`CLI build failed for ${input.bunTarget}`);
}
mkdirSync(dirname(input.outfile), { recursive: true });
copyFileSync(tmpOutfile, input.outfile);
if (process.platform !== "win32") {
chmodSync(input.outfile, 0o755);
}
} finally {
process.chdir(previousCwd);
rmSync(tmpDir, { recursive: true, force: true });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { $ } from "bun";
let directory: Promise<string> | undefined;
/** Build once per platform-build invocation; the compiler embeds this tree unchanged. */
export function buildDashboardRuntimeDirectory(): Promise<string> {
directory ??= buildDirectory();
return directory;
}
async function buildDirectory(): Promise<string> {
const root = resolve(import.meta.dir, "../../..");
await $`bun -F @cline/cline-hub build:webview`.cwd(root).quiet();
const directory = join(root, "apps/cline-hub/dist/webview");
if (!existsSync(join(directory, "index.html")))
throw new Error("Dashboard build produced no index.html");
return directory;
}
+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" },
+37 -3
View File
@@ -8,7 +8,7 @@ import {
getPluginDisplayName,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginTools,
listPluginToolsWithDiagnostics,
type RuleConfig,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
@@ -372,10 +372,16 @@ async function runToolsConfigCommand(
availabilityContext?: BuiltinToolAvailabilityContext,
): Promise<number> {
const tools = getToolCatalog(availabilityContext);
const pluginTools = await listPluginTools({
const pluginReport = await listPluginToolsWithDiagnostics({
workspacePath: cwd,
cwd,
});
const pluginTools = pluginReport.tools;
// A plugin that fails to import or set up contributes no tools; list it
// anyway so the failure is visible instead of its tools just vanishing.
const failedPlugins = pluginReport.plugins.filter(
(plugin) => plugin.state === "failed",
);
if (outputMode === "json") {
process.stdout.write(
@@ -390,11 +396,28 @@ async function runToolsConfigCommand(
enabled: tool.enabled,
description: tool.description,
})),
...failedPlugins.map((plugin) => ({
type: "plugin_failure" as const,
pluginName: plugin.pluginName,
path: plugin.path,
state: plugin.state,
enabled: false,
error: plugin.lastError
? {
phase: plugin.lastError.phase,
message: plugin.lastError.message,
}
: undefined,
})),
]),
);
return 0;
}
if (tools.length === 0 && pluginTools.length === 0) {
if (
tools.length === 0 &&
pluginTools.length === 0 &&
failedPlugins.length === 0
) {
io.writeln("No tools found.");
return 0;
}
@@ -417,6 +440,17 @@ async function runToolsConfigCommand(
);
}
}
if (failedPlugins.length > 0) {
io.writeln();
io.writeln("Failed plugins (their tools are unavailable):");
for (const plugin of failedPlugins) {
const phase = plugin.lastError?.phase ?? "load";
const message = plugin.lastError?.message ?? "unknown error";
io.writeln(
` ${plugin.pluginName} [failed during ${phase}] ${message} (${plugin.path})`,
);
}
}
return 0;
}
+1 -44
View File
@@ -1,5 +1,5 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { arch, platform, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
@@ -20,7 +20,6 @@ const ENV_KEYS = [
"PUBLIC_URL",
"ROOM_SECRET",
"CLINE_HUB_WEBVIEW_DIST_DIR",
"CLINE_WRAPPER_PATH",
] as const;
const originalEnv = Object.fromEntries(
@@ -149,48 +148,6 @@ describe("runDashboardCommand", () => {
expect(openUrl).not.toHaveBeenCalled();
});
it("finds webview assets from the published wrapper package layout", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
const platformName = platform() === "win32" ? "windows" : platform();
const webviewDistDir = join(
root,
"node_modules",
"cline",
"node_modules",
"@cline",
`cli-${platformName}-${arch()}`,
"cline-hub",
"webview",
);
mkdirSync(join(wrapperPath, ".."), { recursive: true });
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_WRAPPER_PATH = wrapperPath;
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
let observedWebviewDistDir: string | undefined;
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => {
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
return {
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
};
},
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(observedWebviewDistDir).toBe(webviewDistDir);
});
it("settles shutdown when server stop rejects", async () => {
const shutdown = waitForProcessShutdown({
listenUrl: "http://127.0.0.1:8787/",
+5 -32
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveEmbeddedDashboard } from "../utils/embedded-dashboard";
import { configureSandboxEnvironment } from "../utils/helpers";
import open from "../utils/open";
import { c } from "../utils/output";
@@ -97,49 +97,22 @@ function resolveDefaultWebviewDistDir(): string | undefined {
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
return undefined;
}
const embedded = resolveEmbeddedDashboard();
if (embedded) {
return embedded;
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
const candidates = [
...resolveInstalledPlatformPackageWebviewCandidates(),
// Source checkout: apps/cli/src/commands/dashboard.ts
join(moduleDir, "../../../cline-hub/dist/webview"),
// Node bundle: apps/cli/dist/index.js
join(moduleDir, "cline-hub/webview"),
// Compiled platform package: apps/cli/dist/<platform>/bin/cline
join(dirname(process.execPath), "../cline-hub/webview"),
];
return candidates.find((candidate) => existsSync(candidate));
}
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
const packageName = resolvePlatformPackageName();
const starts = [
process.env.CLINE_WRAPPER_PATH
? dirname(process.env.CLINE_WRAPPER_PATH)
: undefined,
dirname(process.execPath),
].filter((value): value is string => !!value?.trim());
const candidates: string[] = [];
for (const start of starts) {
let current = start;
for (;;) {
candidates.push(
join(current, "node_modules", packageName, "cline-hub/webview"),
);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
}
return candidates;
}
function resolvePlatformPackageName(): string {
const platformName = platform() === "win32" ? "windows" : platform();
return `@cline/cli-${platformName}-${arch()}`;
}
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
return await startClineHubDashboardServer();
+12 -7
View File
@@ -387,7 +387,16 @@ describe("runDoctorCommand", () => {
});
});
it("doctor --fix kills stale code sidecar processes", async () => {
it.each([
[
"/src-tauri/bin/code-sidecar",
"60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
],
[
"/bin/desktop-backend/index.js",
"60123 /Applications/Cline.app/Contents/MacOS/cline-cli run --no-env-file /Applications/Cline.app/Contents/Resources/bin/desktop-backend/index.js\n",
],
])("doctor --fix kills stale desktop backend processes matching %s", async (pattern, pgrepOutput) => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue(undefined);
mockProbeHubServer.mockResolvedValue(undefined);
@@ -397,13 +406,9 @@ describe("runDoctorCommand", () => {
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
args[2] === pattern
) {
return {
status: 0,
stdout:
"60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
};
return { status: 0, stdout: pgrepOutput };
}
return { status: 1, stdout: "" };
});
+3
View File
@@ -259,10 +259,13 @@ function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
"/apps/examples/desktop-app/dist/sidecar/index.js",
// The packaged desktop backend runs on the bundled Cline CLI.
"/bin/desktop-backend/index.js",
// Keep the pre-example-reorg paths so `doctor --fix` can still clean up
// stale sidecars that were launched from older checkouts.
"/apps/code/sidecar/index.ts",
"/apps/code/dist/sidecar/index.js",
// Backend executable of earlier desktop releases.
"/src-tauri/bin/code-sidecar",
"/Resources/code-sidecar",
" code-sidecar",
+203
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockEnsureLoginShellPath,
mockLocalHubHasNoActiveSessions,
mockProbeHubServer,
mockReadHubDiscovery,
@@ -13,6 +14,7 @@ const {
} = vi.hoisted(() => ({
mockClearHubDiscovery: vi.fn(),
mockEnsureDetachedHubServer: vi.fn(),
mockEnsureLoginShellPath: vi.fn(async () => undefined),
mockLocalHubHasNoActiveSessions: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
@@ -31,6 +33,7 @@ const {
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
ensureLoginShellPath: mockEnsureLoginShellPath,
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
@@ -48,6 +51,7 @@ const originalBuildEnv = process.env.CLINE_BUILD_ENV;
describe("createHubCommand", () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
@@ -258,6 +262,28 @@ describe("createHubCommand", () => {
);
});
it("un-drains and aborts upgrade when authenticated shutdown fails", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
mockStopLocalHubServerGracefully.mockResolvedValue(false);
const { cmd, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(exitCode()).toBe(1);
expect(errors).toEqual(["Hub shutdown failed; upgrade aborted."]);
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub upgrade aborted",
{ off: true },
);
});
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
@@ -307,4 +333,181 @@ describe("createHubCommand", () => {
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
it("does not signal a stale discovery PID when authenticated shutdown fails", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
pid: 50174,
authToken: "expired-token",
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
const kill = vi.spyOn(process, "kill").mockReturnValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["stop"], { from: "user" });
expect(kill).toHaveBeenCalledExactlyOnceWith(50174, 0);
expect(exitCode()).toBe(1);
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
expect(JSON.parse(output[0] || "")).toEqual({ stopped: false });
});
it("succeeds when the hub has already exited", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
vi.spyOn(process, "kill").mockImplementation(() => {
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
});
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode()).toBe(0);
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
expect(mockClearHubDiscovery).toHaveBeenCalled();
});
it.each([
Object.assign(new Error("permission denied"), { code: "EACCES" }),
new Error("Invalid JSON in Hub discovery record"),
new Error("Invalid Hub discovery record"),
])("fails stop when discovery cannot be read: %s", async (error) => {
mockReadHubDiscovery.mockRejectedValue(error);
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode()).toBe(1);
expect(output).toEqual([]);
expect(errors).toEqual([error.message]);
expect(mockReadHubDiscovery).toHaveBeenCalledWith(expect.any(String), {
onError: "throw",
});
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
});
it("succeeds when there is no discovery record", async () => {
mockReadHubDiscovery.mockResolvedValue(undefined);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode()).toBe(0);
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
});
it("does not mistake an inaccessible process for an exited hub", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
vi.spyOn(process, "kill").mockImplementation(() => {
throw Object.assign(new Error("not permitted"), { code: "EPERM" });
});
const { cmd, exitCode } = createCommand();
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode()).toBe(1);
expect(mockClearHubDiscovery).not.toHaveBeenCalled();
});
it("replaces a hub that exits before upgrade can shut it down", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
pid: 50174,
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(false);
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockEnsureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
});
vi.spyOn(process, "kill").mockImplementation(() => {
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
});
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(exitCode()).toBe(0);
expect(errors).toEqual([]);
expect(mockClearHubDiscovery).toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
expect(JSON.parse(output[0] || "")).toEqual({
upgraded: true,
url: "ws://127.0.0.1:25466/hub",
});
});
it("prints the hub URL by default from ensure", async () => {
mockEnsureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["ensure"], { from: "user" });
expect(exitCode()).toBe(0);
expect(output).toEqual(["ws://127.0.0.1:25463/hub"]);
expect(mockEnsureLoginShellPath).not.toHaveBeenCalled();
});
it("starts a dedicated hub for programmatic clients with ensure --json", async () => {
const originalDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
mockEnsureDetachedHubServer.mockImplementation(async () => {
expect(process.env.CLINE_HUB_DISCOVERY_PATH).toBe(
"/home/dev/.cline/data/remote/owner.json",
);
return { url: "ws://127.0.0.1:41000/hub", authToken: "remote-token" };
});
try {
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(
[
"ensure",
"--cwd",
"/home/dev",
"--discovery-path",
"/home/dev/.cline/data/remote/owner.json",
"--host",
"127.0.0.1",
"--port",
"0",
"--pathname",
"/hub",
"--json",
"--allow-port-fallback",
"--no-connectors",
"--login-shell-path",
],
{ from: "user" },
);
expect(errors).toEqual([]);
expect(exitCode()).toBe(0);
expect(mockEnsureDetachedHubServer).toHaveBeenCalledWith("/home/dev", {
host: "127.0.0.1",
port: 0,
pathname: "/hub",
allowPortFallback: true,
manageConnectors: false,
beforeSpawn: expect.any(Function),
});
// PATH resolution is deferred until a new daemon is actually spawned.
expect(mockEnsureLoginShellPath).not.toHaveBeenCalled();
await mockEnsureDetachedHubServer.mock.calls[0]?.[1]?.beforeSpawn?.();
expect(mockEnsureLoginShellPath).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toEqual({
url: "ws://127.0.0.1:41000/hub",
authToken: "remote-token",
cwd: "/home/dev",
platform: process.platform,
arch: process.arch,
});
} finally {
if (originalDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalDiscoveryPath;
}
}
});
});
+158 -40
View File
@@ -1,7 +1,9 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
ensureLoginShellPath,
localHubHasNoActiveSessions,
NodeHubClient,
probeHubServer,
readHubDiscovery,
requestHubDrain,
@@ -9,7 +11,11 @@ import {
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import {
formatUptime,
type PluginStatusRecord,
resolveClineBuildEnv,
} from "@cline/shared";
import { Command, InvalidArgumentError } from "commander";
import { version as cliVersion } from "../../package.json";
import { createDashboardCommand } from "./dashboard-command";
@@ -24,33 +30,52 @@ interface HubOptions {
host?: string;
port?: number;
pathname?: string;
discoveryPath?: string;
}
interface HubEnsureCommandOptions extends Partial<HubOptions> {
json?: boolean;
allowPortFallback?: boolean;
connectors?: boolean;
loginShellPath?: boolean;
}
const HUB_DISCOVERY_PATH_ENV = "CLINE_HUB_DISCOVERY_PATH";
function addHubOptions(command: Command, defaultCwd?: string): Command {
return command
.option("--cwd <path>", "Workspace root", defaultCwd)
.option("--host <host>", "Hub host")
.option("--port <port>", "Hub port", (value) => Number.parseInt(value, 10))
.option("--pathname <path>", "Hub websocket path");
.option("--pathname <path>", "Hub websocket path")
.option(
"--discovery-path <path>",
"Use a dedicated hub discovery record instead of the default one",
);
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
async function stopHubServer(): Promise<boolean> {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully(owner)) {
await clearHubDiscovery(owner.discoveryPath);
return true;
const discovery = await readHubDiscovery(owner.discoveryPath, {
onError: "throw",
});
if (!discovery) {
return true; // Already stopped: cleanup is idempotent.
}
const pid = discovery?.pid;
if (pid) {
if (!(await stopLocalHubServerGracefully(owner))) {
// A stale PID must never receive a termination signal. A zero-signal
// existence check can only establish that the recorded process exited;
// a live/reused PID or a permission error cannot prove shutdown.
if (!discovery.pid) return false;
try {
process.kill(pid, "SIGTERM");
} catch {
// best effort
process.kill(discovery.pid, 0);
return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false;
}
}
await clearHubDiscovery(owner.discoveryPath);
return !!pid;
return true;
}
function formatHubUptimeFromStartedAt(
@@ -106,6 +131,19 @@ export function createHubCommand(
.exitOverride()
// Subcommands may use the same flag names for different services.
.enablePositionalOptions()
.hook("preAction", (_hub, actionCommand) => {
// Every subcommand resolves its owner record from this env var, so a
// dedicated record (SSH remote hubs) is honored by ensure, status,
// and stop alike, and by the daemon the ensure spawns. Accepted
// before or after the subcommand.
const discoveryPath = (
actionCommand.opts<{ discoveryPath?: string }>().discoveryPath ??
hub.opts<{ discoveryPath?: string }>().discoveryPath
)?.trim();
if (discoveryPath) {
process.env[HUB_DISCOVERY_PATH_ENV] = discoveryPath;
}
})
.hook("postAction", () => {
setExitCode(actionExitCode);
});
@@ -115,29 +153,58 @@ export function createHubCommand(
...options,
});
hub.command("ensure").action(
action(async (options: Partial<HubOptions>) => {
const opts = resolveOptions(options);
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(url);
}),
);
hub.command("start").action(
action(async (options: Partial<HubOptions>) => {
const opts = resolveOptions(options);
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(url);
}),
);
const ensureAction = action(async (cmdOptions: HubEnsureCommandOptions) => {
const opts = resolveOptions(cmdOptions);
const result = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
...(cmdOptions.allowPortFallback ? { allowPortFallback: true } : {}),
...(cmdOptions.connectors === false ? { manageConnectors: false } : {}),
// Launchers without a login shell (GUI apps, non-interactive SSH)
// would otherwise hand the daemon a minimal PATH, so agent tools
// installed from shell profiles could not be found.
...(cmdOptions.loginShellPath
? {
beforeSpawn: async () => {
await ensureLoginShellPath();
},
}
: {}),
});
if (cmdOptions.json) {
io.writeln(
JSON.stringify({
url: result.url,
authToken: result.authToken,
cwd: opts.cwd,
platform: process.platform,
arch: process.arch,
}),
);
return;
}
io.writeln(result.url);
});
for (const name of ["ensure", "start"]) {
hub
.command(name)
.description("Start the hub daemon unless a compatible one is running")
.option(
"--json",
"Print the hub URL and auth token as JSON for programmatic clients",
)
.option(
"--allow-port-fallback",
"Use an OS-assigned port when the requested one is unavailable",
)
.option("--no-connectors", "Do not supervise account-wide connectors")
.option(
"--login-shell-path",
"Resolve PATH from the user's login shell before starting the hub",
)
.action(ensureAction);
}
hub.command("status").action(
action(async () => {
@@ -163,11 +230,60 @@ export function createHubCommand(
}),
);
hub
.command("plugins")
.description("Show the status and last error of each plugin the hub loaded")
.option(
"--reload <plugin>",
"Re-import one plugin (name or path) without restarting the hub",
)
.action(
action(async (cmdOptions: { reload?: string }) => {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
io.writeErr("No hub is running.");
fail();
return;
}
const client = new NodeHubClient({
url: discovery.url,
...(discovery.authToken ? { authToken: discovery.authToken } : {}),
clientType: "cli-hub-plugins",
displayName: "hub plugins",
});
try {
await client.connect();
const reply = cmdOptions.reload
? await client.command("plugins.reload", {
plugin: cmdOptions.reload,
})
: await client.command("plugins.status");
if (!reply.ok) {
io.writeErr(
reply.error?.message ?? "The hub rejected the plugins request.",
);
fail();
return;
}
const plugins = (reply.payload?.plugins ??
[]) as PluginStatusRecord[];
io.writeln(JSON.stringify({ plugins }));
} finally {
try {
client.close();
} catch {
// One-shot connection; a failed close changes nothing.
}
}
}),
);
hub.command("stop").action(
action(async (options: Partial<HubOptions>) => {
const opts = resolveOptions(options);
const stopped = await stopHubServer(opts.cwd);
action(async () => {
const stopped = await stopHubServer();
io.writeln(JSON.stringify({ stopped }));
if (!stopped) fail();
}),
);
@@ -263,7 +379,9 @@ export function createHubCommand(
fail();
return;
}
await stopHubServer(opts.cwd);
if (!(await stopHubServer())) {
throw new Error("Hub shutdown failed; upgrade aborted.");
}
} catch (error) {
await undrain();
throw error;
+18 -7
View File
@@ -1,15 +1,20 @@
import { SKILLS_CLI_PACKAGE } from "@cline/core";
import { describe, expect, it } from "vitest";
import { buildSkillsArgs } from "./skill";
describe("buildSkillsArgs", () => {
it("runs the skills package through npx with -y", () => {
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
expect(buildSkillsArgs(["list"])).toEqual([
"-y",
SKILLS_CLI_PACKAGE,
"list",
]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
"owner/repo",
"--agent",
@@ -23,7 +28,7 @@ describe("buildSkillsArgs", () => {
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"remove",
"my-skill",
"--agent",
@@ -48,7 +53,7 @@ describe("buildSkillsArgs", () => {
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"--agent",
"cursor",
"add",
@@ -56,7 +61,13 @@ describe("buildSkillsArgs", () => {
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
).toEqual([
"-y",
SKILLS_CLI_PACKAGE,
"--agent=cursor",
"remove",
"my-skill",
]);
});
it("does not scope non-install subcommands to cline", () => {
@@ -67,7 +78,7 @@ describe("buildSkillsArgs", () => {
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"remove",
"--agent",
"cline",
@@ -83,6 +94,6 @@ describe("buildSkillsArgs", () => {
});
it("forwards an empty arg list unchanged", () => {
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
expect(buildSkillsArgs([])).toEqual(["-y", SKILLS_CLI_PACKAGE]);
});
});
+4 -3
View File
@@ -1,4 +1,5 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { SKILLS_CLI_PACKAGE } from "@cline/core";
export interface SkillCommandIo {
writeln: (text?: string) => void;
@@ -7,9 +8,9 @@ export interface SkillCommandIo {
// `cline skill` is a thin wrapper around the open skills CLI
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
// don't need a separate global install. Pin the version here if we ever need to
// lock behavior to a known-good release.
const SKILLS_PACKAGE = "skills@latest";
// don't need a separate global install. The release is pinned in @cline/core
// (sdk/packages/core/package.json) and bumped by Dependabot.
const SKILLS_PACKAGE = SKILLS_CLI_PACKAGE;
// Subcommands that write skill files into an agent's skills directory. For a
// `cline skill` command we default these to Cline unless the user picked their
+2
View File
@@ -7,6 +7,7 @@ import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "@cline/core";
import { hasActiveHubSessions } from "@cline/core/hub";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { c, writeErr, writeln } from "../utils/output";
@@ -381,6 +382,7 @@ async function otherCliClientsAttached(): Promise<boolean> {
undefined,
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
);
if (hasActiveHubSessions(sessions.payload)) return true;
const sessionRecords =
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
.sessions ?? [];
+6 -4
View File
@@ -1071,10 +1071,7 @@ class DiscordConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -1097,6 +1094,11 @@ class DiscordConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
applicationId: options.applicationId,
+6 -4
View File
@@ -577,10 +577,7 @@ class GoogleChatConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -602,6 +599,11 @@ class GoogleChatConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
pid: process.pid,
+6 -4
View File
@@ -600,10 +600,7 @@ class LinearConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -625,6 +622,11 @@ class LinearConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
pid: process.pid,
+6 -4
View File
@@ -874,10 +874,7 @@ class SlackConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -899,6 +896,11 @@ class SlackConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
claimId: claim.claimId,
userName: options.userName,
+6 -4
View File
@@ -804,10 +804,7 @@ class TelegramConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -829,6 +826,11 @@ class TelegramConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
botUsername: options.botUsername,
botId: readTelegramBotId(options.botToken),
+6 -4
View File
@@ -569,10 +569,7 @@ class WhatsAppConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -597,6 +594,11 @@ class WhatsAppConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
instanceKey,
userName: options.userName,
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
import { enqueueThreadTurn } from "./chat-runtime";
import { handleConnectorUserTurn } from "./connector-host";
@@ -178,6 +179,64 @@ describe("handleConnectorUserTurn", () => {
}
});
it.each([
"discord",
"gchat",
"linear",
"slack",
"telegram",
"whatsapp",
])("routes %s plugin commands with each thread's current session and workspace", async (transport) => {
const dir = mkdtempSync(join(tmpdir(), "connector-command-context-"));
tempDirs.push(dir);
const run = vi.fn(async () => ({ reply: "handled" }));
const { host } = await createWorkspaceChatCommandHost({
cwd: dir,
commands: {
run,
list: vi
.fn()
.mockResolvedValue({ status: "ready", commands: [{ name: "echo" }] }),
},
});
await Promise.all(
["one", "two"].map(async (id) => {
const workspace = join(dir, id);
const { thread, posts } = createThread({
sessionId: id,
cwd: workspace,
workspaceRoot: workspace,
});
await handleConnectorUserTurn({
thread: thread as never,
client: {} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
clientId: "client",
explicitSystemPrompt: undefined,
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport,
botUserName: "cline",
requestStop: vi.fn(),
bindingsPath: join(dir, `${id}.json`),
systemRules: "rules",
errorLabel: transport,
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
text: "/echo hello",
chatCommandHost: host,
});
expect(run).toHaveBeenCalledWith({
sessionId: id,
workspacePath: workspace,
prompt: "/echo hello",
});
expect(messageText(posts.at(-1))).toContain("handled");
}),
);
});
it("posts no greeting when the adapter configures none", async () => {
// Slack deliberately configures no first-contact message: the greeting is
// gated on per-thread state, so a restart or a cleared history replayed it
@@ -628,6 +628,7 @@ export async function handleConnectorUserTurn<
input.forceDisableTools,
);
return {
sessionId: effectiveCurrent.sessionId,
enableTools:
effectiveCurrent.enableTools ?? input.baseStartRequest.enableTools,
autoApproveTools:
+21
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import { setPluginSandboxLauncher } from "@cline/core";
import {
buildTelemetrySelfcheckReport,
claimHubDaemonProcess,
claimSupervisedConnectorProcess,
createClineTelemetryServiceConfig,
disableCurrentDirectoryExecutableSearch,
disposeAll,
ensureLoopbackProxyBypass,
@@ -30,8 +33,26 @@ disableCurrentDirectoryExecutableSearch();
// Before any personality probes the local hub over 127.0.0.1.
ensureLoopbackProxyBypass();
if (Bun.isStandaloneExecutable) {
setPluginSandboxLauncher({
command: process.execPath,
argsPrefix: ["--plugin-sandbox"],
});
}
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (process.argv.includes("--plugin-sandbox")) {
// Boot as the compiled host so the child mounts the same read-only resources.
const bootstrap = process.argv[process.argv.indexOf("--plugin-sandbox") + 1];
if (!bootstrap) throw new Error("Missing plugin sandbox bootstrap path");
await import(bootstrap);
} else if (process.argv.includes("--telemetry-selfcheck")) {
// Inspect the config embedded in this executable without starting a hub,
// loading user settings, or initializing telemetry exporters.
process.stdout.write(
`${JSON.stringify(buildTelemetrySelfcheckReport(createClineTelemetryServiceConfig()))}\n`,
);
} else if (claimHubDaemonProcess()) {
// Claim rather than read: the sentinel is consumed here so the processes a
// daemon-hosted session spawns do not inherit it and try to become daemons.
+23
View File
@@ -406,6 +406,29 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
}, 30_000);
it("does not capture an activation event for hub commands", async () => {
// The desktop app and the SSH remote flow run `cline hub ensure` on
// every launch/connect; those must not count as CLI activations.
process.argv = ["bun", "src/index.ts", "hub", "dashboard", "--no-open"];
const { runCli } = await import("./main");
await runCli();
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledTimes(1);
expect(telemetryMocks.captureCliExtensionActivated).not.toHaveBeenCalled();
});
it("captures an activation event for regular commands", async () => {
process.argv = ["bun", "src/index.ts", "history", "--json"];
const { runCli } = await import("./main");
await runCli();
expect(telemetryMocks.captureCliExtensionActivated).toHaveBeenCalledTimes(
1,
);
});
it.each([
"connect",
"mcp",
+6 -1
View File
@@ -166,7 +166,12 @@ export async function runCli(): Promise<void> {
// has been applied, so the telemetry singleton's persisted distinct-id
// (and any other storage it touches) lands under the user-selected
// `--config <dir>` rather than the default home/config location.
captureCliExtensionActivated();
// `cline hub ...` is plumbing other clients invoke (the desktop app on
// every launch, the SSH remote flow on every connect), not a user
// activation, so it must not count as one.
if (cliArgs[0] !== "hub") {
captureCliExtensionActivated();
}
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
@@ -144,6 +144,7 @@ function makeManager() {
});
return {
start,
pluginCommands: { list: vi.fn(), run: vi.fn() },
stop: vi.fn(async () => {}),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
@@ -242,6 +243,65 @@ describe("createInteractiveSessionRuntime", () => {
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
});
it.each([
undefined,
"resumed-session",
])("shares initialization between plugin catalog discovery and session startup (%s)", async (resumeSessionId) => {
const manager = makeManager();
manager.get.mockResolvedValue(undefined);
manager.getAccumulatedUsage.mockResolvedValue(undefined);
const runtime = await makeRuntime(manager, { resumeSessionId });
if (resumeSessionId)
loadInteractiveResumeMessagesMock.mockResolvedValue([
{ role: "user", content: "resume" },
]);
const gate = deferred<typeof manager>();
createCliCoreMock.mockReturnValue(gate.promise);
const commands = runtime.getPluginCommandsApi();
const ready = runtime.ensureReady();
expect(createCliCoreMock).toHaveBeenCalledTimes(1);
gate.resolve(manager);
expect(await commands).toBe(manager.pluginCommands);
await ready;
expect(manager.start).toHaveBeenCalledTimes(1);
expect(subscribeToAgentEventsMock).toHaveBeenCalledTimes(1);
await runtime.cleanup();
expect(manager.dispose).toHaveBeenCalledTimes(1);
});
it("retries failed shared initialization", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
createCliCoreMock.mockRejectedValueOnce(new Error("startup failed"));
await expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"startup failed",
);
expect(await runtime.getPluginCommandsApi()).toBe(manager.pluginCommands);
expect(createCliCoreMock).toHaveBeenCalledTimes(2);
await runtime.cleanup();
});
it("awaits and disposes plugin-only initialization during cleanup", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
const gate = deferred<typeof manager>();
createCliCoreMock.mockReturnValue(gate.promise);
const commands = expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"shutdown requested",
);
const cleanup = runtime.cleanup();
gate.resolve(manager);
await commands;
await cleanup;
expect(manager.dispose).toHaveBeenCalledExactlyOnceWith(
"cli_interactive_startup_cancelled",
);
await expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"shutdown requested",
);
expect(createCliCoreMock).toHaveBeenCalledTimes(1);
});
it("manual compact updates the active session sidecar without restarting", async () => {
const sessionId = "sess-active";
const messages = [
@@ -518,6 +578,7 @@ describe("createInteractiveSessionRuntime", () => {
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const onActiveSessionChange = vi.fn();
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
@@ -532,11 +593,13 @@ describe("createInteractiveSessionRuntime", () => {
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
onActiveSessionChange,
});
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("session-1");
expect(onActiveSessionChange).toHaveBeenCalledTimes(1);
await runtime.resetForNewSession();
@@ -544,11 +607,13 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("");
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
expect(onActiveSessionChange).toHaveBeenCalledTimes(2);
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
expect(onActiveSessionChange).toHaveBeenCalledTimes(3);
});
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
@@ -28,6 +28,7 @@ import type {
} from "../../utils/chat-commands";
import { createRuntimeHooks } from "../../utils/hooks";
import { setActiveCliSession } from "../../utils/output";
import { formatPluginIssueWarnings } from "../../utils/plugin-issues";
import { loadInteractiveResumeMessages } from "../../utils/resume";
import type { Config } from "../../utils/types";
import { markAbortInProgress } from "../active-runtime";
@@ -110,8 +111,10 @@ export function createInteractiveSessionRuntime(input: {
onTeamEvent: (event: TeamEvent) => void;
onPendingPrompts: (event: PendingPromptSnapshot) => void;
onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent) => void;
onActiveSessionChange?: () => void;
}) {
let sessionManager: CliCore | undefined;
let sessionManagerPromise: Promise<CliCore> | undefined;
let runtimeHooks: RuntimeHooks | undefined;
let unsubscribeAgent = () => {};
let unsubscribePendingPrompts = () => {};
@@ -133,6 +136,7 @@ export function createInteractiveSessionRuntime(input: {
const clearActiveSession = (): void => {
activeSessionId = "";
setActiveCliSession(undefined);
input.onActiveSessionChange?.();
};
const applyStartedSession = (started: StartedSession): void => {
@@ -140,12 +144,18 @@ export function createInteractiveSessionRuntime(input: {
manifest: started.manifest,
});
activeSessionId = started.sessionId;
input.onActiveSessionChange?.();
for (const message of formatPluginIssueWarnings(started.pluginIssues)) {
input.onAgentEvent({
type: "notice",
noticeType: "status",
displayRole: "status",
message,
});
}
};
const ensureSessionManager = async (): Promise<CliCore> => {
if (sessionManager) {
return sessionManager;
}
const createSessionManager = async (): Promise<CliCore> => {
const manager = await createCliCore({
// Interactive startup must never wait for a detached hub daemon to boot.
// `auto` uses an already-compatible hub when one is immediately available,
@@ -196,6 +206,20 @@ export function createInteractiveSessionRuntime(input: {
return manager;
};
const ensureSessionManager = (): Promise<CliCore> => {
if (sessionManager) return Promise.resolve(sessionManager);
if (shutdownRequested)
return Promise.reject(
new Error("interactive runtime shutdown requested"),
);
if (!sessionManagerPromise) {
sessionManagerPromise = createSessionManager().finally(() => {
sessionManagerPromise = undefined;
});
}
return sessionManagerPromise;
};
const buildSessionConfig = (): Config => {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
@@ -876,6 +900,7 @@ export function createInteractiveSessionRuntime(input: {
shutdownRequested = true;
let exitSummary: InteractiveExitSummary | undefined;
try {
await sessionManagerPromise?.catch(() => {});
await startupPromise?.catch(() => {});
await missingSessionRecoveryPromise?.catch(() => {});
} finally {
@@ -890,7 +915,11 @@ export function createInteractiveSessionRuntime(input: {
await stopCurrentSession();
} finally {
if (sessionManager) {
await sessionManager.dispose("cli_interactive_shutdown");
try {
await sessionManager.dispose("cli_interactive_shutdown");
} finally {
sessionManager = undefined;
}
}
}
return exitSummary;
@@ -899,6 +928,8 @@ export function createInteractiveSessionRuntime(input: {
};
return {
getPluginCommandsApi: async () =>
(await ensureSessionManager()).pluginCommands,
ensureReady,
listCoreSettings,
toggleCoreSettings,
+5
View File
@@ -32,7 +32,9 @@ import {
setActiveCliSession,
writeErr,
writeln,
writeWarn,
} from "../utils/output";
import { formatPluginIssueWarnings } from "../utils/plugin-issues";
import type { Config } from "../utils/types";
import { shouldShowCliUsageCost } from "../utils/usage-cost-display";
import { setActiveRuntimeAbort } from "./active-runtime";
@@ -307,6 +309,9 @@ export async function runAgent(
});
activeSessionId = started.sessionId;
for (const warning of formatPluginIssueWarnings(started.pluginIssues)) {
writeWarn(warning);
}
setActiveCliSession({
manifest: started.manifest,
});
+44 -59
View File
@@ -194,44 +194,49 @@ export async function runInteractive(
userInstructionService,
);
let interactiveChatCommandHost = chatCommandHost;
let pluginChatCommandHostLoaded = false;
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
let pluginHostPromise:
| ReturnType<typeof createWorkspaceChatCommandHost>
| undefined;
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
if (pluginChatCommandHostLoaded) {
return pluginChatSlashCommands;
}
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
})
.then(({ host, pluginSlashCommands, shutdown }) => {
interactiveChatCommandHost = host;
pluginChatCommandHostShutdown = shutdown;
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
name: cmd.name,
instructions: "",
description: cmd.description ?? "Plugin command",
}));
return pluginChatSlashCommands;
const ensurePluginHost = () => {
pluginHostPromise ??= sessionRuntime
.getPluginCommandsApi()
.then((commands) =>
createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
commands,
getSessionId: () => sessionRuntime.getActiveSessionId(),
}),
)
.then((host) => {
interactiveChatCommandHost = host.host;
return host;
})
.finally(() => {
pluginChatCommandHostLoaded = true;
pluginChatCommandHostPromise = undefined;
.catch((error) => {
pluginHostPromise = undefined;
throw error;
});
return await pluginChatCommandHostPromise;
return pluginHostPromise;
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
const shouldTryPluginChatCommands = (prompt: string): boolean => {
return prompt.trimStart().startsWith("/");
> => {
const host = await ensurePluginHost();
return (await host.listCommands()).map((command) => ({
...command,
description: command.description ?? "Plugin command",
instructions: "",
}));
};
// Session commands come from the active session's plugins, so a session
// change can change the catalog.
const activeSessionListeners = new Set<() => void>();
const subscribeAdditionalSlashCommands = (listener: () => void) => {
activeSessionListeners.add(listener);
return () => {
activeSessionListeners.delete(listener);
};
};
const enableChatCommands = true;
@@ -297,6 +302,9 @@ export async function runInteractive(
onPendingPromptSubmitted: (event) => {
uiEvents.emit("pending-prompt-submitted", event);
},
onActiveSessionChange: () => {
for (const listener of activeSessionListeners) listener();
},
});
const configDataLoader = createInteractiveConfigDataLoader({
config,
@@ -385,11 +393,6 @@ export async function runInteractive(
try {
exitSummary = await sessionRuntime.cleanup();
} finally {
await pluginChatCommandHostPromise?.catch(() => []);
await pluginChatCommandHostShutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
pluginChatCommandHostShutdown = undefined;
setActiveRuntimeAbort(undefined);
setActiveRuntimeCleanup(undefined);
}
@@ -525,6 +528,7 @@ export async function runInteractive(
initialRepoStatus,
workflowSlashCommands,
loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands,
loadWelcomeLine: async () =>
await resolveClineWelcomeLine({
config,
@@ -579,7 +583,8 @@ export async function runInteractive(
isRunning = true;
}
let chatCommandResult = await runInteractiveChatCommand({
if (input.trimStart().startsWith("/")) await ensurePluginHost();
const chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
@@ -594,27 +599,7 @@ export async function runInteractive(
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
if (
shouldTryPluginChatCommands(input) &&
!pluginChatCommandHostLoaded
) {
await ensurePluginChatCommandHost();
chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
host: interactiveChatCommandHost,
chatCommandState,
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
@@ -206,6 +206,13 @@ export function buildSlashCommandRegistry(input: {
});
}
for (const command of input.additionalSlashCommands ?? []) {
const entry = entryFromRuntimeCommand(command, "plugin");
if (entry) {
addEntry(byName, entry);
}
}
for (const command of input.workflowSlashCommands ?? []) {
const source =
command.kind === "skill" || command.kind === "workflow"
@@ -217,13 +224,6 @@ export function buildSlashCommandRegistry(input: {
}
}
for (const command of input.additionalSlashCommands ?? []) {
const entry = entryFromRuntimeCommand(command, "plugin");
if (entry) {
addEntry(byName, entry);
}
}
return {
entries: [...byName.values()],
byName,
+2 -4
View File
@@ -201,10 +201,8 @@ function formatToolParams(
case "skills": {
if (rawInput && typeof rawInput === "object" && "skill" in rawInput) {
const s = String((rawInput as { skill: unknown }).skill);
const args =
"args" in rawInput
? ` ${String((rawInput as { args: unknown }).args)}`
: "";
const rawArgs = (rawInput as { args?: unknown }).args;
const args = rawArgs == null || rawArgs === "" ? "" : ` ${rawArgs}`;
const full = `${s}${args}`;
return full.length > 70 ? `${full.slice(0, 70)}...` : full;
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSlashCommands } from "./use-slash-commands";
const reactMock = vi.hoisted(() => ({
cleanups: [] as Array<() => void>,
setCommands: vi.fn(),
}));
vi.mock("react", () => ({
useState: (initial: unknown) => [initial, reactMock.setCommands],
useMemo: (compute: () => unknown) => compute(),
useEffect: (effect: () => (() => void) | undefined) => {
const cleanup = effect();
if (cleanup) reactMock.cleanups.push(cleanup);
},
}));
const first = [{ name: "first", description: "First", instructions: "" }];
const next = [{ name: "next", description: "Next", instructions: "" }];
function useTestSlashCommands(
load: NonNullable<
Parameters<typeof useSlashCommands>[0]["loadAdditionalSlashCommands"]
>,
subscribe?: Parameters<
typeof useSlashCommands
>[0]["subscribeAdditionalSlashCommands"],
) {
useSlashCommands({
workflowSlashCommands: [],
loadAdditionalSlashCommands: load,
subscribeAdditionalSlashCommands: subscribe,
canFork: false,
});
}
function unmount() {
for (const cleanup of reactMock.cleanups.splice(0)) cleanup();
}
beforeEach(() => {
reactMock.setCommands.mockClear();
});
afterEach(() => {
unmount();
});
describe("plugin slash command catalog lifecycle", () => {
it("reloads when notified and keeps the last catalog when a reload fails", async () => {
let notify = () => {};
const stop = vi.fn();
const load = vi
.fn()
.mockResolvedValueOnce(first)
.mockRejectedValueOnce(new Error("hub unavailable"))
.mockResolvedValueOnce(next);
useTestSlashCommands(load, (listener) => {
notify = listener;
return stop;
});
await vi.waitFor(() =>
expect(reactMock.setCommands).toHaveBeenLastCalledWith(first),
);
notify();
await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2));
await Promise.resolve();
expect(reactMock.setCommands).toHaveBeenCalledTimes(1);
notify();
await vi.waitFor(() =>
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next),
);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
it("ignores an older load that resolves after a newer one", async () => {
let notify = () => {};
let resolveOlder!: (commands: typeof first) => void;
const load = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve;
}),
)
.mockResolvedValueOnce(next);
useTestSlashCommands(load, (listener) => {
notify = listener;
return () => {};
});
notify();
await vi.waitFor(() =>
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next),
);
resolveOlder(first);
await Promise.resolve();
expect(reactMock.setCommands).toHaveBeenCalledTimes(1);
});
});
+22 -13
View File
@@ -10,9 +10,15 @@ import type { TuiProps } from "../types";
export function useSlashCommands(input: {
workflowSlashCommands: TuiProps["workflowSlashCommands"];
loadAdditionalSlashCommands: TuiProps["loadAdditionalSlashCommands"];
subscribeAdditionalSlashCommands: TuiProps["subscribeAdditionalSlashCommands"];
canFork: boolean;
}) {
const { workflowSlashCommands, loadAdditionalSlashCommands, canFork } = input;
const {
workflowSlashCommands,
loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands,
canFork,
} = input;
const [additionalSlashCommands, setAdditionalSlashCommands] = useState<
TuiProps["workflowSlashCommands"] | undefined
>(loadAdditionalSlashCommands ? [] : undefined);
@@ -23,21 +29,24 @@ export function useSlashCommands(input: {
return;
}
let cancelled = false;
void loadAdditionalSlashCommands()
.then((commands) => {
if (!cancelled) {
setAdditionalSlashCommands(commands);
}
})
.catch(() => {
if (!cancelled) {
setAdditionalSlashCommands([]);
}
});
let requestId = 0;
const refresh = () => {
const id = ++requestId;
void loadAdditionalSlashCommands()
.then((commands) => {
if (!cancelled && id === requestId)
setAdditionalSlashCommands(commands);
})
// Keep the last catalog; the next refresh tries again.
.catch(() => {});
};
refresh();
const unsubscribe = subscribeAdditionalSlashCommands?.(refresh);
return () => {
cancelled = true;
unsubscribe?.();
};
}, [loadAdditionalSlashCommands]);
}, [loadAdditionalSlashCommands, subscribeAdditionalSlashCommands]);
const registry = useMemo(() => {
return buildSlashCommandRegistry({
+1
View File
@@ -146,6 +146,7 @@ function App(props: TuiProps) {
} = useSlashCommands({
workflowSlashCommands,
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands: props.subscribeAdditionalSlashCommands,
canFork: canForkSession,
});
+2
View File
@@ -157,6 +157,8 @@ export interface TuiProps {
initialRepoStatus?: RepoStatus;
workflowSlashCommands?: InteractiveSlashCommand[];
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
/** Notifies when additional commands may have changed (the active session changed). */
subscribeAdditionalSlashCommands?: (listener: () => void) => () => void;
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
+20 -2
View File
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
import { resolveWorkspaceRoot } from "./helpers";
export type ChatCommandState = {
sessionId?: string;
enableTools: boolean;
autoApproveTools: boolean;
cwd: string;
@@ -69,6 +70,7 @@ type ParsedChatCommand = {
trimmed: string;
command: string;
args: string[];
argumentsText: string;
state: ChatCommandState;
};
@@ -83,6 +85,19 @@ export type ChatCommandDefinition = {
export class ChatCommandHost {
private readonly definitions: ChatCommandDefinition[];
private fallback?: (
parsed: ParsedChatCommand,
context: ChatCommandContext,
) => Promise<boolean>;
setFallback(
handler: (
parsed: ParsedChatCommand,
context: ChatCommandContext,
) => Promise<boolean>,
): this {
this.fallback = handler;
return this;
}
constructor(definitions: ChatCommandDefinition[] = []) {
this.definitions = [...definitions];
@@ -101,7 +116,9 @@ export class ChatCommandHost {
}
clone(): ChatCommandHost {
return new ChatCommandHost(this.definitions);
const host = new ChatCommandHost(this.definitions);
host.fallback = this.fallback;
return host;
}
async handle(input: string, context: ChatCommandContext): Promise<boolean> {
@@ -130,13 +147,14 @@ export class ChatCommandHost {
trimmed,
command,
args,
argumentsText: trimmed.slice(commandRaw.length),
state: await context.getState(),
};
const matched = this.definitions.find((definition) =>
definition.names.includes(parsed.command),
);
if (!matched) {
return false;
return (await this.fallback?.(parsed, context)) ?? false;
}
if (matched.isAvailable && !matched.isAvailable(context)) {
return false;
+7
View File
@@ -0,0 +1,7 @@
declare const __CLINE_DASHBOARD_RUNTIME_DIR__: string | undefined;
export function resolveEmbeddedDashboard(): string | undefined {
return typeof __CLINE_DASHBOARD_RUNTIME_DIR__ === "undefined"
? undefined
: __CLINE_DASHBOARD_RUNTIME_DIR__;
}
+8
View File
@@ -192,6 +192,14 @@ export function writeErr(text: string): void {
console.error(`${c.red}error:${c.reset} ${text}`);
}
export function writeWarn(text: string): void {
if (currentOutputMode === "json") {
emitJsonLine("stderr", { type: "warning", message: text });
return;
}
console.error(`${c.yellow}warning:${c.reset} ${text}`);
}
// =============================================================================
// Formatting helpers
// =============================================================================
+105 -11
View File
@@ -1,13 +1,16 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClineCore, type PluginCommandsApi } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceChatCommandHost } from "./plugin-chat-commands";
describe("plugin chat commands", () => {
const tempRoots: string[] = [];
const cores: ClineCore[] = [];
afterEach(async () => {
await Promise.all(cores.splice(0).map((core) => core.dispose()));
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -36,20 +39,22 @@ describe("plugin chat commands", () => {
].join("\n"),
);
const { host, pluginSlashCommands, shutdown } =
await createWorkspaceChatCommandHost({
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const core = await ClineCore.create({ backendMode: "local" });
cores.push(core);
const { host, listCommands } = await createWorkspaceChatCommandHost({
commands: core.pluginCommands,
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const reply = vi.fn(async () => undefined);
// Filter to only our test plugin to ignore any discovered system plugins
const testCommands = pluginSlashCommands.filter(
const testCommands = (await listCommands()).filter(
(cmd) => cmd.name === "echo",
);
expect(testCommands).toEqual([{ name: "echo", description: "Echo input" }]);
const handled = await host.handle("/echo hello plugin", {
const handled = await host.handle("/echo hello plugin\nnext line", {
enabled: true,
getState: async () => ({
enableTools: false,
@@ -62,8 +67,7 @@ describe("plugin chat commands", () => {
});
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
await shutdown?.();
expect(reply).toHaveBeenCalledWith("echo:hello plugin\nnext line");
});
it("bridges plugin command submit prompts onto the chat command context", async () => {
@@ -91,7 +95,10 @@ describe("plugin chat commands", () => {
].join("\n"),
);
const { host, shutdown } = await createWorkspaceChatCommandHost({
const core = await ClineCore.create({ backendMode: "local" });
cores.push(core);
const { host } = await createWorkspaceChatCommandHost({
commands: core.pluginCommands,
cwd: tempRoot,
workspaceRoot: tempRoot,
});
@@ -114,6 +121,93 @@ describe("plugin chat commands", () => {
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("goal:fix tests");
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
await shutdown?.();
});
it.each([
"/skill explain",
"/workflow run",
])("lets %s reach instruction expansion when plugin discovery fails", async (prompt) => {
const commands: PluginCommandsApi = {
list: vi.fn().mockRejectedValue(new Error("hub unavailable")),
run: vi.fn().mockRejectedValue(new Error("must not execute")),
};
const logger = { log: vi.fn(), debug: vi.fn() };
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
logger,
});
const handled = await host.handle(prompt, {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
});
expect(handled).toBe(false);
expect(commands.run).not.toHaveBeenCalled();
expect(logger.log).toHaveBeenCalled();
});
it("does not execute commands absent from a partial catalog", async () => {
const commands: PluginCommandsApi = {
list: vi.fn().mockResolvedValue({
status: "error",
commands: [{ name: "echo" }],
error: "sibling failed",
}),
run: vi.fn(),
};
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
});
expect(
await host.handle("/workflow run", {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
}),
).toBe(false);
expect(commands.run).not.toHaveBeenCalled();
});
it("preserves execution errors for a command owned by a healthy plugin", async () => {
const commands: PluginCommandsApi = {
list: vi.fn().mockResolvedValue({
status: "error",
commands: [{ name: "echo" }],
error: "sibling failed",
}),
run: vi.fn().mockRejectedValue(new Error("handler failed")),
};
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
});
await expect(
host.handle("/echo input", {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
}),
).rejects.toThrow("handler failed");
expect(commands.run).toHaveBeenCalledTimes(1);
});
});
+49 -40
View File
@@ -1,50 +1,59 @@
import {
type BasicLogger,
createPluginCommandService,
type PluginSlashCommand,
import type {
BasicLogger,
PluginCommandCatalog,
PluginCommandsApi,
} from "@cline/core";
import { type ChatCommandHost, chatCommandHost } from "./chat-commands";
export type { PluginSlashCommand };
export interface WorkspaceChatCommandHostResult {
host: ChatCommandHost;
// Plugin-registered commands surfaced as slash commands for TUI autocomplete.
pluginSlashCommands: PluginSlashCommand[];
shutdown?: () => Promise<void>;
}
import { chatCommandHost } from "./chat-commands";
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
logger?: BasicLogger;
}): Promise<WorkspaceChatCommandHostResult> {
const service = createPluginCommandService({
cwd: input.cwd,
commands: PluginCommandsApi;
getSessionId?: () => string | undefined;
}) {
const commands = input.commands;
const target = () => ({
workspacePath: input.workspaceRoot?.trim() || input.cwd,
logger: input.logger,
sessionId: input.getSessionId?.() || undefined,
});
// Load failures are logged by the service and yield no commands.
const pluginSlashCommands = await service.listCommands();
if (pluginSlashCommands.length === 0) {
await service.shutdown();
return { host: chatCommandHost, pluginSlashCommands: [] };
}
const host = chatCommandHost.clone();
for (const command of pluginSlashCommands) {
host.register("command", {
names: [`/${command.name}`],
run: async ({ args }, context) => {
const result = await service.run(command.name, args.join(" "));
if (result?.reply) {
await context.reply(result.reply);
}
if (result?.submitPrompt) {
await context.submitPrompt?.(result.submitPrompt);
}
},
const host = chatCommandHost.clone().setFallback(async (parsed, context) => {
const commandTarget = {
workspacePath: parsed.state.workspaceRoot || parsed.state.cwd,
sessionId: parsed.state.sessionId ?? input.getSessionId?.(),
};
let catalog: PluginCommandCatalog;
try {
catalog = await commands.list(commandTarget);
} catch (error) {
input.logger?.log("Plugin command discovery failed", {
error,
severity: "warn",
});
return false;
}
if (
!catalog.commands.some(
(command) => command.name === parsed.command.slice(1),
)
)
return false;
const result = await commands.run({
...commandTarget,
prompt: `${parsed.command}${parsed.argumentsText}`,
});
}
return { host, pluginSlashCommands, shutdown: service.shutdown };
if (!result) return false;
if (result.reply) await context.reply(result.reply);
if (result.submitPrompt) await context.submitPrompt?.(result.submitPrompt);
return true;
});
return {
host,
async listCommands() {
const catalog = await commands.list(target());
if (catalog.status === "error")
input.logger?.log(`Plugin commands unavailable: ${catalog.error}`);
return catalog.commands;
},
};
}
+14
View File
@@ -0,0 +1,14 @@
import { formatSessionPluginIssue } from "@cline/core";
import type { SessionPluginIssue } from "@cline/shared";
/**
* One warning line per plugin the session asked for but could not load.
* Plugins the user turned off (settings or session policy) are not warnings.
*/
export function formatPluginIssueWarnings(
issues: ReadonlyArray<SessionPluginIssue> | undefined,
): string[] {
return (issues ?? [])
.filter((issue) => issue.state === "failed")
.map(formatSessionPluginIssue);
}
@@ -9,6 +9,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SKILLS_CLI_PACKAGE } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceMcpInput,
@@ -171,7 +172,7 @@ describe("marketplace installer", () => {
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
"vercel-labs/agent-skills",
"--skill",
@@ -314,7 +315,7 @@ describe("marketplace installer", () => {
});
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"remove",
"cline-sdk",
"-g",
+2 -1
View File
@@ -22,6 +22,7 @@ import {
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
SKILLS_CLI_PACKAGE,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
@@ -763,7 +764,7 @@ async function installSkill(
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
...(entry.install.args ?? []),
"-g",
+32
View File
@@ -1,5 +1,37 @@
# Cline Desktop Changelog
## 0.0.48
- GPT-6 models (such as `gpt-6-sol` and `gpt-6.1-sol`) work on OpenAI and Azure OpenAI-compatible endpoints. Before, every request was rejected because Cline sent `max_tokens` instead of `max_completion_tokens`
- The Cline provider's default model is Claude Sonnet 5.5 again, which is back at the top of Cline's recommended list
- Refreshed the model catalog. Adds the CommandCode and Reflection providers. Default models change for 302.AI (Gemini 4 Argon), Eden AI and Prime Inference (Muse Spark Open), CoreWeave (Reflection Beam 1.0), NanoGPT, Pioneer (Nemotron 3.5 Super VL), and Scaleway (DeepSeek V4.1 Flash)
## 0.0.47
- Marketplace plugins such as goal load again. In 0.0.46 they failed with `Cannot find module '@cline/sdk'` because the packaged app didn't include the SDK packages plugins import
- The Cloud sessions toggle is gone from Settings. Cloud sessions are on wherever they are available
- When a provider error interrupts a response mid-stream, Cline retries it up to 3 times before failing the turn
- The Cline provider's default model is now Gemini 4 Argon, the new first model in Cline's recommended list
- Refreshed the model catalog. Adds the Boundless and Standard Compute providers, and SambaNova now comes from the catalog with MiniMax M3 as its default. The Cline recommended list adds Gemini 4 Argon and drops Grok 4.7, and the Cline free list replaces Step 5 Preview with Nemotron 3.5 Super VL. OpenCode Go no longer lists MiniMax M2.7. Default models change for Cortecs, CrossModel, Eden AI, Google Gemini, GitHub Copilot, Google Vertex AI, LLM Gateway, Ofox, OpenCode Go, and Prime Inference (Gemini 4 Argon), Fireworks AI, Nebius Token Factory, and Requesty (Muse Spark Open), CoreWeave, DigitalOcean, NanoGPT, and Nvidia (Nemotron 3.5 Super VL), Aixy and Neon (Claude Haiku 5.5), Agnes AI, Ambient, IteraCompute, Poe, Scaleway, and SenseNova
## 0.0.46
- A new outline along the left edge of the conversation lists each prompt you sent. Hover a mark to preview the prompt, and click it to jump there
- Plugins that fail to load or error during a session now show a status line in the chat naming the plugin and the error, instead of silently losing their tools
- You can cancel a Cline Cloud sign-in while it is waiting for the browser
- Session titles renamed on the web now update in the app
- The Cloud setup card stays visible on smaller windows when signed out
- Installing a skill without Git installed now says Git is required and how to fix it, instead of showing a raw clone error
- Scheduled routines: queued runs now start instead of staying pending forever, a run whose model call fails (for example, a retired model) is marked failed with the error, and after downtime only the latest missed occurrence runs instead of every one back to back. The routine editor now lists current models instead of retired ones
- With no model chosen, the Cline provider uses the first model in Cline's recommended list (Claude Sonnet 5.5)
- Turning reasoning off now works on every provider. Models that can't turn it off keep their default, and level-only Gemini models no longer reject the request with a thinking-budget error
- Screenshots and other images no longer inflate the token estimate, which made auto-compaction run long before the conversation was near its limit
- Auto-compaction uses the selected model's context window when the catalog entry has none
- Rule files that are symlinks now load
- Skills in `.claude/skills` are now discovered
- The editor tool no longer receives null in place of the text to replace from some models
- Refreshed the model catalog. Adds the AI/ML API, LowRouter, and Prime Inference providers. The v0 provider now offers only its default model, `v0-1.5-md`, and OpenCode Go uses its new `https://opencode.ai/inference/go/v1` endpoint. The Cline free list adds Step 5 Preview. Default models change for 302.AI, CrossModel, Cortecs, DigitalOcean, NEAR AI Cloud, and Ofox (Claude Haiku 5.5), DevPass (LLM Gateway), LLM Gateway, and Meta (Muse Wonder 1.0), Aixy (GPT-6.1 Sol), CloudFerro Sherlock and Moark (GLM 5.3), Ambient, NanoGPT, Neuralwatt, and Novita AI
## 0.0.45
- SSH remotes start again. Since 0.0.38, connecting to an SSH environment failed with `SyntaxError: Invalid character`
+34 -29
View File
@@ -1,6 +1,6 @@
# Desktop App Example
Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspecting Cline chat sessions.
Tauri desktop shell + desktop backend (`sidecar/`) + Next.js UI for running and inspecting Cline chat sessions. The packaged app bundles a self-contained Cline CLI binary that starts the shared Hub and runs the backend, so no Node or Bun install is needed.
## Dev Commands
@@ -11,9 +11,9 @@ From `apps/examples/desktop-app/`:
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
- `bun run dev` - Tauri desktop dev
- `bun run build:web` - build production web assets only (includes the shared UI build)
- `bun run build` - build web assets and the sidecar binary
- `bun run build` - build web assets and the desktop runtime
- `bun run build:sidecar` - build the Bun sidecar bundle
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
- `bun run build:runtime` - compile the bundled Cline CLI (host plus Linux SSH remote copies) and bundle the desktop backend into `src-tauri/bin/`
- `bun run build:binary` - build desktop binary
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
@@ -151,27 +151,27 @@ Desktop owns the settings UI and packaged helper resource lookup.
The service stores host metadata at
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
identity-file path, never private-key contents. On first connect it uploads a
content-addressed, branch-matched, self-contained Hub helper under
`~/.cline/remote/`, binds the Hub to remote loopback, and forwards it to a
random local loopback port. Linux x64 and arm64 helpers are bundled by
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
supported. A macOS desktop reaches macOS SSH hosts with its own signed
universal sidecar, which runs the same helper entrypoint; Windows and Linux
desktops need a locally built darwin helper passed through
`CLINE_REMOTE_HELPER_BINARY`. The helper includes its own runtime (about 115 MB
per helper) and must not be UPX-compressed; Bun reads its embedded code back
from the executable at runtime, so a packed helper crashes with
content-addressed, branch-matched, self-contained Cline CLI binary under
`~/.cline/remote/` and runs `cline hub ensure --json --discovery-path ...`
there, which reuses a compatible healthy remote Hub or starts one bound to
remote loopback. The desktop forwards it to a random local loopback port.
Linux x64 and arm64 CLI copies are bundled by `bun run build:runtime`; 32-bit
Raspberry Pi operating systems are not supported. A macOS desktop reaches
macOS SSH hosts with its own signed universal Cline CLI; Windows and Linux
desktops need a locally built darwin CLI passed through
`CLINE_REMOTE_HELPER_BINARY`. The remote CLI includes its own runtime (about
115 MB per copy) and must not be UPX-compressed; Bun reads its embedded code
back from the executable at runtime, so a packed binary crashes with
`SyntaxError: Invalid character: '\0'`. It is copied once per matching desktop
build and cached, with no
`apt`, `npm`, root access,
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
remote Hub but leaves the helper cached for a faster reconnect. The helper
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
MCP executables remain visible.
build and cached, with no `apt`, `npm`, root access, global CLI install, or
public Hub port. Disconnecting runs `cline hub stop` for the desktop-owned
remote Hub but leaves the CLI cached for a faster reconnect. The remote Hub
imports the remote login-shell `PATH` (`--login-shell-path`), so
user-installed Git, GitHub CLI, and MCP executables remain visible.
Each service instance uses its own discovery record, so an existing Cline CLI/Hub on the
same account is neither replaced nor stopped. Both Hub processes can coexist
while the desktop is connected; this isolation keeps the remote helper separate from the default CLI Hub.
while the desktop is connected; this isolation keeps the desktop's remote Hub separate from the default CLI Hub.
The desktop currently leaves file attachments and opening a remote file in a local
editor disabled. Text, images, file mentions/search, Git branch operations,
@@ -239,11 +239,11 @@ sudo dnf install ./Cline_<version>_aarch64.rpm # Fedora / RHEL (arm64)
```
The app installs as `/usr/bin/cline-app` with a `Cline` launcher entry; the
sidecar is `/usr/bin/code-sidecar` and the bundled SSH remote helpers live in
`/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and
bundled Cline CLI is `/usr/bin/cline-cli`, and the desktop backend bundle and
SSH remote CLIs live in `/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and
GNOME with the AppIndicator extension); without one the app still runs but the
tray menu is unavailable. There is no AppImage: linuxdeploy cannot process the
Bun-compiled sidecar (`ldd` fails on it and `patchelf` corrupts it), so the
Bun-compiled Cline CLI (`ldd` fails on it and `patchelf` corrupts it), so the
AppImage target is excluded from `tauri build` on Linux. A Linux desktop
cannot use a Mac as an SSH remote host (see the changelog).
@@ -293,16 +293,20 @@ bun run package:desktop:mac
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 2–10 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled Cline CLI (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
## Runtime Overview
Startup flow:
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
2. The desktop backend starts the Bun sidecar, which discovers or starts the
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
for desktop commands, queries, and pushed events.
In a packaged app the backend is `bin/desktop-backend/index.js`, executed by
the bundled Cline CLI (`cline-cli run`); `bun run dev` runs `sidecar/index.ts`
with Bun directly.
2. The backend runs `cline-cli hub ensure`, which reuses a compatible healthy
shared Cline Hub on loopback or starts one (the Hub daemon is the same CLI
binary). The backend then attaches to the Hub and exposes one websocket
transport (`/transport`) for desktop commands, queries, and pushed events.
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
4. Tool approval updates are pushed from the backend instead of polled from the UI.
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
@@ -323,7 +327,8 @@ Desktop transport envelope:
## Key Files
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent desktop backend entrypoint
- [`sidecar/cli-runtime.ts`](./sidecar/cli-runtime.ts) - bundled Cline CLI runtime adoption and `hub ensure`
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
@@ -364,7 +369,7 @@ credentials, request headers, recorded audio, or transcript contents.
## Troubleshooting
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
- Tauri restarts the desktop backend if its process exits and stops it on app teardown. The shared Hub keeps running for other clients.
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
- Linux with the proprietary NVIDIA driver: WebKitGTK's DMA-BUF renderer
+1 -1
View File
@@ -1,7 +1,7 @@
import { $ } from "bun";
const main = async () => {
await $`bun run build:sidecar:bin`;
await $`bun run build:runtime`;
await $`next build`.cwd("webview");
};
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.45",
"version": "0.0.48",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -15,7 +15,7 @@
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:runtime": "bun run scripts/build-desktop-runtime.ts",
"build:binary": "tauri build",
"dmg:background": "bun run scripts/dmg-background.ts",
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
@@ -108,6 +108,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@cline/sdk": "workspace:*",
"@tailwindcss/postcss": "^4.2.0",
"@tauri-apps/cli": "^2.0.0",
"@types/bun": "^1.4.2",
@@ -0,0 +1,197 @@
import { cpSync, existsSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
import {
compileCliBinary,
installOpenTuiNativeVariants,
} from "../../../cli/script/compile-binary";
import { telemetryDefineArgs } from "./telemetry-define-args";
// The desktop app ships two runtime artifacts and no app-specific executable:
//
// - The Cline CLI, compiled self-contained for this target (Tauri externalBin
// `bin/cline-cli`). `cline-cli hub ensure` starts or reuses the CLI-managed
// Hub, and the Hub daemon is that same binary.
// - The desktop backend (sidecar/ sources: the UI command router plus its
// HTTP/WebSocket server) as one JS bundle, executed on the CLI's embedded
// runtime. It attaches to the Hub as an ordinary client.
//
// Linux CLI copies are bundled as resources so SSH remotes run a CLI-managed
// Hub too; macOS remotes reuse the signed universal CLI itself.
const APP_ROOT = fileURLToPath(new URL("..", import.meta.url));
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
const BIN_DIR = "src-tauri/bin";
export const DESKTOP_CLI_NAME = "cline-cli";
export const DESKTOP_BACKEND_BUNDLE = `${BIN_DIR}/desktop-backend/index.js`;
export const REMOTE_CLI_DIR = `${BIN_DIR}/remote-helpers`;
const resolveTargetTriple = async (): Promise<string> => {
const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET;
if (fromEnv?.trim()) {
return fromEnv.trim();
}
const rustcVersion = await $`rustc -vV`.text();
const hostLine = rustcVersion
.split("\n")
.find((line) => line.startsWith("host: "));
const host = hostLine?.slice("host: ".length).trim();
if (!host) {
throw new Error("failed to resolve Rust host target triple");
}
return host;
};
// Bun cross-compiles --compile binaries, so a CI runner can produce the CLI
// for a different architecture than its own (e.g. the x86_64 macOS bundle
// from an arm64 runner).
const resolveBunCompileTarget = (
targetTriple: string,
): Bun.Build.CompileTarget => {
if (targetTriple.startsWith("aarch64-apple-darwin"))
return "bun-darwin-arm64";
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
if (targetTriple.startsWith("aarch64-pc-windows")) return "bun-windows-arm64";
// SSH hosts may predate AVX2 (e.g. Ivy Bridge Xeons). The default Bun
// x64 runtime can SIGILL before our entrypoint runs on those CPUs.
if (targetTriple.startsWith("x86_64-unknown-linux"))
return "bun-linux-x64-baseline";
if (targetTriple.startsWith("aarch64-unknown-linux"))
return "bun-linux-arm64";
throw new Error(`unsupported desktop target triple: ${targetTriple}`);
};
const desktopCliOutfile = (targetTriple: string): string => {
const extension = targetTriple.includes("windows") ? ".exe" : "";
return `${BIN_DIR}/${DESKTOP_CLI_NAME}-${targetTriple}${extension}`;
};
const compileDesktopCli = async (
targetTriple: string,
outfile = desktopCliOutfile(targetTriple),
): Promise<string> => {
await compileCliBinary({
bunTarget: resolveBunCompileTarget(targetTriple),
outfile: fileURLToPath(new URL(`../${outfile}`, import.meta.url)),
// A packaged app and an SSH session launch the CLI from directories the
// user did not choose (/, an SSH home). Reading .env or bunfig.toml
// from there could fail startup or leak credentials into the Hub.
autoloadLaunchDirectoryConfig: false,
// Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to
// intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM
// firewalls) fails with "unable to get local issuer certificate". Bake
// --use-system-ca in so the Hub also trusts the OS Keychain/cert store,
// as the npm wrapper does. `BUN_BE_BUN=1 cline-cli run` ignores baked-in
// flags, so main.rs passes it again when launching the backend.
execArgv: ["--use-system-ca"],
});
return outfile;
};
const bundleDesktopBackend = async (): Promise<void> => {
// Telemetry config must be inlined into the bundle: a packaged app launched
// from Finder/the Dock has no OTEL_* env at runtime, so without this the
// backend silently ships with telemetry disabled. Verify with
// `BUN_BE_BUN=1 cline-cli run <bundle> --telemetry-selfcheck`.
// The script runner does not mount the CLI's bunfs. Ship the same runtime
// artifact beside the backend, just as npm and VS Code hosts do.
const runtime = `${APP_ROOT}/${BIN_DIR}/desktop-backend/plugin-runtime`;
rmSync(runtime, { recursive: true, force: true });
cpSync(`${REPO_ROOT}/sdk/packages/core/dist/plugin-runtime`, runtime, {
recursive: true,
});
const defines = telemetryDefineArgs();
await $`bun build ./sidecar/index.ts --target bun --minify ${defines} --outfile ${DESKTOP_BACKEND_BUNDLE}`;
};
// Each compiled CLI embeds a full Bun runtime (~100 MB), and both Linux copies
// ship inside every desktop bundle. They must not be UPX-packed: the Bun 1.4
// runtime reads its embedded module payload back from its own executable at
// runtime, and on a packed binary those reads return NUL padding, so every
// `hub ensure` fails with "SyntaxError: Invalid character: '\0'" (#14815).
// `strip` is not an option either; it discards Bun's appended module payload.
// Linux x64 and arm64 cover common SSH hosts. macOS copies are deliberately
// not bundled: they would be Mach-O files under Contents/Resources, which
// Tauri does not codesign, and any unsigned Mach-O in the bundle fails
// notarization. Mac remotes are served by the signed desktop CLI instead
// (sidecar/remote-helper.ts).
//
// On a Windows host, Bun fails to extract the downloaded Linux runtime these
// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'").
// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun-<target>-v<version>`
// already exists, so seed those two files from the @oven/bun-<target> npm
// packages first; desktop-publish.yml does exactly that in its Windows job.
const buildRemoteClis = async (): Promise<void> => {
for (const targetTriple of [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]) {
await compileDesktopCli(
targetTriple,
`${REMOTE_CLI_DIR}/cline-${targetTriple}`,
);
}
};
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
// but expects externalBin binaries to already be fat binaries named
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
const buildUniversalMacCli = async (): Promise<void> => {
const arm64 = await compileDesktopCli("aarch64-apple-darwin");
const x64 = await compileDesktopCli("x86_64-apple-darwin");
const outfile = desktopCliOutfile("universal-apple-darwin");
await $`lipo -create -output ${outfile} ${arm64} ${x64}`;
await $`chmod +x ${outfile}`;
await $`lipo -info ${outfile}`;
};
const hasOpenTuiNativeVariant = (os: string, arch: string): boolean =>
existsSync(`${REPO_ROOT}/node_modules/.bun`) &&
[
...new Bun.Glob(`@opentui+core-${os}-${arch}@*`).scanSync({
cwd: `${REPO_ROOT}/node_modules/.bun`,
onlyFiles: false,
}),
].length > 0;
const main = async () => {
// The CLI, its Hub daemon, and the backend bundle must all carry the same
// SDK build identity, so build the SDK packages they bundle once, first.
await $`bun run build:sdk`.cwd(REPO_ROOT);
process.chdir(APP_ROOT);
const targetTriple = await resolveTargetTriple();
await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR}`;
// Every build cross-compiles the Linux SSH copies (and macOS builds both
// architectures), which needs the target platforms' OpenTUI native
// packages in addition to the host's.
const needed: Array<[string, string]> = [
["linux", "x64"],
["linux", "arm64"],
...(targetTriple === "universal-apple-darwin"
? ([
["darwin", "x64"],
["darwin", "arm64"],
] as Array<[string, string]>)
: []),
];
if (needed.some(([os, arch]) => !hasOpenTuiNativeVariant(os, arch))) {
await installOpenTuiNativeVariants();
}
await bundleDesktopBackend();
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacCli();
} else {
await compileDesktopCli(targetTriple);
}
await buildRemoteClis();
};
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
}
@@ -1,147 +0,0 @@
import { fileURLToPath } from "node:url";
import { $ } from "bun";
import { telemetryDefineArgs } from "./telemetry-define-args";
const resolveTargetTriple = async (): Promise<string> => {
const fromEnv = process.env.TAURI_ENV_TARGET_TRIPLE ?? process.env.TARGET;
if (fromEnv?.trim()) {
return fromEnv.trim();
}
const rustcVersion = await $`rustc -vV`.text();
const hostLine = rustcVersion
.split("\n")
.find((line) => line.startsWith("host: "));
const host = hostLine?.slice("host: ".length).trim();
if (!host) {
throw new Error("failed to resolve Rust host target triple");
}
return host;
};
// Bun cross-compiles --compile binaries, so a CI runner can produce the
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
// bundle from an arm64 runner). Without an explicit --target, bun always
// emits a host-arch binary even when Tauri is building for another triple.
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
if (targetTriple.startsWith("aarch64-apple-darwin"))
return "bun-darwin-arm64";
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
// SSH hosts may predate AVX2 (e.g. Ivy Bridge Xeons). The default Bun
// x64 runtime can SIGILL before our entrypoint runs on those CPUs.
if (targetTriple.startsWith("x86_64-unknown-linux"))
return "bun-linux-x64-baseline";
if (targetTriple.startsWith("aarch64-unknown-linux"))
return "bun-linux-arm64";
return undefined;
};
const sidecarOutfile = (targetTriple: string): string => {
const extension = targetTriple.includes("windows") ? ".exe" : "";
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
};
const buildSidecar = async (
targetTriple: string,
outfile = sidecarOutfile(targetTriple),
entrypoint = "./sidecar/index.ts",
minify = false,
): Promise<string> => {
const bunTarget = resolveBunCompileTarget(targetTriple);
// Telemetry config must be inlined into the compiled binary: a packaged
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
// without this the sidecar silently ships with telemetry disabled.
// Verify with `<binary> --telemetry-selfcheck` after building.
const defines = telemetryDefineArgs();
const optimizationArgs = minify ? ["--minify"] : [];
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
// launch directory before our entrypoint runs. Remote helpers are launched
// from an SSH user's home directory, so that behavior can both make the
// helper fail on an unrelated dotenv file and leak workspace credentials
// into the Hub process. Packaged binaries must depend only on their explicit
// process environment and compiled configuration.
const runtimeIsolationArgs = [
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
// Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to
// intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM
// firewalls) fails with "unable to get local issuer certificate". Bake
// --use-system-ca into the runtime so the sidecar and the Hub daemon it
// re-executes from this binary also trust the OS Keychain/cert store,
// matching the CLI wrapper's OS trust-anchor harvesting.
"--compile-exec-argv=--use-system-ca",
];
if (bunTarget) {
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
} else {
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
}
return outfile;
};
// Each compiled helper embeds a full Bun runtime (~100 MB) around ~14 MB of
// our code, and both helpers ship uncompressed inside every desktop bundle.
// Do not UPX-pack or `strip` them: since Bun 1.4 the runtime lazily reads its
// appended module payload back from its own executable file, so a packed
// helper starts but crashes with "SyntaxError: Invalid character: '\0'" on the
// first lazy read (cline/cline#14815, #14781). `strip` discards the payload.
//
// SSH environments run the same Hub build as the desktop in a dedicated
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
// macOS helpers are deliberately not bundled: they are Mach-O files under
// Contents/Resources, which Tauri does not codesign, and any unsigned Mach-O
// in the bundle fails notarization. Mac remotes are served from a macOS
// desktop by its own signed sidecar instead (sidecar/remote-helper.ts).
//
// On a Windows host, Bun fails to extract the downloaded Linux runtime these
// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'").
// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun-<target>-v<version>`
// already exists, so seed those two files from the @oven/bun-<target> npm
// packages first; desktop-publish.yml does exactly that in its Windows job.
const buildRemoteHelpers = async (): Promise<void> => {
for (const targetTriple of [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]) {
await buildSidecar(
targetTriple,
`./src-tauri/bin/remote-helpers/cline-remote-helper-${targetTriple}`,
"../../../sdk/packages/core/dist/remote/remote-helper-entry.js",
true,
);
}
};
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
// but expects sidecars (externalBin) to already be fat binaries named
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
const buildUniversalMacSidecar = async (): Promise<void> => {
const arm64 = await buildSidecar("aarch64-apple-darwin");
const x64 = await buildSidecar("x86_64-apple-darwin");
const outfile = sidecarOutfile("universal-apple-darwin");
await $`lipo -create -output ${outfile} ${arm64} ${x64}`;
await $`chmod +x ${outfile}`;
await $`lipo -info ${outfile}`;
};
const main = async () => {
// All compiled helpers and the sidecar depend on fresh SDK package exports.
await $`bun run build:sdk`.cwd(
fileURLToPath(new URL("../../../../", import.meta.url)),
);
const targetTriple = await resolveTargetTriple();
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacSidecar();
} else {
await buildSidecar(targetTriple);
}
await buildRemoteHelpers();
};
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
@@ -2,56 +2,95 @@ import { afterAll, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import {
closeSync,
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { compileCliBinary } from "../../../cli/script/compile-binary";
// Exercise the actual compiled entrypoint: source-only tests miss mixed SDK
// build identities between the desktop client and its embedded Hub daemon.
// Exercise the shipped runtime artifacts: the compiled Cline CLI that starts
// the Hub and the backend bundle that runs on its embedded runtime. Source-only
// tests miss mixed SDK build identities between the desktop client and the
// CLI-managed Hub it attaches to.
let compiledBinaryDir: string | undefined;
let compiledBinary: string | undefined;
let buildDir: string | undefined;
function resolveSidecarBinary(): string {
if (process.env.CLINE_TEST_SIDECAR_BIN) {
return resolve(process.env.CLINE_TEST_SIDECAR_BIN);
function ensureBuildDir(): string {
buildDir ??= mkdtempSync(join(tmpdir(), "cline-desktop-startup-bin-"));
return buildDir;
}
let compiledCli: string | undefined;
async function resolveCliBinary(): Promise<string> {
if (process.env.CLINE_TEST_DESKTOP_CLI_BIN) {
return resolve(process.env.CLINE_TEST_DESKTOP_CLI_BIN);
}
if (compiledBinary) {
return compiledBinary;
if (!compiledCli) {
const outfile = join(
ensureBuildDir(),
process.platform === "win32" ? "cline-cli.exe" : "cline-cli",
);
await compileCliBinary({
bunTarget:
`bun-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` as Bun.Build.CompileTarget,
outfile,
autoloadLaunchDirectoryConfig: false,
execArgv: ["--use-system-ca"],
});
compiledCli = outfile;
}
compiledBinaryDir = mkdtempSync(join(tmpdir(), "cline-desktop-startup-bin-"));
const binary = join(
compiledBinaryDir,
process.platform === "win32" ? "sidecar.exe" : "sidecar",
);
const build = spawnSync(
process.execPath,
[
"build",
fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)),
"--compile",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
"--compile-exec-argv=--use-system-ca",
"--outfile",
binary,
],
{ cwd: compiledBinaryDir, encoding: "utf8", timeout: 60_000 },
);
expect(build.status, build.stderr || String(build.error)).toBe(0);
compiledBinary = binary;
return binary;
return compiledCli;
}
let bundledBackend: string | undefined;
function resolveBackendBundle(): string {
if (process.env.CLINE_TEST_DESKTOP_BACKEND_BUNDLE) {
return resolve(process.env.CLINE_TEST_DESKTOP_BACKEND_BUNDLE);
}
if (!bundledBackend) {
const outdir = join(ensureBuildDir(), "desktop-backend");
const build = spawnSync(
process.execPath,
[
"build",
fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)),
"--target",
"bun",
"--outdir",
outdir,
],
{ encoding: "utf8", timeout: 120_000 },
);
expect(build.status, build.stderr || String(build.error)).toBe(0);
cpSync(
fileURLToPath(
new URL(
"../../../../sdk/packages/core/dist/plugin-runtime",
import.meta.url,
),
),
join(outdir, "plugin-runtime"),
{ recursive: true },
);
bundledBackend = join(outdir, "index.js");
}
return bundledBackend;
}
afterAll(() => {
if (compiledBinaryDir) {
if (buildDir) {
try {
rmSync(compiledBinaryDir, {
rmSync(buildDir, {
recursive: true,
force: true,
maxRetries: 20,
@@ -63,8 +102,52 @@ afterAll(() => {
}
});
test("the shipped CLI and backend report telemetry without starting a hub", async () => {
const cli = await resolveCliBinary();
const bundle = resolveBackendBundle();
for (const args of [
["--telemetry-selfcheck"],
["run", "--no-env-file", bundle, "--telemetry-selfcheck"],
]) {
const result = spawnSync(cli, args, {
encoding: "utf8",
timeout: 10_000,
env: { ...process.env, BUN_BE_BUN: args[0] === "run" ? "1" : "" },
});
expect(result.status, result.stderr || String(result.error)).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
telemetry_selfcheck: true,
enabled: expect.any(Boolean),
otlp_endpoint_host: expect.any(String),
logs_exporter: expect.any(String),
metrics_exporter: expect.any(String),
traces_exporter: expect.any(String),
});
}
}, 120_000);
async function reserveLoopbackPort(): Promise<number> {
const server = createServer();
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
const address = server.address();
await new Promise<void>((done) => server.close(() => done()));
if (!address || typeof address === "string") {
throw new Error("could not reserve a loopback port");
}
return address.port;
}
function processCommandLine(pid: number): string | undefined {
if (process.platform === "win32") return undefined;
const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], {
encoding: "utf8",
});
return ps.status === 0 ? ps.stdout.trim() : undefined;
}
async function runStartupScenario(
extraEnv: Record<string, string>,
options: { hubAlreadyRunning: boolean; discoveryContents?: string },
): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "cline-desktop-startup-"));
const discoveryPath = join(root, "hub.json");
@@ -75,12 +158,16 @@ async function runStartupScenario(
let child: ReturnType<typeof Bun.spawn> | undefined;
let hubPid: number | undefined;
try {
const binary = resolveSidecarBinary();
if (options.discoveryContents !== undefined) {
writeFileSync(discoveryPath, options.discoveryContents);
}
const cli = await resolveCliBinary();
const bundle = resolveBackendBundle();
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_)/.test(key) &&
!/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_|BUN_BE_BUN)/.test(key) &&
!/^(https?_proxy|all_proxy|no_proxy)$/i.test(key),
),
);
@@ -88,31 +175,63 @@ async function runStartupScenario(
CLINE_DIR: root,
CLINE_DATA_DIR: join(root, "data"),
CLINE_HUB_DISCOVERY_PATH: discoveryPath,
CLINE_NO_AUTO_UPDATE: "1",
...extraEnv,
});
// Bootstrap on an OS-assigned port using the same executable. Pin the
// desktop to that port so this test cannot touch a developer's real Hub,
// even if a regression makes it reject its own discovery record.
const bootstrap = spawnSync(
binary,
["--remote-hub-ensure", "--discovery-path", discoveryPath, "--cwd", root],
{ cwd: root, env, encoding: "utf8", timeout: 20_000 },
// Pin the Hub to a free port so this test cannot touch a developer's
// real Hub even if a regression makes the backend reject the CLI's
// discovery record.
let existingHub: { pid: number; url: string } | undefined;
if (options.hubAlreadyRunning) {
// Another client (e.g. a terminal) already started the shared Hub.
const ensure = spawnSync(
cli,
[
"hub",
"ensure",
"--json",
"--cwd",
root,
"--port",
"0",
"--allow-port-fallback",
"--no-connectors",
],
{ cwd: root, env, encoding: "utf8", timeout: 30_000 },
);
expect(ensure.status, ensure.stderr || String(ensure.error)).toBe(0);
const ensured = JSON.parse(ensure.stdout.trim().split("\n").at(-1) ?? "");
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
expect(ensured).toMatchObject({
url: hub.url,
authToken: hub.authToken,
});
env.CLINE_HUB_PORT = String(hub.port);
existingHub = { pid: hub.pid, url: hub.url };
} else {
env.CLINE_HUB_PORT = String(await reserveLoopbackPort());
}
// The backend bundle on the CLI's runtime, started outside the
// workspace like the packaged app does.
child = Bun.spawn(
[cli, "run", "--use-system-ca", "--no-env-file", bundle],
{
cwd: dirname(bundle),
env: {
...env,
BUN_BE_BUN: "1",
CLINE_DESKTOP_CLI_PATH: cli,
CLINE_DESKTOP_WORKSPACE_ROOT: root,
},
stdin: "ignore",
stdout,
stderr,
},
);
expect(bootstrap.status, bootstrap.stderr || String(bootstrap.error)).toBe(
0,
);
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
env.CLINE_HUB_PORT = String(hub.port);
child = Bun.spawn([binary], {
cwd: root,
env,
stdin: "ignore",
stdout,
stderr,
});
let endpoint: string | undefined;
const deadline = Date.now() + 15_000;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
try {
@@ -134,6 +253,21 @@ async function runStartupScenario(
});
expect(health.ok).toBe(true);
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
if (existingHub) {
// The backend attached to the running Hub instead of replacing it.
expect(hub).toMatchObject(existingHub);
} else {
expect(hub.port).toBe(Number(env.CLINE_HUB_PORT));
}
// Either way the Hub daemon is the bundled CLI, not the backend.
expect(hub.pid).not.toBe(child.pid);
const hubCommand = processCommandLine(hub.pid);
if (hubCommand !== undefined) {
expect(hubCommand).toContain(cli);
expect(hubCommand).toContain("--cline-hub-daemon");
}
} finally {
if (child && child.exitCode === null) {
child.kill();
@@ -141,7 +275,7 @@ async function runStartupScenario(
if (child.exitCode === null) child.kill("SIGKILL");
await child.exited;
}
// A failed bootstrap may still have published a discovery record.
// A failed start may still have published a discovery record.
try {
hubPid ??= JSON.parse(readFileSync(discoveryPath, "utf8")).pid;
if (hubPid) process.kill(hubPid, "SIGTERM");
@@ -149,10 +283,10 @@ async function runStartupScenario(
/* The isolated Hub may already have exited. */
}
// Windows refuses to remove a directory that is any live process's cwd,
// and both the Hub and the backend run with `root` as theirs. The kill
// above only requests termination, so wait for the pid to actually go
// away; on POSIX the remove would have succeeded regardless, which is
// why this only ever failed on the Windows runner.
// and the Hub runs with `root` as its cwd. The kill above only requests
// termination, so wait for the pid to actually go away; on POSIX the
// remove would have succeeded regardless, which is why this only ever
// failed on the Windows runner.
if (hubPid) {
const gone = Date.now() + 10_000;
while (Date.now() < gone) {
@@ -182,17 +316,263 @@ async function runStartupScenario(
}
}
test("compiled desktop backend publishes its endpoint with its own Hub", async () => {
await runStartupScenario({});
}, 100_000);
test("desktop backend has the bundled CLI start the Hub when none is running", async () => {
await runStartupScenario({}, { hubAlreadyRunning: false });
}, 180_000);
test("desktop backend reuses a compatible Hub the CLI already started", async () => {
await runStartupScenario({}, { hubAlreadyRunning: true });
}, 180_000);
// Bun's fetch has no localhost proxy bypass, so a system proxy used to swallow
// the hub discovery probes (cline/cline#14265, #14292).
test("compiled desktop backend starts behind a dead HTTP(S) proxy", async () => {
await runStartupScenario({
HTTP_PROXY: "http://127.0.0.1:9",
HTTPS_PROXY: "http://127.0.0.1:9",
http_proxy: "http://127.0.0.1:9",
https_proxy: "http://127.0.0.1:9",
test("desktop startup works behind a dead HTTP(S) proxy", async () => {
await runStartupScenario(
{
HTTP_PROXY: "http://127.0.0.1:9",
HTTPS_PROXY: "http://127.0.0.1:9",
http_proxy: "http://127.0.0.1:9",
https_proxy: "http://127.0.0.1:9",
},
{ hubAlreadyRunning: false },
);
}, 180_000);
for (const discoveryContents of ["{ invalid json", "{}"]) {
test(`desktop startup recovers from an invalid discovery record: ${discoveryContents}`, async () => {
await runStartupScenario(
{},
{ hubAlreadyRunning: false, discoveryContents },
);
}, 120_000);
}
test.each([
"worker",
"sandbox",
])("standalone CLI loads an SDK plugin without Node (%s)", async (mode) => {
const cli = await resolveCliBinary();
const root = mkdtempSync(join(tmpdir(), "cline-plugin-installed-"));
try {
const directory = join(root, ".cline", "plugins");
mkdirSync(directory, { recursive: true });
// Occupy the old cache path: startup must succeed without extraction.
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
const marker = join(root, "plugin-result.json");
// Copy the raw TypeScript fixture so SDK imports must resolve from the
// packaged runtime, outside the monorepo and without prebundling.
copyFileSync(
fileURLToPath(
new URL("./fixtures/embedded-plugin/smoke.ts", import.meta.url),
),
join(directory, "smoke.ts"),
);
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test(
key,
),
),
);
const result = spawnSync(cli, ["config", "tools", "--json"], {
cwd: root,
encoding: "utf8",
timeout: 30000,
env: {
...env,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
PATH: root,
JITI_FS_CACHE: "false",
CLINE_PLUGIN_MODE: mode,
CLINE_TEST_PLUGIN_RESULT_PATH: marker,
TMPDIR: root,
CLINE_NO_AUTO_UPDATE: "1",
},
});
expect(result.status, result.stderr || String(result.error)).toBe(0);
expect(
JSON.parse(result.stdout).some(
(tool: { name: string }) => tool.name === "embedded_ping",
),
).toBe(true);
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
"no extraction",
);
expect(JSON.parse(readFileSync(marker, "utf8"))).toEqual({
pong: true,
// The native resolver expands Windows 8.3 short names (RUNNER~1), as
// the child's process.execPath does.
execPath: realpathSync.native(cli),
isMainThread: mode === "sandbox",
bunRuntimeFlag: null,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 120000);
test.each([
"worker",
"sandbox",
])("packaged desktop backend loads its on-disk SDK runtime (%s)", async (mode) => {
const cli = await resolveCliBinary();
const outdir = dirname(resolveBackendBundle());
const probe = join(outdir, "plugin-runtime-probe.js");
const built = await Bun.build({
entrypoints: [
fileURLToPath(
new URL("./fixtures/desktop-plugin-runtime.ts", import.meta.url),
),
],
target: "bun",
outdir,
naming: "plugin-runtime-probe.js",
});
}, 100_000);
expect(built.success, built.logs.map(String).join("\n")).toBe(true);
const root = mkdtempSync(join(tmpdir(), "cline-desktop-plugin-"));
try {
const plugins = join(root, ".cline", "plugins");
mkdirSync(plugins, { recursive: true });
copyFileSync(
fileURLToPath(
new URL("./fixtures/embedded-plugin/desktop.ts", import.meta.url),
),
join(plugins, "desktop.ts"),
);
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test(
key,
),
),
);
const result = spawnSync(
cli,
["run", "--use-system-ca", "--no-env-file", probe],
{
cwd: root,
encoding: "utf8",
timeout: 30000,
env: {
...env,
PATH: root,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
BUN_BE_BUN: "1",
CLINE_DESKTOP_CLI_PATH: cli,
CLINE_PLUGIN_MODE: mode,
JITI_FS_CACHE: "false",
},
},
);
expect(result.status, result.stderr || String(result.error)).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
commands: [{ name: "packaged-ping" }],
result: { reply: "pong" },
});
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
"no extraction",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 120000);
test("standalone CLI serves the dashboard and its assets without external files or Node", async () => {
const cli = await resolveCliBinary();
const root = mkdtempSync(join(tmpdir(), "cline-standalone-dashboard-"));
mkdirSync(join(root, ".cline"));
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|HOST$|PORT$|ROOM_SECRET$|PUBLIC_URL$)/.test(
key,
),
),
);
const port = await reserveLoopbackPort();
const isolatedEnv = {
...env,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
CLINE_HUB_DISCOVERY_PATH: join(root, "hub.json"),
CLINE_HUB_PORT: String(await reserveLoopbackPort()),
CLINE_NO_AUTO_UPDATE: "1",
PATH: root,
};
const child = Bun.spawn(
[
cli,
"dashboard",
"--no-open",
"--host",
"127.0.0.1",
"--port",
String(port),
"--cwd",
root,
],
{ cwd: root, env: isolatedEnv, stdout: "ignore", stderr: "pipe" },
);
try {
const url = `http://127.0.0.1:${port}`;
let response: Response | undefined;
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
try {
response = await fetch(url);
if (response.status === 200) break;
} catch {}
if (child.exitCode !== null)
throw new Error(await new Response(child.stderr).text());
await Bun.sleep(100);
}
expect(response?.status).toBe(200);
if (!response) throw new Error("Dashboard did not start");
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
"no extraction",
);
const html = await response.text();
expect(html).toContain("<html");
const assets = [
...html.matchAll(/(?:src|href)="([^"]+\.(?:js|css))"/g),
].map((match) => match[1]);
expect(assets.length).toBeGreaterThan(0);
for (const asset of assets) {
if (!asset) throw new Error("Missing dashboard asset URL");
const resource = await fetch(new URL(asset, url));
expect(resource.status).toBe(200);
expect(resource.headers.get("content-type")).toMatch(
asset.endsWith(".css") ? /text\/css/ : /(?:javascript|ecmascript)/,
);
expect((await resource.arrayBuffer()).byteLength).toBeGreaterThan(0);
}
} finally {
child.kill("SIGTERM");
await child.exited;
spawnSync(cli, ["hub", "stop"], {
cwd: root,
env: isolatedEnv,
timeout: 10000,
});
// The stopped Hub can hold its cwd briefly on Windows (EBUSY).
try {
rmSync(root, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 100,
});
} catch {
/* The runner discards its temp directory anyway. */
}
}
}, 120000);
@@ -0,0 +1,26 @@
import { ClineCore, resolvePluginRuntimeDirectory } from "@cline/core";
import { adoptDesktopCliRuntime } from "../../sidecar/cli-runtime";
async function main() {
adoptDesktopCliRuntime();
const runtime = resolvePluginRuntimeDirectory();
if (!runtime?.includes("desktop-backend"))
throw new Error(`Expected the packaged backend runtime, got ${runtime}`);
const core = await ClineCore.create({ backendMode: "local" });
try {
const workspacePath = process.cwd();
const { commands } = await core.pluginCommands.list({ workspacePath });
const result = await core.pluginCommands.run({
workspacePath,
prompt: "/packaged-ping",
});
process.stdout.write(JSON.stringify({ commands, result }));
} finally {
await core.dispose();
}
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
import type { AgentPlugin } from "@cline/sdk";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
export default {
name: "packaged-backend-smoke",
manifest: { capabilities: ["commands"] },
setup(api) {
if (typeof CLINE_DEFAULT_MODEL_ID !== "string")
throw new Error("Missing SDK browser exports");
api.registerCommand({ name: "packaged-ping", handler: () => "pong" });
},
} satisfies AgentPlugin;
@@ -0,0 +1,68 @@
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { isMainThread } from "node:worker_threads";
import { getAllProviders } from "@cline/llms/browser";
import {
type AgentPlugin,
createTool,
resolvePluginRuntimeDirectory,
} from "@cline/sdk";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "@cline/shared/types";
export default {
name: "embedded-smoke",
manifest: { capabilities: ["tools"] },
async setup(api) {
if (
typeof CLINE_DEFAULT_MODEL_ID !== "string" ||
typeof getAllProviders !== "function"
) {
throw new Error("Missing browser SDK exports");
}
if (
!AUTH_ERROR_PATTERNS.includes("unauthorized") ||
!isLikelyAuthError(new Error("401 Unauthorized"))
) {
throw new Error("Missing shared/types runtime exports");
}
if (!process.execArgv.includes("--use-system-ca")) {
throw new Error("Plugin runtime lost compiled system CA trust");
}
const runtime = resolvePluginRuntimeDirectory();
const bunfsRoot =
process.platform === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/";
if (!runtime?.replaceAll("\\", "/").startsWith(bunfsRoot)) {
throw new Error(`Expected the embedded runtime, got ${runtime}`);
}
const marker = process.env.CLINE_TEST_PLUGIN_RESULT_PATH;
if (!marker) {
throw new Error("Missing CLINE_TEST_PLUGIN_RESULT_PATH");
}
const nested = spawnSync(process.execPath, ["--telemetry-selfcheck"], {
env: process.env,
encoding: "utf8",
timeout: 10000,
});
if (nested.status !== 0) throw new Error(nested.stderr);
JSON.parse(nested.stdout);
const tool = createTool({
name: "embedded_ping",
description: "Packaged SDK tool",
inputSchema: { type: "object", properties: {} },
execute: async () => ({
pong: true,
execPath: process.execPath,
isMainThread,
bunRuntimeFlag: process.env.BUN_BE_BUN ?? null,
}),
});
writeFileSync(
marker,
JSON.stringify(
await tool.execute({}, { agentId: "embedded-smoke", iteration: 0 }),
),
);
api.registerTool(tool);
},
} satisfies AgentPlugin;
@@ -140,7 +140,7 @@ const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
throw new Error(
[
`cannot build ${platform} desktop bundles from ${host}.`,
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
"Tauri desktop bundles are produced on the target OS because the native bundle tools and bundled Cline CLI binary are platform-specific.",
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
].join("\n"),
);
@@ -285,7 +285,7 @@ const main = async () => {
if (!skipBuild) {
// Linux ships deb and rpm only. The AppImage target is skipped because
// linuxdeploy cannot process the Bun-compiled sidecar (ldd fails on it
// linuxdeploy cannot process the Bun-compiled Cline CLI (ldd fails on it
// and patchelf corrupts it), which aborts the whole bundle step.
if (platform === "linux") {
await $`bun run build:binary --bundles deb,rpm`;
@@ -2,7 +2,7 @@
#
# Invoked by the Tauri bundler through `bundle > windows > signCommand` (the
# desktop-publish workflow generates a config overlay pointing here), once per
# binary it stages: the main app exe, the code-sidecar external binary, the
# binary it stages: the main app exe, the bundled cline-cli external binary, the
# NSIS uninstaller, and the NSIS installer itself.
#
# Requirements (all provided by the desktop-publish Windows job):
@@ -46,6 +46,21 @@ describe("telemetryDefineArgs", () => {
const defines = defineMap(telemetryDefineArgs({}));
expect(defines["process.env.OTEL_TELEMETRY_ENABLED"]).toBe('""');
expect(defines["process.env.OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe('""');
expect(defines["process.env.OTEL_TRACES_EXPORTER"]).toBe('""');
});
it("inlines the trace policy vars the Langfuse relay reads at runtime", () => {
const defines = defineMap(
telemetryDefineArgs({
CLINE_TRACE_SAMPLE_PERCENT: "25",
CLINE_TRACE_RECORD_CONTENT: "true",
}),
);
expect(defines["process.env.CLINE_TRACE_SAMPLE_PERCENT"]).toBe('"25"');
expect(defines["process.env.CLINE_TRACE_RECORD_CONTENT"]).toBe('"true"');
const unset = defineMap(telemetryDefineArgs({}));
expect(unset["process.env.CLINE_TRACE_SAMPLE_PERCENT"]).toBe('""');
expect(unset["process.env.CLINE_TRACE_RECORD_CONTENT"]).toBe('""');
});
it("includes API key defines only when the vars are set", () => {
@@ -20,14 +20,20 @@ const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
* (sdk/packages/shared/src/services/telemetry-config.ts), plus the trace
* policy vars the Langfuse relay reads literally
* (sdk/packages/llms/src/services/langfuse-telemetry.ts). Always inlined,
* defaulting to "", so a packaged binary never falls back to runtime env.
* Mirrors BUILD_TIME_INLINED_ENV_VARS in apps/cli/script/compile-binary.ts,
* which bakes the same set into the bundled Cline CLI (the Hub daemon).
*/
const OTEL_ENV_VARS = [
"OTEL_TELEMETRY_ENABLED",
"OTEL_METRICS_EXPORTER",
"OTEL_LOGS_EXPORTER",
"OTEL_TRACES_EXPORTER",
"CLINE_TRACE_SAMPLE_PERCENT",
"CLINE_TRACE_RECORD_CONTENT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
@@ -158,7 +158,7 @@ Section
Quit
CreateDirectory "$INSTDIR"
ClearErrors
FileOpen $0 "$INSTDIR\\code-sidecar.exe" w
FileOpen $0 "$INSTDIR\\cline-cli.exe" w
IfErrors 0 +3
SetErrorLevel 4
Quit
@@ -170,7 +170,7 @@ SectionEnd
Section "Uninstall"
!insertmacro NSIS_HOOK_PREUNINSTALL
ClearErrors
Delete "$INSTDIR\\code-sidecar.exe"
Delete "$INSTDIR\\cline-cli.exe"
Delete "$INSTDIR\\cline-app.exe"
IfErrors 0 +2
SetErrorLevel 5
@@ -201,28 +201,28 @@ SectionEnd
const dir = path.join(root, "fresh install");
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe(
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
"replaced",
);
}, 120_000);
test("stops all target x64 processes, releases the file, and preserves another install", async () => {
const dir = path.join(root, "Cline 'quoted' $ space (测试)");
const beta = await start(`${dir} Beta`, "code-sidecar.exe");
const beta = await start(`${dir} Beta`, "cline-cli.exe");
const targets = [
await start(dir, "cline-app.exe"),
await start(dir, "code-sidecar.exe"),
await start(dir, "code-sidecar.exe"),
await start(dir, "cline-cli.exe"),
await start(dir, "cline-cli.exe"),
];
// Windows must actually have the executable locked before the test.
expect(() =>
writeFileSync(path.join(dir, "code-sidecar.exe"), "locked"),
writeFileSync(path.join(dir, "cline-cli.exe"), "locked"),
).toThrow();
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
await waitFor(() =>
targets.every((p) => p.exitCode !== null || p.signalCode !== null),
);
expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe(
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
"replaced",
);
expect(beta.exitCode).toBeNull();
@@ -232,14 +232,25 @@ SectionEnd
test("uninstaller releases the same files before deleting them", async () => {
const dir = path.join(root, "uninstall");
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
copyFileSync(fixture, path.join(dir, "code-sidecar.exe"));
const target = await start(dir, "code-sidecar.exe");
copyFileSync(fixture, path.join(dir, "cline-cli.exe"));
const target = await start(dir, "cline-cli.exe");
// _?= disables NSIS's temp-copy trampoline so we wait for the real exit.
expect(
await run(path.join(dir, "uninstall.exe"), ["/S", `_?=${dir}`]),
).toBe(0);
await waitFor(() => target.exitCode !== null || target.signalCode !== null);
expect(existsSync(path.join(dir, "cline-cli.exe"))).toBe(false);
}, 120_000);
test("upgrade stops and removes the earlier backend executable", async () => {
const dir = path.join(root, "upgrade");
const legacy = await start(dir, "code-sidecar.exe");
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(0);
await waitFor(() => legacy.exitCode !== null || legacy.signalCode !== null);
expect(existsSync(path.join(dir, "code-sidecar.exe"))).toBe(false);
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
"replaced",
);
}, 120_000);
// Only silent mode is automatable: passive (/P, what the Tauri updater
@@ -249,9 +260,9 @@ SectionEnd
// RmRegisterResources rejects directories with ERROR_ACCESS_DENIED.
// This exercises a real native error, not an injected API mock.
mkdirSync(path.join(dir, "cline-app.exe"), { recursive: true });
writeFileSync(path.join(dir, "code-sidecar.exe"), "untouched");
writeFileSync(path.join(dir, "cline-cli.exe"), "untouched");
expect(await run(installer, ["/S", `/D=${dir}`])).toBe(2);
expect(readFileSync(path.join(dir, "code-sidecar.exe"), "utf8")).toBe(
expect(readFileSync(path.join(dir, "cline-cli.exe"), "utf8")).toBe(
"untouched",
);
expect(existsSync(path.join(dir, "uninstall.exe"))).toBe(false);
@@ -21,7 +21,6 @@ sidecar/
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
├── cline-auth.ts # Refresh-aware Cline auth token resolution
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
@@ -82,9 +81,16 @@ sessionManager.subscribe((event) => {
});
```
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
The packaged backend runs on the bundled Cline CLI and has it start the Hub:
`cline-cli hub ensure` reuses a compatible healthy Hub or starts one, and the
Hub daemon is that same CLI binary. Any Hub the backend itself has to start
later goes through the same binary (`setHubDaemonLauncher`). Startup discovery
and locking ensure concurrent clients converge on one Hub.
The backend ships Core’s complete `plugin-runtime` artifact beside its script.
The standalone CLI embeds the same artifact in its read-only Bun filesystem.
The script runner does not mount that filesystem, so Desktop uses its packaged
copy without extracting resources at startup.
Every create, restart, fork, and restore also attaches the serializable Desktop
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
@@ -210,9 +216,7 @@ Supported commands:
| `read_session_hooks` | Session data readers |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
| `get_desktop_settings` | `readDesktopSettings()` |
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
| `get_feature_flags` | PostHog flag snapshot + `isCloudAgentsEnabled()` (env override + `code-cloud-agents` rollout flag) |
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
| `list_cloud_models` | `CloudSessionManager.listModels()` (account-scoped Usage-Billing, ClinePass, and ClineFree catalogs; model IDs use the Cline cloud transport) |
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
@@ -9,7 +9,8 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionNotFoundError } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveClineDir, setClineDir } from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
@@ -116,7 +117,7 @@ function localRuntimeContext(
workspaceRoot,
sessionManager,
hubClient: {
command: vi.fn(async () => undefined),
command: vi.fn(async () => ({ ok: true, payload: {} })),
},
unsubscribeSessionEvents: () => {},
},
@@ -2233,14 +2234,30 @@ describe("workspace metadata prewarming", () => {
describe("runtime slash command expansion on send", () => {
const tempRoots: string[] = [];
let originalClineDir: string;
beforeEach(() => {
originalClineDir = resolveClineDir();
const settingsRoot = mkdtempSync(
join(tmpdir(), "desktop-command-settings-"),
);
tempRoots.push(settingsRoot);
setClineDir(settingsRoot);
});
afterEach(() => {
setClineDir(originalClineDir);
for (const dir of tempRoots) {
rmSync(dir, { recursive: true, force: true });
}
tempRoots.length = 0;
});
function goalCatalog(workspacePath: string) {
return {
catalog: { workspacePath, status: "ready", commands: [{ name: "goal" }] },
};
}
function createWorkspaceWithSkill(): string {
const workspace = mkdtempSync(join(tmpdir(), "desktop-slash-send-"));
tempRoots.push(workspace);
@@ -2328,15 +2345,95 @@ Follow the desktop send workflow instructions.`,
expect(session.prompt).toBe("/desktop-send-skill write the docs");
});
it("still sends a skill command when a workspace plugin fails to load", async () => {
it.each([
"local",
"ssh",
] as const)("dispatches plugin commands through the owning %s hub", async (kind) => {
const workspace = createWorkspaceWithSkill();
const pluginsDir = join(workspace, ".cline", "plugins");
mkdirSync(pluginsDir, { recursive: true });
writeFileSync(
join(pluginsDir, "broken.js"),
"export default { name: 'broken', manifest: { capabilities: ['bogus'] }, setup() {} };",
);
const { ctx, send, sessionId } = createContext(workspace);
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
binding.kind = kind;
const liveSession = ctx.liveSessions.get(sessionId);
if (liveSession) liveSession.config.apiKey = "test-key";
binding.sessionManager.updateSessionConnection = vi.fn(async () => {});
const command = vi.fn(async (name: string) => ({
version: "v1" as const,
ok: true,
payload:
name === "plugins.commands.list"
? goalCatalog(workspace)
: {
result: {
reply: "Goal set",
submitPrompt: "expanded goal\nnext line",
},
},
}));
binding.hubClient.command = command;
const result = await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/goal original",
});
expect(result).toMatchObject({
result: { finishReason: "completed", text: "done" },
});
expect(command).toHaveBeenCalledWith(
"plugins.commands.run",
expect.objectContaining({
workspacePath: workspace,
sessionId,
prompt: "/goal original",
}),
sessionId,
{ timeoutMs: null, retryAfterDispatch: false },
);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ prompt: "expanded goal\nnext line" }),
);
});
it("targets the session's workspace root when its cwd is a subdirectory", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, session, sessionId } = createContext(workspace);
Object.assign(session.config, {
cwd: join(workspace, "packages", "app"),
workspaceRoot: workspace,
});
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
const command = vi.fn(async (name: string) => ({
version: "v1" as const,
ok: true,
payload:
name === "plugins.commands.list"
? goalCatalog(workspace)
: { result: { reply: "Goal set" } },
}));
binding.hubClient.command = command;
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/goal original",
});
expect(command).toHaveBeenCalledWith(
"plugins.commands.run",
expect.objectContaining({ workspacePath: workspace, sessionId }),
sessionId,
{ timeoutMs: null, retryAfterDispatch: false },
);
});
it("still sends a skill command when the plugin catalog cannot be read", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, send, sessionId } = createContext(workspace);
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
const command = vi.fn(async () => {
throw new Error("hub connection closed");
});
binding.hubClient.command = command;
await handleChatSessionCommand(ctx, {
action: "send",
@@ -2344,6 +2441,12 @@ Follow the desktop send workflow instructions.`,
prompt: "/desktop-send-skill write the docs",
});
expect(command).not.toHaveBeenCalledWith(
"plugins.commands.run",
expect.anything(),
expect.anything(),
expect.anything(),
);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "/desktop-send-skill write the docs",
@@ -10,6 +10,7 @@ import {
createSessionCompactionState,
createUserInstructionConfigService,
findCheckpointForRun,
formatSessionPluginIssue,
getCoreBuiltinToolCatalog,
isSessionNotFoundError,
isSkillsToolAvailable,
@@ -36,6 +37,7 @@ import {
type ConsecutiveMistakeLimitContext,
type ConsecutiveMistakeLimitDecision,
formatUserCommandBlock,
type SessionPluginIssue,
} from "@cline/shared";
import {
deleteMaterializedAttachments,
@@ -1151,9 +1153,24 @@ async function handleStart(
cwd,
workspaceRoot,
environmentId: binding.environmentId,
...pluginWarningsOf(startResult.pluginIssues),
};
}
/**
* Plugins the session asked for but could not load, as display lines.
* Failures that happen later (setup and hooks run on the first turn) arrive
* as notices on the session stream instead.
*/
function pluginWarningsOf(
issues: ReadonlyArray<SessionPluginIssue> | undefined,
): { pluginWarnings?: string[] } {
const pluginWarnings = (issues ?? [])
.filter((issue) => issue.state === "failed")
.map(formatSessionPluginIssue);
return pluginWarnings.length > 0 ? { pluginWarnings } : {};
}
async function handleAttach(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
@@ -1641,8 +1658,8 @@ async function handleSend(
const workspacePath =
readWorkspacePath(session?.config ?? request.config) ??
ctx.localWorkspaceRoot;
// Plugin slash commands (`api.registerCommand`) run here in the sidecar,
// as in the CLI: the handler's reply goes to the webview as a toast and
// Plugin slash commands execute in the session runtime.
// The handler's reply goes to the webview as a toast and
// only its `submitPrompt` (if any) reaches the model.
const commandName = prompt.match(/^\/(\S+)/)?.[1]?.toLowerCase();
if (commandName === "compact") {
@@ -1669,10 +1686,13 @@ async function handleSend(
return { sessionId, ok: true, commandHandled: true };
}
const pluginCommand =
commandName &&
!BUILTIN_SLASH_COMMAND_NAMES.has(commandName) &&
binding.kind !== "ssh"
? await runPluginSlashCommand(ctx, { workspacePath, prompt })
commandName && !BUILTIN_SLASH_COMMAND_NAMES.has(commandName)
? await runPluginSlashCommand(ctx, {
workspacePath,
prompt,
sessionId,
environmentId: binding.environmentId,
})
: undefined;
if (pluginCommand) {
if (pluginCommand.reply) {
@@ -1689,15 +1709,15 @@ async function handleSend(
// Dispatch the expanded or rewritten instructions, but keep the raw
// `/command` token as the session's display prompt.
const runtimePrompt =
binding.kind === "ssh"
pluginCommand?.submitPrompt ??
(binding.kind === "ssh"
? prompt
: (pluginCommand?.submitPrompt ??
(await resolveDesktopRuntimePrompt(
: await resolveDesktopRuntimePrompt(
ctx,
workspacePath,
prompt,
request.config?.mode ?? session?.config?.mode,
)));
));
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime";
describe("ensureHubWithDesktopCli", () => {
it("asks the bundled CLI to start or reuse the Hub and returns only its URL", async () => {
const run = vi.fn(
async () =>
'warming up\n{"url":"ws://127.0.0.1:25463/hub","authToken":"secret","cwd":"/work"}\n',
);
await expect(
ensureHubWithDesktopCli("/opt/cline/cline-cli", "/work", {
env: { PATH: "/usr/bin" },
run,
}),
).resolves.toEqual({ url: "ws://127.0.0.1:25463/hub" });
expect(run).toHaveBeenCalledWith(
"/opt/cline/cline-cli",
["hub", "ensure", "--json", "--cwd", "/work"],
{
cwd: "/work",
env: {
PATH: "/usr/bin",
CLINE_NO_AUTO_UPDATE: "1",
CLINE_HUB_LAUNCHER_CLIENT: "cline-desktop",
CLINE_HUB_LAUNCHER_VERSION: expect.stringMatching(/^\d+\.\d+\.\d+/),
CLINE_HUB_LAUNCHER_TRANSPORT: "local",
},
timeoutMs: 60_000,
},
);
});
it("rejects when the CLI does not report a Hub URL", async () => {
await expect(
ensureHubWithDesktopCli("/opt/cline/cline-cli", "/work", {
env: {},
run: async () => "",
}),
).rejects.toThrow("did not report a Hub URL");
});
});
describe("adoptDesktopCliRuntime", () => {
it("routes Hub spawns through the bundled CLI and restores the workspace cwd", () => {
const env: NodeJS.ProcessEnv = {
BUN_BE_BUN: "1",
CLINE_DESKTOP_CLI_PATH:
"/Applications/Cline.app/Contents/MacOS/cline-cli",
CLINE_DESKTOP_WORKSPACE_ROOT: "/Users/dev/project",
};
const chdir = vi.fn();
const setLauncher = vi.fn();
const setPluginLauncher = vi.fn();
expect(
adoptDesktopCliRuntime({ env, chdir, setLauncher, setPluginLauncher }),
).toEqual({
cliPath: "/Applications/Cline.app/Contents/MacOS/cline-cli",
});
expect(env.BUN_BE_BUN).toBeUndefined();
expect(env.CLINE_DESKTOP_WORKSPACE_ROOT).toBeUndefined();
expect(env.CLINE_DESKTOP_CLI_PATH).toBe(
"/Applications/Cline.app/Contents/MacOS/cline-cli",
);
expect(chdir).toHaveBeenCalledWith("/Users/dev/project");
expect(setPluginLauncher).toHaveBeenCalledWith({
command: env.CLINE_DESKTOP_CLI_PATH,
argsPrefix: ["--plugin-sandbox"],
});
expect(setLauncher).toHaveBeenCalledWith({
command: "/Applications/Cline.app/Contents/MacOS/cline-cli",
});
});
it("keeps the default Hub launcher when no bundled CLI is provided", () => {
const chdir = vi.fn();
const setLauncher = vi.fn();
const setPluginLauncher = vi.fn();
expect(
adoptDesktopCliRuntime({
env: {},
chdir,
setLauncher,
setPluginLauncher,
}),
).toEqual({
cliPath: undefined,
});
expect(chdir).not.toHaveBeenCalled();
expect(setLauncher).not.toHaveBeenCalled();
expect(setPluginLauncher).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,127 @@
import { execFile } from "node:child_process";
import { setHubDaemonLauncher, setPluginSandboxLauncher } from "@cline/core";
import { withHubLauncherIdentity } from "@cline/shared";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
/** Absolute path of the Cline CLI binary the desktop shell bundles and launched us with. */
export const DESKTOP_CLI_PATH_ENV = "CLINE_DESKTOP_CLI_PATH";
/** Workspace root the shell resolved; the backend starts in its own resource directory. */
export const DESKTOP_WORKSPACE_ROOT_ENV = "CLINE_DESKTOP_WORKSPACE_ROOT";
/** Makes a compiled Bun executable run a script instead of its embedded entrypoint. */
export const BUN_BE_BUN_ENV = "BUN_BE_BUN";
export function resolveDesktopCliPath(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return env[DESKTOP_CLI_PATH_ENV]?.trim() || undefined;
}
/**
* The packaged backend is a script running on the bundled Cline CLI's embedded
* runtime. Adopt that host before anything spawns: every child (the Hub
* daemon, connectors, agent tools) must see the CLI binary as the CLI, not as
* a script runner, and Hubs this process starts must be CLI-managed Hubs.
*/
export function adoptDesktopCliRuntime(
options: {
env?: NodeJS.ProcessEnv;
chdir?: (directory: string) => void;
setLauncher?: typeof setHubDaemonLauncher;
setPluginLauncher?: typeof setPluginSandboxLauncher;
} = {},
): { cliPath?: string } {
const env = options.env ?? process.env;
const chdir = options.chdir ?? ((directory) => process.chdir(directory));
const setLauncher = options.setLauncher ?? setHubDaemonLauncher;
delete env[BUN_BE_BUN_ENV];
// The shell starts the runtime outside the workspace so it cannot pick up a
// workspace .env or bunfig.toml; workspace-relative code expects that cwd.
const workspaceRoot = env[DESKTOP_WORKSPACE_ROOT_ENV]?.trim();
delete env[DESKTOP_WORKSPACE_ROOT_ENV];
if (workspaceRoot) {
chdir(workspaceRoot);
}
const cliPath = resolveDesktopCliPath(env);
if (cliPath) {
setLauncher({ command: cliPath });
(options.setPluginLauncher ?? setPluginSandboxLauncher)({
command: cliPath,
argsPrefix: ["--plugin-sandbox"],
});
}
return { cliPath };
}
const HUB_ENSURE_TIMEOUT_MS = 60_000;
export type RunCliCommand = (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
) => Promise<string>;
const runCliCommand: RunCliCommand = (command, args, options) =>
new Promise((resolve, reject) => {
execFile(
command,
args,
{
cwd: options.cwd,
env: options.env,
timeout: options.timeoutMs,
windowsHide: true,
maxBuffer: 1024 * 1024,
},
(error, stdout, stderr) => {
if (error) {
const detail = String(stderr).trim();
reject(
new Error(detail ? `${error.message}: ${detail}` : error.message),
);
return;
}
resolve(String(stdout));
},
);
});
/**
* Has the bundled CLI start the shared Hub, or reuse a compatible healthy
* one, before the session manager connects. Returns the Hub URL (never the
* auth token, which callers may log).
*/
export async function ensureHubWithDesktopCli(
cliPath: string,
workspaceRoot: string,
options: { env?: NodeJS.ProcessEnv; run?: RunCliCommand } = {},
): Promise<{ url: string }> {
const run = options.run ?? runCliCommand;
const stdout = await run(
cliPath,
["hub", "ensure", "--json", "--cwd", workspaceRoot],
{
cwd: workspaceRoot,
// The CLI spawns the daemon, so stamp the desktop as its launcher
// here or the hub's telemetry would say the CLI started it.
env: withHubLauncherIdentity(
{ ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
{
client: DESKTOP_CLIENT_CONTEXT.name,
version: DESKTOP_CLIENT_CONTEXT.version,
transport: "local",
},
),
timeoutMs: HUB_ENSURE_TIMEOUT_MS,
},
);
const line = stdout
.split(/\r?\n/)
.map((value) => value.trim())
.filter((value) => value.startsWith("{"))
.at(-1);
const parsed = line ? (JSON.parse(line) as { url?: unknown }) : undefined;
if (typeof parsed?.url !== "string" || !parsed.url) {
throw new Error("cline hub ensure did not report a Hub URL");
}
return { url: parsed.url };
}
@@ -30,7 +30,6 @@ import {
fetchConnectableToolkits,
initiateConnection,
listConnections,
listToolkitTools,
waitForConnectionActive,
} from "./cline-connectors-api";
@@ -295,68 +294,59 @@ describe("connector router contract", () => {
);
});
it("loads all 47 schemas across pages and preserves input_parameters and version", async () => {
const tools = Array.from({ length: 47 }, (_, i) => ({
slug: `GOOGLECALENDAR_TOOL_${i}`,
version: "v1",
input_parameters: {
type: "object",
properties: { to: { type: "string" } },
required: ["to"],
},
it("loads all connections across pages, including empty intermediate pages", async () => {
const connections = Array.from({ length: 47 }, (_, i) => ({
...account,
id: `account_${i}`,
}));
const fetchMock = vi
.fn()
.mockResolvedValueOnce(page(tools.slice(0, 20), "next/+="))
.mockResolvedValueOnce(page(connections.slice(0, 20), "next/+="))
.mockResolvedValueOnce(page([], "last"))
.mockResolvedValueOnce(page(tools.slice(20)));
.mockResolvedValueOnce(page(connections.slice(20)));
global.fetch = fetchMock as unknown as typeof fetch;
expect(await listToolkitTools("googlecalendar")).toEqual(tools);
expect(await listConnections()).toEqual(connections);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"https://core-api.staging.int.cline.bot/api/v1/connectors/toolkits/googlecalendar/tools?limit=200",
"https://core-api.staging.int.cline.bot/api/v1/connectors/toolkits/googlecalendar/tools?limit=200&cursor=next%2F%2B%3D",
"https://core-api.staging.int.cline.bot/api/v1/connectors/toolkits/googlecalendar/tools?limit=200&cursor=last",
"https://core-api.staging.int.cline.bot/api/v1/connectors/connections?limit=200",
"https://core-api.staging.int.cline.bot/api/v1/connectors/connections?limit=200&cursor=next%2F%2B%3D",
"https://core-api.staging.int.cline.bot/api/v1/connectors/connections?limit=200&cursor=last",
]);
});
it("rejects a failed tool page instead of returning partial schemas", async () => {
it("rejects a failed connection page instead of returning partial connections", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(page([{ slug: "GMAIL_SEND_EMAIL" }], "next"))
.mockResolvedValueOnce(page([account], "next"))
.mockResolvedValueOnce(
Response.json({ error: "Tools unavailable" }, { status: 502 }),
Response.json({ error: "Connections unavailable" }, { status: 502 }),
) as unknown as typeof fetch;
await expect(listToolkitTools("gmail")).rejects.toThrow(
"Tools unavailable",
);
await expect(listConnections()).rejects.toThrow("Connections unavailable");
});
it("rejects a malformed later tool page", async () => {
it("rejects a malformed later connection page", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(page([{ slug: "GMAIL_SEND_EMAIL" }], "next"))
.mockResolvedValueOnce(page([account], "next"))
.mockResolvedValueOnce(
Response.json({ success: true, data: { items: [] } }),
) as unknown as typeof fetch;
await expect(listToolkitTools("gmail")).rejects.toThrow(
/Invalid connectors page/,
);
await expect(listConnections()).rejects.toThrow(/Invalid connectors page/);
});
it("rejects cyclic tool pagination", async () => {
it("rejects cyclic connection pagination", async () => {
global.fetch = vi.fn(async () =>
page([{ slug: "GMAIL_SEND_EMAIL" }], "same"),
page([account], "same"),
) as unknown as typeof fetch;
await expect(listToolkitTools("gmail")).rejects.toThrow(/repeated cursor/);
await expect(listConnections()).rejects.toThrow(/repeated cursor/);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("does not combine tool pages from different accounts", async () => {
it("does not combine connection pages from different accounts", async () => {
global.fetch = vi.fn(async () => {
identity.accountId = "account-b";
return page([{ slug: "GMAIL_SEND_EMAIL" }], "next");
return page([account], "next");
}) as unknown as typeof fetch;
await expect(listToolkitTools("gmail")).rejects.toMatchObject({
await expect(listConnections()).rejects.toMatchObject({
status: 401,
});
expect(global.fetch).toHaveBeenCalledOnce();
@@ -68,14 +68,6 @@ export type ConnectorInitiateResult = {
redirectUrl?: string;
};
export type ConnectorToolSchema = {
slug: string;
name?: string;
description?: string;
version?: string;
input_parameters?: unknown;
};
type ConnectorPage<T> = {
items: T[];
nextToken: string;
@@ -209,7 +201,7 @@ export async function listConnections(
}
async function listAllConnectorPages<T>(
path: "/connections" | "/toolkits" | `/toolkits/${string}/tools`,
path: "/connections" | "/toolkits",
ctx?: ConnectorsRequestContext,
): Promise<T[]> {
ctx = { ...ctx, accountId: ctx?.accountId ?? getClineAccountId() };
@@ -268,21 +260,6 @@ export async function deleteConnection(
);
}
/**
* `GET /api/v1/connectors/toolkits/{slug}/tools` — complete toolkit schemas
* in provider order. Fetch every page before
* returning so failed pagination cannot persist an incomplete tool set.
*/
export async function listToolkitTools(
toolkit: ComposioToolkitSlug,
ctx?: ConnectorsRequestContext,
): Promise<ConnectorToolSchema[]> {
return listAllConnectorPages<ConnectorToolSchema>(
`/toolkits/${encodeURIComponent(toolkit)}/tools`,
ctx,
);
}
/**
* Waits for a just-initiated connection to turn ACTIVE by polling the
* caller's connections while the user finishes the OAuth flow in the
@@ -1,4 +1,8 @@
import { HubCommandError, HubTransportError } from "@cline/core";
import {
HubCommandError,
HubTransportError,
type NodeHubClient,
} from "@cline/core";
import type { HubEventEnvelope } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import {
@@ -55,8 +59,11 @@ async function createFixture() {
name: string,
_payload?: unknown,
_sessionId?: string,
_options?: unknown,
) => reply(name),
options?: Parameters<NodeHubClient["command"]>[3],
) => {
options?.beforeDispatch?.();
return reply(name);
},
);
const connection: Awaited<
ReturnType<CloudSessionManager["ensureConnection"]>
@@ -106,6 +113,51 @@ const transportError = () =>
new HubTransportError("hub_connection_closed", "socket closed");
describe("CloudSessionManager state", () => {
it("honors the dispatch hook before sending through the desktop adapter", async () => {
const { manager, command, reply, updateTitle, live } =
await createFixture();
const dispatch = vi.fn();
command.mockImplementation(async (name, _payload, _sessionId, options) => {
options?.beforeDispatch?.();
if (name === "session.send_input") dispatch();
return reply(name);
});
const beforeDispatch = vi.fn<() => void>(() => {
throw new Error("Could not save continuation");
});
await expect(
manager.send(
"ses-outer",
"Continue the task",
undefined,
undefined,
undefined,
{
beforeDispatch,
},
),
).rejects.toThrow("Could not save continuation");
expect(beforeDispatch).toHaveBeenCalledOnce();
expect(dispatch).not.toHaveBeenCalled();
expect(updateTitle).not.toHaveBeenCalled();
expect(live.messages).toEqual([]);
beforeDispatch.mockImplementation(() => {});
await manager.send(
"ses-outer",
"Continue the task",
undefined,
undefined,
undefined,
{
beforeDispatch,
},
);
expect(beforeDispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledOnce();
});
it("keeps a pending send cancelled when deletion clears its abort token", async () => {
const { manager, connection, ensureAttached, command } =
await createFixture();
@@ -1,6 +1,7 @@
import { ClineAccountService, ProviderSettingsManager } from "@cline/core";
import {
type CloudCreationOptions,
type CloudSendLifecycle,
CloudSessionApi,
CloudSessionController,
type CloudSessionControllerOptions,
@@ -178,6 +179,7 @@ export class CloudSessionManager extends CloudSessionController {
delivery?: "queue" | "steer",
modelId?: string,
images?: string[],
lifecycle?: CloudSendLifecycle,
) {
// Existing desktop commands update this local preference before lazy inner creation.
const config = this.ctx.liveSessions.get(id)?.config;
@@ -197,7 +199,7 @@ export class CloudSessionManager extends CloudSessionController {
: {}),
});
try {
return await super.send(id, prompt, delivery, modelId, images);
return await super.send(id, prompt, delivery, modelId, images, lifecycle);
} finally {
const snapshot = this.getSnapshot(id);
if (snapshot)
@@ -9,12 +9,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCloudSessionManager } from "./cloud-sessions";
import { handleCommand } from "./commands";
import { createSidecarContext } from "./context";
import { setCloudSessionsEnabled } from "./desktop-settings";
import {
getDesktopFeatureFlagsService,
resetDesktopFeatureFlagsForTesting,
} from "./feature-flags";
import type { SidecarContext } from "./types";
import { recordHubActivity } from "./hub-admin";
import {
LOCAL_ENVIRONMENT_ID,
type SessionRuntimeBinding,
type SidecarContext,
} from "./types";
function createContext(): {
ctx: SidecarContext;
@@ -47,6 +51,42 @@ afterEach(() => {
});
describe("desktop settings commands", () => {
it("only returns hub notification content to trusted desktop connections", async () => {
const { ctx } = createContext();
const command = vi
.fn()
.mockResolvedValue({ ok: true, payload: { clients: [] } });
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
hubClient: { command, getUrl: () => "ws://127.0.0.1:1234/hub" },
} as unknown as SessionRuntimeBinding);
recordHubActivity(ctx, {
event: "ui.notify",
payload: { title: "Task completed", body: "Private task reply" },
});
await expect(handleCommand(ctx, "get_hub_status")).rejects.toThrow(
"hub status requires a trusted desktop connection",
);
await expect(
handleCommand(ctx, "get_hub_status", undefined, {
connection: { send: vi.fn(), data: { canApproveTools: false } },
}),
).rejects.toThrow("hub status requires a trusted desktop connection");
expect(command).not.toHaveBeenCalled();
const result = await handleCommand(ctx, "get_hub_status", undefined, {
connection: { send: vi.fn(), data: { canApproveTools: true } },
});
expect(command).toHaveBeenCalledWith("client.list");
expect(result).toMatchObject({
events: [{ title: "Task completed", detail: "Private task reply" }],
});
});
it("rejects hub restarts from untrusted connections", async () => {
const { ctx } = createContext();
await expect(handleCommand(ctx, "restart_hub")).rejects.toThrow(
"trusted desktop connection",
);
});
it("lists only current gateway transcription models for voice input", async () => {
const { ctx } = createContext();
vi.stubGlobal(
@@ -79,7 +119,7 @@ describe("desktop settings commands", () => {
"list_chat_sessions",
"list_discovered_sessions",
"search_sessions",
])("hides cloud rows from %s unless rollout and user toggle are enabled", async (command) => {
])("hides cloud rows from %s unless the rollout flag is enabled", async (command) => {
const { ctx } = createContext();
for (const target of ["local", "cloud"] as const) {
ctx.liveSessions.set(target, {
@@ -113,16 +153,13 @@ describe("desktop settings commands", () => {
.map((row) => row.sessionId)
.sort();
expect(await rows()).toEqual(["local"]);
expect(discovery).not.toHaveBeenCalled();
setCloudSessionsEnabled(true);
expect(await rows()).toEqual(["cloud", "local"]);
discovery.mockImplementationOnce(async () => {
setCloudSessionsEnabled(false);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
return cloudRows;
});
expect(await rows()).toEqual(["local"]);
setCloudSessionsEnabled(true);
delete process.env.CLINE_CODE_CLOUD_AGENTS;
discovery.mockClear();
flags.hydrateCache({
userId: null,
@@ -172,45 +209,4 @@ describe("desktop settings commands", () => {
expect.objectContaining({ id: "cline-cloud/cloud-only" }),
);
});
it("rejects a non-boolean cloud sessions toggle value", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: "yes",
}),
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
expect(events).toEqual([]);
});
it.each([
true,
false,
])("persists toggle %s and refreshes cloud history immediately", async (enabled) => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: enabled,
}),
).resolves.toEqual({ cloudSessionsEnabled: enabled });
expect(events).toEqual([
{ name: "cloud_sessions_changed", payload: { environmentId: "local" } },
{
name: "feature_flags_changed",
payload: {
cloudAgents: false,
cloudAgentsAvailable: false,
environmentId: "local",
},
},
]);
await expect(
handleCommand(ctx, "get_feature_flags", {}),
).resolves.toMatchObject({ cloudAgents: false });
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: enabled });
});
});
+22 -41
View File
@@ -126,17 +126,13 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
readDesktopSettings,
setCloudSessionsEnabled,
} from "./desktop-settings";
import { writeDiagnosticsReport } from "./diagnostics";
import {
identifyDesktopFeatureFlagsAccount,
isCloudAgentsAvailable,
isCloudAgentsEnabled,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { getHubStatus, restartHub } from "./hub-admin";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
installMarketplaceEntryForDesktopCommand,
@@ -166,10 +162,7 @@ import {
sessionLogPath,
sharedSessionDataDir,
} from "./paths";
import {
getPluginCommandService,
warmPluginCommandService,
} from "./plugin-commands";
import { listPluginCommands } from "./plugin-commands";
import { getPullRequestStatus } from "./pull-request";
import { capturePullRequestEvent } from "./pull-request-telemetry";
import { resolveDesktopRemoteHelper } from "./remote-helper";
@@ -2207,8 +2200,6 @@ export async function handleCommand(
};
}
if (command === "get_feature_flags") {
// Resolve flags in the sidecar: available shows the opt-in;
// enabled requires both the rollout flag and the user's opt-in.
const snapshot = await refreshDesktopFeatureFlags({
logger: ctx.logger,
telemetry: ctx.telemetry,
@@ -2219,10 +2210,6 @@ export async function handleCommand(
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
cloudAgentsAvailable: isCloudAgentsAvailable({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
};
}
if (command === "list_cloud_models") {
@@ -2242,6 +2229,20 @@ export async function handleCommand(
return "";
}
if (command === "get_hub_status") {
if (!options?.connection?.data?.canApproveTools) {
throw new Error("hub status requires a trusted desktop connection");
}
return await getHubStatus(ctx, await ensureSharedHubClient(ctx));
}
if (command === "restart_hub") {
if (!options?.connection?.data?.canApproveTools) {
throw new Error("hub restart requires a trusted desktop connection");
}
await restartHub(ctx, await ensureSharedHubClient(ctx));
return await getHubStatus(ctx, await ensureSharedHubClient(ctx));
}
// ── Managed hub upgrade ───────────────────────────────────────────
if (command === "hub_upgrade") {
// Replacing the shared Hub interrupts other clients' sessions, so it
@@ -3221,9 +3222,6 @@ export async function handleCommand(
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
if (command === "get_desktop_settings") {
return readDesktopSettings();
}
if (command === "export_diagnostics") {
const sessionIds = Array.isArray(args?.sessionIds)
? args.sessionIds.filter(
@@ -3234,18 +3232,6 @@ export async function handleCommand(
openFileInEditor(dirname(result.path));
return result;
}
if (command === "set_cloud_sessions_enabled") {
if (typeof args?.cloud_sessions_enabled !== "boolean") {
throw new Error("cloud_sessions_enabled must be a boolean");
}
const settings = setCloudSessionsEnabled(args.cloud_sessions_enabled);
broadcastEvent(ctx, "cloud_sessions_changed", {});
broadcastEvent(ctx, "feature_flags_changed", {
cloudAgents: isCloudAgentsEnabled(),
cloudAgentsAvailable: isCloudAgentsAvailable(),
});
return settings;
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
@@ -3568,22 +3554,17 @@ export async function handleCommand(
String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot,
);
}
if (command === "warm_plugin_commands") {
// The webview reports whichever local workspace it has adopted so the
// plugin sandbox is loaded before the slash menu first needs it.
const binding = getCommandRuntimeBinding(ctx, args);
const workspacePath = String(args?.workspacePath ?? "").trim();
if (binding.kind === "local" && workspacePath) {
warmPluginCommandService(ctx, workspacePath);
}
return { environmentId: binding.environmentId };
}
if (command === "list_plugin_commands") {
// Same workspace the session will execute in (handleSend), so the menu
// only offers commands that can actually run there.
const workspacePath =
String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot;
return await getPluginCommandService(ctx, workspacePath).listCommands();
return await listPluginCommands(
ctx,
workspacePath,
typeof args?.sessionId === "string" ? args.sessionId : undefined,
requestedEnvironmentId(args),
);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
@@ -35,7 +35,6 @@ const proxy = vi.hoisted(() => ({
initiateConnection: vi.fn(),
listConnections: vi.fn(),
deleteConnection: vi.fn(),
listToolkitTools: vi.fn(),
waitForConnectionActive: vi.fn(),
}));
@@ -49,7 +48,6 @@ vi.mock("./cline-connectors-api", async () => {
initiateConnection: proxy.initiateConnection,
listConnections: proxy.listConnections,
deleteConnection: proxy.deleteConnection,
listToolkitTools: proxy.listToolkitTools,
waitForConnectionActive: proxy.waitForConnectionActive,
};
});
@@ -117,7 +115,6 @@ beforeEach(() => {
__resetComposioCachesForTesting();
// Sensible defaults; individual tests override.
proxy.listConnections.mockResolvedValue([]);
proxy.listToolkitTools.mockResolvedValue([]);
proxy.deleteConnection.mockResolvedValue(undefined);
proxy.fetchConnectableToolkits.mockResolvedValue([]);
// waitForConnectionActive resolves immediately by default (connection active).
@@ -202,11 +199,8 @@ describe("account isolation", () => {
it("keeps an abandoned connection's tombstone under its original account", async () => {
const dir = useTempDataDir();
proxy.initiateConnection.mockResolvedValue({
connectedAccountId: "a-pending",
});
let complete!: (items: unknown[]) => void;
proxy.listToolkitTools.mockImplementationOnce(
let complete!: (result: { connectedAccountId: string }) => void;
proxy.initiateConnection.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = resolve;
@@ -226,7 +220,7 @@ describe("account isolation", () => {
proxy.deleteConnection.mockRejectedValue(
new ConnectorsApiError("account changed", 401),
);
complete([{ slug: "GITHUB_LIST_ISSUES" }]);
complete({ connectedAccountId: "a-pending" });
expect((await request).alreadyConnected).toBeUndefined();
expect(readStateFile(dir).toolkits?.github?.connectedAccountId).toBe(
"b-github",
@@ -309,7 +303,6 @@ describe("getComposioStatus", () => {
expect(
status.integrations.find((entry) => entry.toolkit === "gmail")?.status,
).toBe("not_connected");
expect(proxy.listToolkitTools).not.toHaveBeenCalled();
});
it("preserves stored connectors when the complete remote list cannot be fetched", async () => {
@@ -359,69 +352,11 @@ describe("getComposioStatus", () => {
const status = await getComposioStatus();
const github = status.integrations.find((e) => e.toolkit === "github");
expect(github?.status).toBe("connected");
expect(github?.toolNames).toEqual(["Create issue"]);
expect(proxy.listToolkitTools).not.toHaveBeenCalled();
expect(github).not.toHaveProperty("toolNames");
});
it.each([
0, 20,
])("refreshes a connected toolkit cached with %i tools to all 47 tools", async (cachedCount) => {
it("refreshes connection metadata without persisting app schemas", async () => {
const dir = useTempDataDir();
makeAvailable([
{
id: "ca_wedged",
status: "ACTIVE",
toolkit: { slug: "googlecalendar" },
},
]);
const tools = Array.from({ length: 47 }, (_, i) => ({
slug: `GOOGLECALENDAR_TOOL_${i}`,
name: `Calendar tool ${i}`,
}));
proxy.listToolkitTools.mockResolvedValue(tools);
const connection = {
connectedAccountId: "ca_wedged",
connectedAt: "2026-08-28T00:00:00.000Z",
name: "Google Calendar",
logo: "https://logos/googlecalendar.png",
};
writeState(dir, {
toolkits: {
googlecalendar: {
...connection,
tools: tools.slice(0, cachedCount),
},
},
});
const before = await getComposioStatus();
const cached = before.integrations.find(
(e) => e.toolkit === "googlecalendar",
);
expect(cached?.toolNames).toHaveLength(cachedCount);
if (cachedCount === 0) {
expect(cached?.error).toMatch(/no tools were retrieved/);
}
expect(proxy.listToolkitTools).not.toHaveBeenCalled();
const status = await getComposioStatus({ refresh: true });
const healed = status.integrations.find(
(e) => e.toolkit === "googlecalendar",
);
expect(healed?.toolNames).toEqual(tools.map((tool) => tool.name));
expect(healed?.error).toBeUndefined();
expect(readStateFile(dir).toolkits?.googlecalendar).toEqual({
...connection,
tools,
});
});
it("preserves cached schemas after a failed refresh and retries on the next refresh", async () => {
const dir = useTempDataDir();
const stored = {
connectedAccountId: "ca_calendar",
connectedAt: "2026-08-28T00:00:00.000Z",
tools: [{ slug: "GOOGLECALENDAR_LIST_EVENTS" }],
};
writeState(dir, { toolkits: { googlecalendar: stored } });
makeAvailable([
{
id: "ca_calendar",
@@ -429,21 +364,23 @@ describe("getComposioStatus", () => {
toolkit: { slug: "googlecalendar" },
},
]);
const tools = [...stored.tools, { slug: "GOOGLECALENDAR_CREATE_EVENT" }];
proxy.listToolkitTools
.mockRejectedValueOnce(new ConnectorsApiError("later page failed", 502))
.mockResolvedValueOnce(tools);
await getComposioStatus({ refresh: true });
expect(readStateFile(dir).toolkits?.googlecalendar).toEqual(stored);
const connection = {
connectedAccountId: "ca_calendar",
connectedAt: "2026-08-28T00:00:00.000Z",
name: "Google Calendar",
};
writeState(dir, { toolkits: { googlecalendar: connection } });
const status = await getComposioStatus({ refresh: true });
expect(readStateFile(dir).toolkits?.googlecalendar?.tools).toEqual(tools);
expect(
status.integrations.find((e) => e.toolkit === "googlecalendar")
?.toolNames,
).toHaveLength(2);
const summary = status.integrations.find(
(e) => e.toolkit === "googlecalendar",
);
expect(summary?.status).toBe("connected");
expect(summary?.error).toBeUndefined();
expect(readStateFile(dir).toolkits?.googlecalendar).toEqual(connection);
expect(summary).not.toHaveProperty("toolNames");
});
it("does not restore a connector disconnected while its schemas are refreshing", async () => {
it("does not restore a connector disconnected while remote connections are refreshing", async () => {
const dir = useTempDataDir();
const tools = [{ slug: "GOOGLECALENDAR_LIST_EVENTS" }];
writeState(dir, {
@@ -458,8 +395,9 @@ describe("getComposioStatus", () => {
toolkit: { slug: "googlecalendar" },
},
]);
await getComposioStatus(); // Cache the availability probe before delaying refresh.
let complete!: (tools: unknown[]) => void;
proxy.listToolkitTools.mockImplementationOnce(
proxy.listConnections.mockResolvedValueOnce([]).mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = resolve;
@@ -468,7 +406,13 @@ describe("getComposioStatus", () => {
const refresh = getComposioStatus({ refresh: true });
await vi.waitFor(() => expect(complete).toBeTypeOf("function"));
await disconnectComposioToolkit("googlecalendar");
complete(tools);
complete([
{
id: "ca_calendar",
status: "ACTIVE",
toolkit: { slug: "googlecalendar" },
},
]);
const status = await refresh;
expect(readStateFile(dir).toolkits?.googlecalendar).toBeUndefined();
expect(
@@ -520,27 +464,26 @@ describe("listComposioToolkits", () => {
});
describe("connectComposioToolkit", () => {
it("initiates through the proxy, waits, and finalizes with fetched tools", async () => {
it("initiates through the proxy, waits, and finalizes connection metadata", async () => {
const dir = useTempDataDir();
makeAvailable();
proxy.initiateConnection.mockResolvedValue({
connectedAccountId: "ca_new",
redirectUrl: "https://connect.example/ca_new",
});
proxy.listToolkitTools.mockResolvedValue([{ slug: "GMAIL_SEND_EMAIL" }]);
const result = await connectComposioToolkit("gmail");
expect(result.redirectUrl).toContain("ca_new");
expect(proxy.initiateConnection).toHaveBeenCalledWith(
"gmail",
expect.anything(),
);
// The background waiter resolved immediately; the finalize wrote tools.
// The background waiter resolved immediately; the finalize wrote connection metadata.
await vi.waitFor(() => {
expect(readStateFile(dir).toolkits?.gmail?.connectedAccountId).toBe(
"ca_new",
);
});
expect(readStateFile(dir).toolkits?.gmail?.tools).toHaveLength(1);
expect(readStateFile(dir).toolkits?.gmail).not.toHaveProperty("tools");
});
it("finalizes a redirect-less connection inline and reports alreadyConnected", async () => {
@@ -550,22 +493,12 @@ describe("connectComposioToolkit", () => {
connectedAccountId: "ca_direct",
// no redirectUrl → already authorized
});
const input_parameters = {
type: "object",
properties: { repo: { type: "string" } },
required: ["repo"],
};
proxy.listToolkitTools.mockResolvedValue([
{ slug: "GITHUB_LIST_ISSUES", version: "v1", input_parameters },
]);
const result = await connectComposioToolkit("github");
expect(result.alreadyConnected).toBe(true);
expect(readStateFile(dir).toolkits?.github?.connectedAccountId).toBe(
"ca_direct",
);
expect(readStateFile(dir).toolkits?.github?.tools).toEqual([
{ slug: "GITHUB_LIST_ISSUES", version: "v1", input_parameters },
]);
expect(readStateFile(dir).toolkits?.github).not.toHaveProperty("tools");
});
it("overlapping connects are single-flight: only one initiate call is made", async () => {
@@ -776,7 +709,6 @@ describe("connector operation ordering", () => {
proxy.initiateConnection.mockResolvedValue({
connectedAccountId: "ca_new",
});
proxy.listToolkitTools.mockResolvedValue([{ slug: "GITHUB_LIST_ISSUES" }]);
const disconnect = disconnectComposioToolkit("github");
await vi.waitFor(() =>
expect(proxy.deleteConnection).toHaveBeenCalledOnce(),
@@ -805,25 +737,22 @@ describe("connector operation ordering", () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(10_000);
const dir = useTempDataDir();
writeState(dir, storedGithub);
proxy.initiateConnection.mockResolvedValue({
connectedAccountId: "ca_new",
});
let releaseTools!: () => void;
proxy.listToolkitTools.mockImplementationOnce(
let releaseInitiation!: () => void;
proxy.initiateConnection.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseTools = () => resolve([{ slug: "GITHUB_LIST_ISSUES" }]);
releaseInitiation = () => resolve({ connectedAccountId: "ca_new" });
}),
);
const connect = connectComposioToolkit("github");
await vi.waitFor(() =>
expect(proxy.listToolkitTools).toHaveBeenCalledOnce(),
expect(proxy.initiateConnection).toHaveBeenCalledOnce(),
);
clock.mockReturnValue(10_000 + clockDelta);
try {
await disconnectComposioToolkit("github");
} finally {
releaseTools();
releaseInitiation();
}
expect((await connect).alreadyConnected).toBeUndefined();
expect(readStateFile(dir).toolkits).toEqual({});
+26 -112
View File
@@ -9,7 +9,7 @@ import {
import { dirname, join } from "node:path";
import {
isClineAccountFeatureEnabled,
resolveComposioToolsStatePath,
resolveComposioConnectionStatePath,
} from "@cline/core";
import { type BasicLogger, FeatureFlag } from "@cline/shared";
import { resolveClineDir } from "@cline/shared/storage";
@@ -36,7 +36,6 @@ import {
fetchConnectableToolkits,
initiateConnection,
listConnections,
listToolkitTools,
waitForConnectionActive,
} from "./cline-connectors-api";
@@ -50,15 +49,10 @@ import {
* account — a client-held key can't be user-scoped, so the proxy is what
* keeps one install from acting as another user. There is no local API key.
*
* The sidecar still owns connection bookkeeping and the OAuth-completion
* wait, and persists connection state plus fetched tool schemas to
* `<cline-data>/settings/composio/<account-hash>.json`, which core's built-in
* `composio-tools` extension (`@cline/core`, composio-tools-extension.ts)
* reads at session start to register one tool per connected Composio tool.
* New sessions pick state changes up automatically; running sessions keep
* their frozen tool set. The persisted file no longer holds an API key; the
* proxy resolves the account bearer token per call, and the composio-tools
* extension executes tools through the same proxy.
* The sidecar owns connection bookkeeping and OAuth completion, persisting
* connection IDs and display metadata to the account-scoped state file.
* Core uses a server-owned meta-tool session for discovery and execution;
* connection management never fetches or stores app tool schemas.
*/
/** Where pre–in-process-registration builds materialized a drop-in plugin;
@@ -66,23 +60,12 @@ import {
const LEGACY_COMPOSIO_PLUGIN_RELATIVE_PATH = ["plugins", "composio-tools.ts"];
/** How long the background waiter gives the user to finish the browser flow. */
const CONNECT_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
const MAX_TOOL_DESCRIPTION_LENGTH = 1024;
type StoredComposioTool = {
slug: string;
name?: string;
description?: string;
version?: string;
input_parameters?: Record<string, unknown>;
};
type StoredComposioToolkit = {
connectedAccountId: string;
connectedAt: string;
/** Display metadata captured from the catalog at connect time. */
name?: string;
logo?: string;
tools: StoredComposioTool[];
};
type StoredComposioState = {
@@ -151,40 +134,6 @@ function getAccountScope(): AccountScope | undefined {
return scope;
}
function parseToolInputParameters(
value: unknown,
): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
function toStoredTool(raw: {
slug: string;
name?: string;
description?: string;
version?: string;
input_parameters?: unknown;
}): StoredComposioTool | undefined {
if (!raw?.slug) {
return undefined;
}
const description = raw.description?.trim();
return {
slug: raw.slug,
name: raw.name?.trim() || undefined,
description:
description && description.length > MAX_TOOL_DESCRIPTION_LENGTH
? `${description.slice(0, MAX_TOOL_DESCRIPTION_LENGTH)}…`
: description || undefined,
version:
typeof raw.version === "string" && raw.version.trim()
? raw.version.trim()
: undefined,
input_parameters: parseToolInputParameters(raw.input_parameters),
};
}
function formatConnectorsError(error: unknown): string {
if (error instanceof ConnectorsApiError) {
return error.message;
@@ -211,7 +160,7 @@ export function parseComposioToolkitSlug(value: unknown): ComposioToolkitSlug {
// ── Persisted state ──────────────────────────────────────────────────────
function readComposioState(scope: AccountScope): StoredComposioState {
const path = resolveComposioToolsStatePath(scope.accountId);
const path = resolveComposioConnectionStatePath(scope.accountId);
if (!existsSync(path)) {
return {};
}
@@ -229,7 +178,7 @@ function writeComposioState(
scope: AccountScope,
state: StoredComposioState,
): void {
const path = resolveComposioToolsStatePath(scope.accountId);
const path = resolveComposioConnectionStatePath(scope.accountId);
mkdirSync(dirname(path), { recursive: true });
// The file no longer holds an API key, but connection metadata is still
// user data; keep it owner-readable only, unchanged from before.
@@ -310,8 +259,8 @@ function pruneConfirmedCancelledAccount(
* through the proxy's delete (which revokes upstream credentials). Returns
* true when the account is confirmed gone (deleted now, or already gone / a
* 404) — the caller then prunes its tombstone. A failure is logged, not
* thrown: the persisted tombstone keeps the account from ever materializing
* tools locally, and the status-refresh reconciliation retries the deletion
* thrown: the persisted tombstone keeps the account from ever being imported
* locally, and the status-refresh reconciliation retries the deletion
* whenever the proxy still reports the account.
*/
async function revokeConnectedAccountQuietly(
@@ -338,7 +287,7 @@ async function revokeConnectedAccountQuietly(
* (their browser flows may still complete afterwards) and revoke them
* remotely in the background, pruning each tombstone once its revocation is
* confirmed. The tombstones are what guarantee the accounts can never
* materialize local tools; the revocations are best-effort cleanup. Mutates
* be imported locally; the revocations are best-effort cleanup. Mutates
* `state`; the caller persists it.
*/
function abandonPendingConnections(
@@ -363,9 +312,9 @@ function abandonPendingConnections(
/**
* Earlier builds materialized a drop-in plugin at
* `~/.cline/plugins/composio-tools.ts`; connector tools now register inside
* core at session start, so a leftover file would double-register the tools
* on hosts that can load drop-in plugins (and fail noisily on hosts that
* `~/.cline/plugins/composio-tools.ts`; core now registers only meta tools.
* A leftover file would eagerly expose app schemas again on hosts that can
* load drop-in plugins (and fail noisily on hosts that
* cannot). Best-effort removal on every reconciled read — a forced unlink
* of a missing file is a single cheap syscall.
*/
@@ -444,8 +393,7 @@ async function isConnectorsAvailable(
if (!configured) {
// Signed out or un-entitled: connections in the state file belong to a
// session that can no longer act on them. Drop them so no stale
// connectors are reported (and so the composio-tools extension, which
// also fails closed without a token, and the UI agree).
// connectors are reported. Core gates meta-tool registration separately.
scope.catalogCache = null;
clearConnectorStateForSignedOut(scope);
}
@@ -484,14 +432,6 @@ function summarizeToolkit(
const catalogEntry = scope.catalogCache?.entries.find(
(entry) => entry.slug === slug,
);
// A connected toolkit with zero materialized tools is a wedge, not a
// healthy state (sessions get nothing) — surface it instead of letting
// the card look fine. Reconciliation re-fetches the schemas on the next
// status refresh (see getComposioStatus).
const zeroToolsWarning =
status === "connected" && stored && stored.tools.length === 0
? "Connected, but no tools were retrieved from Composio yet. They are re-fetched automatically; if this persists, disconnect and reconnect."
: undefined;
return {
toolkit: slug,
name: recommended?.name ?? stored?.name ?? catalogEntry?.name ?? slug,
@@ -501,8 +441,7 @@ function summarizeToolkit(
status,
connectedAccountId: stored?.connectedAccountId,
connectedAt: stored?.connectedAt,
toolNames: stored?.tools.map((tool) => tool.name?.trim() || tool.slug),
error: scope.lastConnectionErrors.get(slug) ?? zeroToolsWarning,
error: scope.lastConnectionErrors.get(slug),
};
}
@@ -558,8 +497,7 @@ export async function getComposioStatus(options?: {
}
// Reconcile with the proxy: connections can be revoked (or added) from the
// Composio dashboard, or by another device, without this app knowing.
// Refresh every active toolkit's schemas too: persisted tools may be
// incomplete or outdated. Ordinary status polling still uses local state.
// Ordinary status polling uses local connection state.
try {
const refreshSequence = ++scope.operationSequence;
const connections = await listConnections({
@@ -591,7 +529,7 @@ export async function getComposioStatus(options?: {
// Reconciliation is computed as per-slug DELTAS against the state
// snapshot taken when this refresh started (the baseline), never as a
// whole toolkit map to assign: an OAuth connection that finalizes
// while the tool fetches below are in flight lives only in the fresh
// while the remote request is in flight lives only in the fresh
// state, and replacing the map wholesale would silently discard it.
const startToolkits = state.toolkits ?? {};
const baselineIdBySlug = new Map<ComposioToolkitSlug, string | undefined>();
@@ -619,7 +557,7 @@ export async function getComposioStatus(options?: {
!scope.pendingConnections.has(slug) &&
// A connect mid-initiation owns the slug (its pending entry does
// not exist yet): a redirect-less attempt can already be ACTIVE
// remotely while its finalize is still fetching tools, and
// remotely while its initiation is still in flight, and
// importing it here would keep it installed even if that
// finalize then fails and abandons the account.
!scope.connectInitiationsInFlight.has(slug) &&
@@ -627,7 +565,7 @@ export async function getComposioStatus(options?: {
// toolkit; writing it back would resurrect the connection.
(scope.lastDisconnectSequence.get(slug) ?? 0) < refreshSequence
) {
// A same-account re-fetch keeps the original connection metadata.
// A same-account refresh keeps the original connection metadata.
const sameAccount = Boolean(
stored && stored.connectedAccountId === remoteAccountId,
);
@@ -640,13 +578,12 @@ export async function getComposioStatus(options?: {
...lookupCatalogDisplayInfo(scope, slug),
...(sameAccount && stored?.name ? { name: stored.name } : {}),
...(sameAccount && stored?.logo ? { logo: stored.logo } : {}),
tools: await fetchToolkitTools(scope, slug, ctx),
});
}
}
if (removals.length > 0 || imports.size > 0) {
// The tool fetches above are slow enough for a connect, disconnect,
// cancel, or sign-out to have landed meanwhile. Each delta is
// A connect, disconnect, cancel, or sign-out may have landed while
// fetching remote connections. Each delta is
// applied to the freshly read state only if that slug still matches
// the baseline it was decided against (per-slug compare-and-swap),
// so concurrent changes survive this write.
@@ -696,7 +633,7 @@ export async function getComposioStatus(options?: {
`composio status refresh failed: ${formatConnectorsError(error)}`,
);
}
return buildStatusResponse(scope, state, configured);
return buildStatusResponse(scope, readComposioState(scope), configured);
}
// ── Toolkit catalog ──────────────────────────────────────────────────────
@@ -893,7 +830,7 @@ export async function connectComposioToolkit(
return; // Cancelled or superseded while we waited.
}
// finalizeToolkitConnection re-checks the attempt at write time,
// so a cancel or disconnect that lands during the tool fetch
// so a cancel or disconnect that lands during authorization
// cannot be overwritten by this attempt.
await finalizeToolkitConnection(
scope,
@@ -1118,26 +1055,7 @@ export async function disconnectComposioToolkit(
return buildStatusResponse(scope, next, true);
}
// ── Tool materialization ─────────────────────────────────────────────────
async function fetchToolkitTools(
scope: AccountScope,
toolkit: ComposioToolkitSlug,
ctx: ClineAuthTelemetryContext,
): Promise<StoredComposioTool[]> {
const rawTools = await listToolkitTools(toolkit, {
...ctx,
accountId: scope.accountId,
});
const tools: StoredComposioTool[] = [];
for (const raw of rawTools) {
const tool = toStoredTool(raw);
if (tool) {
tools.push(tool);
}
}
return tools;
}
// ── Connection finalization ──────────────────────────────────────────────
type FinalizeGuard = {
/** Present for browser-flow attempts; the pending entry must still carry
@@ -1194,14 +1112,13 @@ async function finalizeToolkitConnection(
guard: FinalizeGuard,
ctx: ClineAuthTelemetryContext,
): Promise<boolean> {
const tools = await fetchToolkitTools(scope, toolkit, ctx);
if (getClineAccountId() !== scope.accountId) {
await abandonFinalizedConnection(scope, connectedAccountId, ctx);
return false;
}
// Everything below (up to the state write) runs synchronously, so these
// write-time checks cannot be raced by a cancel or disconnect that
// happened while the tool fetch (or the browser flow) was in flight.
// happened while initiation or the browser flow was in flight.
if (
guard.attemptId &&
scope.pendingConnections.get(toolkit)?.attemptId !== guard.attemptId
@@ -1241,7 +1158,6 @@ async function finalizeToolkitConnection(
connectedAccountId,
connectedAt: new Date().toISOString(),
...lookupCatalogDisplayInfo(scope, toolkit),
tools,
},
};
});
@@ -1255,9 +1171,7 @@ async function finalizeToolkitConnection(
},
);
}
ctx.logger?.log?.(
`composio connected ${toolkit} with ${tools.length} tool(s)`,
);
ctx.logger?.log?.(`composio connected ${toolkit}`);
return true;
}
@@ -29,6 +29,18 @@ describe("desktop connector lifecycle", () => {
});
});
it("runs connector commands through the bundled CLI when the shell provides one", () => {
expect(
__test__.buildCliConnectCommand("/repo", ["telegram"], {
desktopCliPath: "C:\\Program Files\\Cline\\cline-cli.exe",
execPath: "C:\\Program Files\\Cline\\cline-cli.exe",
}),
).toEqual({
launcher: "C:\\Program Files\\Cline\\cline-cli.exe",
childArgs: ["connect", "telegram"],
});
});
it("uses the atomic restart command for an active channel", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true),
@@ -10,6 +10,7 @@ import {
setConnectorCliLaunchSpec,
withResolvedClineBuildEnv,
} from "@cline/shared";
import { resolveDesktopCliPath } from "./cli-runtime";
import type { JsonRecord } from "./types";
type ConnectorField = {
@@ -113,9 +114,14 @@ function buildCliConnectCommand(
options: {
execPath?: string;
cliPath?: string;
desktopCliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const desktopCliPath = options.desktopCliPath ?? resolveDesktopCliPath();
if (desktopCliPath) {
return { launcher: desktopCliPath, childArgs: ["connect", ...args] };
}
const execPath = options.execPath ?? process.execPath;
const cliPath =
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
@@ -242,6 +242,33 @@ describe("Code sidecar runtime capabilities", () => {
expect(connectMock).toHaveBeenCalledOnce();
});
it("records only local observer events, excluding cloud events projected through a local context", async () => {
const { createSidecarContext, ensureSharedHubClient, handleHubLiveEvent } =
await import("./context");
const { getHubStatus } = await import("./hub-admin");
const ctx = createSidecarContext("/workspace/project");
const client = await ensureSharedHubClient(ctx);
const observer = subscribeMock.mock.calls[0][0];
observer({
event: "hub.client.registered",
payload: { displayName: "Local client" },
});
// CloudSessionManager projects cloud hub events through this same handler
// with a local environment context; context identity is not event origin.
for (let i = 0; i < 105; i++) {
handleHubLiveEvent(ctx, {
event: "ui.notify",
payload: { title: "Cloud run", body: "Cloud content" },
});
}
const status = await getHubStatus(ctx, client);
expect(status.events).toHaveLength(1);
expect(status.events[0]).toMatchObject({
title: "Client connected",
detail: "Local client",
});
});
it("returns indexed search results without listing every session", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
@@ -1549,6 +1576,31 @@ describe("Code sidecar runtime capabilities", () => {
]);
});
it.each([
"schedule.execution_failed",
"schedule.execution_completed",
])("forwards %s without requiring an attached session", async (event) => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
const payload = {
scheduleId: "schedule-1",
executionId: "execution-1",
errorMessage: "getaddrinfo ENOTFOUND api.cline.bot",
};
handleHubLiveEvent(ctx, { event, payload });
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: { name: event, payload: { environmentId: "local", ...payload } },
},
]);
});
it("forwards Hub settings changes so open desktop views can refresh", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
@@ -30,6 +30,7 @@ import {
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsService,
} from "./feature-flags";
import { recordHubActivity } from "./hub-admin";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -936,6 +937,12 @@ export function handleHubLiveEvent(
});
return;
}
// Schedules can fail before creating a session. Relay their lifecycle
// events independently of the live-chat/session projection as well.
if (event.event.startsWith("schedule.")) {
sendEvent(ctx, event.event, event.payload ?? {});
return;
}
if (event.event === "settings.changed") {
sendEvent(ctx, event.event, event.payload ?? {});
return;
@@ -1528,6 +1535,7 @@ export async function ensureSharedHubClient(
try {
await client.connect();
client.subscribe((event) => {
recordHubActivity(getSidecarContextOwner(ctx), event);
handleHubLiveEvent(
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
event,
@@ -1,62 +0,0 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
readDesktopSettings,
resolveDesktopSettingsPath,
setCloudSessionsEnabled,
} from "./desktop-settings";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings", () => {
it("defaults cloud sessions to off when no settings file exists", () => {
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("persists the cloud sessions opt-in and reads it back", () => {
expect(setCloudSessionsEnabled(true)).toEqual({
cloudSessionsEnabled: true,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
true,
);
expect(
JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")),
).toMatchObject({ cloudSessionsEnabled: true });
expect(setCloudSessionsEnabled(false)).toEqual({
cloudSessionsEnabled: false,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("treats malformed files and non-boolean values as off", () => {
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
writeFileSync(
resolveDesktopSettingsPath(),
JSON.stringify({ cloudSessionsEnabled: "yes" }),
"utf8",
);
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
});
@@ -1,49 +0,0 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/** Desktop-only preferences kept separate from strict shared global settings. */
export type DesktopSettings = {
/** Opt-in gate for cloud sessions while the feature is in preview. */
cloudSessionsEnabled: boolean;
};
const DEFAULT_SETTINGS: DesktopSettings = {
cloudSessionsEnabled: false,
};
export function resolveDesktopSettingsPath(): string {
return join(resolveClineDataDir(), "settings", "code-settings.json");
}
export function readDesktopSettings(): DesktopSettings {
let raw: string;
try {
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
} catch {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function writeDesktopSettings(settings: DesktopSettings): void {
const filePath = resolveDesktopSettingsPath();
mkdirSync(dirname(filePath), { recursive: true });
// Avoid leaving torn settings if the process exits mid-write.
const tempPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
renameSync(tempPath, filePath);
}
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
writeDesktopSettings(next);
return next;
}
@@ -11,7 +11,6 @@ import { arch, homedir, platform, release } from "node:os";
import { join } from "node:path";
import { readGlobalSettings, resolveClineDataDir } from "@cline/core";
import packageJson from "../package.json";
import { readDesktopSettings } from "./desktop-settings";
import { readSessionManifest } from "./paths";
/** How much of each log file (from the end) is included in the report. */
@@ -88,7 +87,6 @@ export function buildDiagnosticsReport(
},
dataDir,
globalSettings: readGlobalSettings(),
desktopSettings: readDesktopSettings(),
};
let text = `${JSON.stringify(report, null, 2)}\n`;

Some files were not shown because too many files have changed in this diff Show More