Merge remote-tracking branch 'origin/main' into saoudrizwan/keep-changes-in-project-e0b6

# Conflicts:
#	apps/examples/desktop-app/scripts/build-sidecar-bin.ts
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/sidecar/index.ts
#	apps/examples/desktop-app/src-tauri/tauri.conf.json
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/vscode/proto/cline/state.proto
#	apps/vscode/webview-ui/src/components/settings/sections/FeatureSettingsSection.tsx
#	bun.lock
#	sdk/packages/core/package.json
This commit is contained in:
Saoud Rizwan
2026-10-15 21:00:23 +00:00
251 changed files with 30260 additions and 8008 deletions
+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.
+127 -103
View File
@@ -335,12 +335,12 @@ 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 }}
@@ -368,10 +368,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 +451,57 @@ 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
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 +624,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 +636,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"
@@ -697,7 +706,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,7 +737,7 @@ 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 }}
@@ -794,33 +803,40 @@ 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
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 +876,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 +916,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
@@ -999,13 +1015,13 @@ 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 }}
@@ -1057,59 +1073,67 @@ 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
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 +1397,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 +1409,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' }}
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## [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
+16
View File
@@ -1,5 +1,21 @@
# Cline CLI Changelog
## 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.70",
"version": "3.0.71",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+12 -107
View File
@@ -7,13 +7,12 @@ import {
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
} 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 { stageMxcRuntime } from "../../../sdk/packages/core/scripts/stage-mxc-runtime";
import {
@@ -21,38 +20,15 @@ import {
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;
@@ -96,10 +72,7 @@ await $`rm -rf dist`;
// 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)
@@ -155,19 +128,6 @@ if (shouldBuildHubWebview()) {
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();
@@ -186,66 +146,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,
// Plugins import the SDK from the wrapper's node_modules at runtime.
// Without package.json loading Bun cannot resolve bare imports from
// files on disk, so jiti falls back to transpiling the whole SDK
// graph, which can blow the plugin load timeout on a cold cache.
autoloadPackageJson: true,
},
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;
@@ -260,7 +160,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) {
+162
View File
@@ -0,0 +1,162 @@
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { $ } from "bun";
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 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 = join(
scratchRoot,
`cline-build-${input.bunTarget}-${process.pid}`,
);
const tmpOutfile = join(
tmpDir,
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
);
mkdirSync(tmpDir, { recursive: true });
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],
splitting: true,
compile: {
target: input.bunTarget,
outfile: tmpOutfile,
// Plugins import SDK packages from disk at runtime; 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,
// Inline telemetry/OTEL env vars at build time so the compiled
// binary ships with production telemetry configuration baked in.
...buildInlinedEnvDefines(),
},
throw: false,
});
} finally {
process.chdir(previousCwd);
}
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
rmSync(tmpDir, { recursive: true, force: true });
throw new Error(`CLI build failed for ${input.bunTarget}`);
}
mkdirSync(dirname(input.outfile), { recursive: true });
copyFileSync(tmpOutfile, input.outfile);
chmodSync(input.outfile, 0o755);
rmSync(tmpDir, { recursive: true, force: true });
}
+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;
}
}
});
});
+103 -39
View File
@@ -1,6 +1,7 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
ensureLoginShellPath,
localHubHasNoActiveSessions,
NodeHubClient,
probeHubServer,
@@ -29,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(
@@ -111,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);
});
@@ -120,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 () => {
@@ -218,10 +280,10 @@ export function createHubCommand(
);
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();
}),
);
@@ -317,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;
+8
View File
@@ -2,8 +2,10 @@
import { isMainThread } from "node:worker_threads";
import {
buildTelemetrySelfcheckReport,
claimHubDaemonProcess,
claimSupervisedConnectorProcess,
createClineTelemetryServiceConfig,
disableCurrentDirectoryExecutableSearch,
disposeAll,
ensureLoopbackProxyBypass,
@@ -32,6 +34,12 @@ ensureLoopbackProxyBypass();
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} 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);
+18
View File
@@ -1,5 +1,23 @@
# Cline Desktop Changelog
## 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");
};
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.45",
"version": "0.0.46",
"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",
@@ -0,0 +1,267 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
import { stageMxcRuntime } from "../../../../sdk/packages/core/scripts/stage-mxc-runtime";
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 and the backend running on this runtime
// also trust the OS Keychain/cert store, as the npm wrapper does.
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`.
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}`;
};
// Plugin installs drop their @cline/* dependencies because the host provides
// them, and the plugin worker resolves those from node_modules beside its
// bootstrap. Nothing like that exists next to the compiled CLI, so ship
// self-contained builds there. Splitting keeps one shared copy of the SDK,
// placed in core so every chunk sits inside a `"type": "module"` package.
const bundlePluginSdkPackages = async (): Promise<void> => {
const outdir = `${BIN_DIR}/extensions/node_modules/@cline`;
const packagesRoot = "../../../sdk/packages";
const packages = await Promise.all(
["sdk", "core", "agents", "llms", "shared"].map(async (name) => {
const root = `${packagesRoot}/${name}`;
const manifest = (await Bun.file(`${root}/package.json`).json()) as {
exports: Record<string, { import: string }>;
};
// Type-only exports (e.g. @cline/shared/types) have no JS to ship.
const exports = Object.fromEntries(
Object.entries(manifest.exports)
.map(([path, entry]) => [path, entry.import] as const)
.filter(([, file]) => existsSync(`${root}/${file}`)),
);
return { root, name, exports };
}),
);
await $`rm -rf ${outdir}`;
const result = await Bun.build({
entrypoints: packages.flatMap(({ root, exports }) =>
Object.values(exports).map((file) => `${root}/${file}`),
),
root: packagesRoot,
outdir,
naming: { chunk: "core/dist/[name]-[hash].[ext]" },
target: "node",
format: "esm",
splitting: true,
minify: true,
});
if (!result.success) {
throw new AggregateError(result.logs, "plugin SDK bundle failed");
}
for (const { name, exports } of packages) {
await Bun.write(
`${outdir}/${name}/package.json`,
JSON.stringify({ name: `@cline/${name}`, type: "module", exports }),
);
}
};
// Runtime for "Only allow edits in project directory", shipped as the bin/mxc
// resource. macOS is skipped: its native files would sit unsigned under
// Contents/Resources and fail notarization (see buildRemoteClis). The note
// keeps the resource glob matching there.
const stageProjectSandboxRuntime = async (
targetTriple: string,
): Promise<void> => {
const destination = `${BIN_DIR}/mxc`;
const platform = targetTriple.includes("windows")
? "win32"
: targetTriple.includes("linux")
? "linux"
: undefined;
const arch = targetTriple.startsWith("aarch64") ? "arm64" : "x64";
const staged =
platform &&
(await stageMxcRuntime({ destination, targets: [{ platform, arch }] }));
if (!staged) {
mkdirSync(destination, { recursive: true });
writeFileSync(
`${destination}/README.txt`,
`The project sandbox runtime is not bundled for ${targetTriple}.\n`,
);
}
};
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} ${BIN_DIR}/extensions`;
// Plugin workers load this bootstrap from disk in the backend and Hub.
await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js ${BIN_DIR}/extensions/plugin-sandbox-bootstrap.js`;
await bundlePluginSdkPackages();
// 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();
await stageProjectSandboxRuntime(targetTriple);
};
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
}
@@ -1,179 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
import { stageMxcRuntime } from "../../../../sdk/packages/core/scripts/stage-mxc-runtime";
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}`;
};
// Runtime for "Only allow edits in project directory", shipped as the bin/mxc
// resource. macOS is skipped: its native files would sit unsigned under
// Contents/Resources and fail notarization (see macSidecarCandidates in
// sidecar/remote-helper.ts). The note keeps the resource glob matching there.
const stageProjectSandboxRuntime = async (
targetTriple: string,
): Promise<void> => {
const destination = "./src-tauri/bin/mxc";
const platform = targetTriple.includes("windows")
? "win32"
: targetTriple.includes("linux")
? "linux"
: undefined;
const arch = targetTriple.startsWith("aarch64") ? "arm64" : "x64";
const staged =
platform &&
(await stageMxcRuntime({ destination, targets: [{ platform, arch }] }));
if (!staged) {
mkdirSync(destination, { recursive: true });
writeFileSync(
`${destination}/README.txt`,
`The project sandbox runtime is not bundled for ${targetTriple}.\n`,
);
}
};
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 src-tauri/bin/extensions`;
// Plugins run in worker threads that load this self-contained bootstrap
// from disk; the app ships it as a resource (see plugin-bootstrap.ts).
await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js src-tauri/bin/extensions/plugin-sandbox-bootstrap.js`;
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacSidecar();
} else {
await buildSidecar(targetTriple);
}
await buildRemoteHelpers();
await stageProjectSandboxRuntime(targetTriple);
};
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
@@ -6,52 +6,77 @@ import {
openSync,
readFileSync,
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);
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 +88,51 @@ 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),
});
}
}, 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 +143,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 +160,60 @@ 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 },
);
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,
// 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", "--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,
});
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 +235,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 +257,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 +265,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 +298,33 @@ 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",
});
}, 100_000);
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);
}
@@ -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):
@@ -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,11 @@ 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.
Every create, restart, fork, and restore also attaches the serializable Desktop
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
@@ -210,9 +211,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) |
@@ -0,0 +1,74 @@
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" },
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();
expect(adoptDesktopCliRuntime({ env, chdir, setLauncher })).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(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();
expect(adoptDesktopCliRuntime({ env: {}, chdir, setLauncher })).toEqual({
cliPath: undefined,
});
expect(chdir).not.toHaveBeenCalled();
expect(setLauncher).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,111 @@
import { execFile } from "node:child_process";
import { setHubDaemonLauncher } from "@cline/core";
/** 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;
} = {},
): { 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 });
}
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,
env: { ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
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 };
}
@@ -9,7 +9,6 @@ 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,
@@ -79,7 +78,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 +112,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 +168,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 });
});
});
@@ -127,14 +127,9 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
readDesktopSettings,
setCloudSessionsEnabled,
} from "./desktop-settings";
import { writeDiagnosticsReport } from "./diagnostics";
import {
identifyDesktopFeatureFlagsAccount,
isCloudAgentsAvailable,
isCloudAgentsEnabled,
refreshDesktopFeatureFlags,
} from "./feature-flags";
@@ -2208,8 +2203,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,
@@ -2220,10 +2213,6 @@ export async function handleCommand(
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
cloudAgentsAvailable: isCloudAgentsAvailable({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
};
}
if (command === "list_cloud_models") {
@@ -3229,9 +3218,6 @@ export async function handleCommand(
setKeepChangesInProjectGlobally(args.keep_changes_in_project);
return readGlobalSettings();
}
if (command === "get_desktop_settings") {
return readDesktopSettings();
}
if (command === "export_diagnostics") {
const sessionIds = Array.isArray(args?.sessionIds)
? args.sessionIds.filter(
@@ -3242,18 +3228,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") {
@@ -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"));
@@ -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`;
@@ -337,45 +337,32 @@ describe("cloud agents gate", () => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
});
it("is unavailable and disabled while the rollout flag is off", async () => {
const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import(
"./feature-flags"
);
expect(isCloudAgentsAvailable()).toBe(false);
it("is disabled while the rollout flag is off", async () => {
const { isCloudAgentsEnabled } = await import("./feature-flags");
expect(isCloudAgentsEnabled()).toBe(false);
});
it("does not enable a boolean rollout from a truthy variant payload", async () => {
const { isCloudAgentsAvailable } = await import("./feature-flags");
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue("control");
expect(isCloudAgentsAvailable()).toBe(false);
expect(isCloudAgentsEnabled()).toBe(false);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith(
"code-cloud-agents",
);
});
it("needs both the rollout flag and the user's opt-in to enable", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { setCloudSessionsEnabled } = await import("./desktop-settings");
it("is enabled by the rollout flag alone", async () => {
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getBooleanFlagEnabled.mockImplementation(
(flag: unknown) => flag === "code-cloud-agents",
);
expect(isCloudAgentsAvailable()).toBe(true);
setCloudSessionsEnabled(false);
expect(isCloudAgentsEnabled()).toBe(false);
setCloudSessionsEnabled(true);
expect(isCloudAgentsEnabled()).toBe(true);
});
it("lets the env override force the gate in both directions", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue(undefined);
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
expect(isCloudAgentsAvailable()).toBe(true);
expect(isCloudAgentsEnabled()).toBe(true);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
expect(isCloudAgentsEnabled()).toBe(false);
@@ -22,7 +22,6 @@ import {
} from "@cline/core/services/feature-flags/posthog";
import { FeatureFlag as SharedFeatureFlag } from "@cline/shared";
import { resolveClineDataDir } from "@cline/shared/storage";
import { readDesktopSettings } from "./desktop-settings";
const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS;
@@ -282,8 +281,8 @@ export function readCloudAgentsEnvOverride(): boolean | undefined {
return undefined;
}
/** Whether the rollout makes cloud sessions available to this install. */
export function isCloudAgentsAvailable(options?: {
/** Whether the rollout enables cloud sessions for this install. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
@@ -293,14 +292,3 @@ export function isCloudAgentsAvailable(options?: {
FEATURE_FLAG_CODE_CLOUD_AGENTS,
);
}
/** Whether cloud sessions are both available and enabled by the user. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
const override = readCloudAgentsEnvOverride();
if (override !== undefined) return override;
if (!isCloudAgentsAvailable(options)) return false;
return readDesktopSettings().cloudSessionsEnabled;
}
+27 -25
View File
@@ -10,15 +10,15 @@ import {
setModelToolEnabledGlobally,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper";
import {
buildTelemetrySelfcheckReport,
captureSdkError,
claimHubDaemonProcess,
disableCurrentDirectoryExecutableSearch,
ensureLoopbackProxyBypass,
setClineClientIdentity,
} from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
import { configureConnectorCliLaunch } from "./connectors";
import {
@@ -28,11 +28,10 @@ import {
initializeSessionManager,
} from "./context";
import { createDesktopObservability } from "./observability";
import { configurePluginBootstrap } from "./plugin-bootstrap";
import { resolveWorkspaceRoot } from "./paths";
import { configurePluginBootstrap } from "./plugin-bootstrap";
import { linuxResourceCandidates } from "./remote-helper";
import { startServer } from "./server";
import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -57,7 +56,7 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
});
}
async function main() {
async function main(desktopCliPath?: string) {
if (!BunRuntime) {
throw new Error("sidecar must be run with Bun");
}
@@ -99,6 +98,18 @@ async function main() {
"Login shell PATH resolution",
await shellPathPromise,
);
// The packaged app hands Hub startup to its bundled CLI: it reuses a
// compatible healthy Hub or starts one, and the session manager attaches.
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
// the session manager still starts the Hub through the same CLI launcher.
if (desktopCliPath) {
try {
const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot);
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
} catch (error) {
observability.logger.error?.("Cline CLI hub ensure failed", { error });
}
}
await initializeSessionManager(ctx);
let shuttingDown = false;
@@ -224,8 +235,8 @@ async function main() {
/**
* Prints whether the telemetry configuration that was inlined at build time
* (see scripts/telemetry-define-args.ts) actually made it into this binary,
* then exits. CI runs this against the packaged sidecar and fails the
* (see scripts/telemetry-define-args.ts) actually made it into this bundle,
* then exits. CI runs this against the packaged backend and fails the
* publish when a release-grade build reports `"enabled":false` or an
* unusable OTLP endpoint, so a regression in the build-time inlining can
* never ship silently again.
@@ -239,19 +250,21 @@ function runTelemetrySelfcheck(): void {
/**
* Points core at the sandbox runtime for "Only allow edits in project directory",
* which build-sidecar-bin stages into the bundle's bin/mxc resource.
* which build-desktop-runtime stages into the bundle's bin/mxc resource. The
* Hub daemon the bundled CLI starts inherits this env.
*/
function configureProjectSandboxRuntime(): void {
if (process.env.CLINE_MXC_SDK_DIR) {
return;
}
// This backend runs on the bundled CLI, so execPath is that binary.
const executableDirectory = dirname(process.execPath);
const runtimePath = join("mxc", "node_modules", "@microsoft", "mxc-sdk");
const resource = join("bin", runtimePath);
const runtime = [
// Windows installs resources next to the sidecar.
// Windows installs resources next to the CLI.
join(executableDirectory, resource),
// `tauri dev` runs the compiled sidecar from src-tauri/bin.
// `tauri dev` runs the compiled CLI from src-tauri/bin.
join(executableDirectory, runtimePath),
...linuxResourceCandidates(executableDirectory, resource),
].find(existsSync);
@@ -261,32 +274,21 @@ function configureProjectSandboxRuntime(): void {
}
async function runEntrypoint(): Promise<void> {
// Before the daemon-sentinel claim: the selfcheck only inspects build-time
// config and must not consume the sentinel or start anything.
// The selfcheck only inspects build-time config and must not start anything.
if (process.argv.includes("--telemetry-selfcheck")) {
runTelemetrySelfcheck();
return;
}
const { cliPath } = adoptDesktopCliRuntime();
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
disableCurrentDirectoryExecutableSearch();
// Before the Hub daemon (this binary, re-executed) loads any plugin.
// Before the bundled CLI starts the Hub daemon and loads any plugin.
configurePluginBootstrap();
// Before the Hub daemon and agent-spawned processes inherit this env.
ensureLoopbackProxyBypass();
configureProjectSandboxRuntime();
// Claim the Hub daemon sentinel here, not in the shared remote helper: its
// daemon import resolves to the dist build of @cline/core while this bundle
// resolves the source build, and a daemon from the other copy publishes a
// different build id, so the sidecar would retire its own Hub on launch.
if (claimHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
if (await runRemoteHelperEntrypoint()) {
return;
}
await main();
await main(cliPath);
}
runEntrypoint().catch(async (error) => {
@@ -28,7 +28,7 @@ function withLayout(
it("finds the bootstrap in the macOS app bundle resources", () => {
withLayout(
(root) => join(root, "Cline.app", "Contents", "Resources"),
(root) => join(root, "Cline.app", "Contents", "MacOS", "code-sidecar"),
(root) => join(root, "Cline.app", "Contents", "MacOS", "cline-cli"),
(bootstrap, execPath) => {
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
bootstrap,
@@ -40,7 +40,7 @@ it("finds the bootstrap in the macOS app bundle resources", () => {
it("finds the bootstrap in the Linux and Windows resource layouts", () => {
withLayout(
(root) => join(root, "usr", "lib", "Cline"),
(root) => join(root, "usr", "bin", "code-sidecar"),
(root) => join(root, "usr", "bin", "cline-cli"),
(bootstrap, execPath) => {
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
bootstrap,
@@ -49,7 +49,7 @@ it("finds the bootstrap in the Linux and Windows resource layouts", () => {
);
withLayout(
(root) => root,
(root) => join(root, "code-sidecar.exe"),
(root) => join(root, "cline-cli.exe"),
(bootstrap, execPath) => {
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
bootstrap,
@@ -7,9 +7,8 @@ const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js
/**
* Finds the plugin worker bootstrap the app bundle ships as a Tauri resource.
* The compiled sidecar cannot locate it from its own module path (that points
* inside the binary), so the Hub daemon it runs would otherwise fall back to
* loading plugins in-process.
* The backend and CLI-managed Hub need an on-disk bootstrap to run plugins
* in workers; without it they would fall back to loading plugins in-process.
*/
export function resolveDesktopPluginBootstrap(
options: { execPath?: string; cwd?: string } = {},
@@ -15,7 +15,7 @@ it("finds SSH helpers in the installed Windows resource layout", () => {
writeFileSync(helper, "helper");
expect(
resolveDesktopRemoteHelper(target, {
execPath: join(root, "code-sidecar.exe"),
execPath: join(root, "cline-cli.exe"),
cwd: tmpdir(),
env: {},
}),
@@ -29,25 +29,35 @@ it("finds SSH helpers in the installed Windows resource layout", () => {
const FAT_MACHO_HEADER = Buffer.from("cafebabe00000002", "hex");
const THIN_ARM64_MACHO_HEADER = Buffer.from("cffaedfe0c000001", "hex");
it("uses the packaged universal macOS sidecar as the helper for Mac remotes", () => {
it("uses the bundled universal macOS CLI for Mac remotes", () => {
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
try {
const macOS = join(root, "Cline.app", "Contents", "MacOS");
mkdirSync(macOS, { recursive: true });
const sidecar = join(macOS, "code-sidecar");
writeFileSync(sidecar, FAT_MACHO_HEADER);
const cli = join(macOS, "cline-cli");
writeFileSync(cli, FAT_MACHO_HEADER);
for (const arch of ["arm64", "x64"] as const) {
expect(
resolveDesktopRemoteHelper(
{ platform: "darwin", arch },
{ execPath: sidecar, cwd: tmpdir(), env: {}, platform: "darwin" },
{
execPath: cli,
cwd: tmpdir(),
env: { CLINE_DESKTOP_CLI_PATH: cli },
platform: "darwin",
},
),
).toBe(sidecar);
).toBe(cli);
}
expect(
resolveDesktopRemoteHelper(
{ platform: "linux", arch: "arm64" },
{ execPath: sidecar, cwd: tmpdir(), env: {}, platform: "darwin" },
{
execPath: cli,
cwd: tmpdir(),
env: { CLINE_DESKTOP_CLI_PATH: cli },
platform: "darwin",
},
),
).toBeUndefined();
} finally {
@@ -55,17 +65,17 @@ it("uses the packaged universal macOS sidecar as the helper for Mac remotes", ()
}
});
it("only offers a thin packaged macOS sidecar to Mac remotes of its own architecture", () => {
it("only offers a thin bundled macOS CLI to Mac remotes of its own architecture", () => {
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
try {
const macOS = join(root, "Cline.app", "Contents", "MacOS");
mkdirSync(macOS, { recursive: true });
const sidecar = join(macOS, "code-sidecar");
writeFileSync(sidecar, THIN_ARM64_MACHO_HEADER);
const cli = join(macOS, "cline-cli");
writeFileSync(cli, THIN_ARM64_MACHO_HEADER);
const options = {
execPath: sidecar,
execPath: cli,
cwd: tmpdir(),
env: {},
env: { CLINE_DESKTOP_CLI_PATH: cli },
platform: "darwin" as const,
};
expect(
@@ -73,7 +83,7 @@ it("only offers a thin packaged macOS sidecar to Mac remotes of its own architec
{ platform: "darwin", arch: "arm64" },
options,
),
).toBe(sidecar);
).toBe(cli);
expect(
resolveDesktopRemoteHelper({ platform: "darwin", arch: "x64" }, options),
).toBeUndefined();
@@ -82,13 +92,13 @@ it("only offers a thin packaged macOS sidecar to Mac remotes of its own architec
}
});
it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => {
it("uses the compiled CLI for Mac remotes under tauri dev on macOS", () => {
const root = mkdtempSync(join(tmpdir(), "cline-dev-helpers-"));
try {
const bin = join(root, "src-tauri", "bin");
mkdirSync(bin, { recursive: true });
const sidecar = join(bin, "code-sidecar-aarch64-apple-darwin");
writeFileSync(sidecar, "sidecar");
const cli = join(bin, "cline-cli-aarch64-apple-darwin");
writeFileSync(cli, "cli");
const options = {
execPath: "/usr/local/bin/bun",
cwd: root,
@@ -100,7 +110,7 @@ it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => {
{ platform: "darwin", arch: "arm64" },
options,
),
).toBe(sidecar);
).toBe(cli);
expect(
resolveDesktopRemoteHelper({ platform: "darwin", arch: "x64" }, options),
).toBeUndefined();
@@ -109,7 +119,7 @@ it("uses the compiled sidecar for Mac remotes under tauri dev on macOS", () => {
}
});
it("finds SSH helpers in the installed Linux resource layout", () => {
it("selects only the running Linux channels SSH helpers", () => {
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
try {
const target = { platform: "linux", arch: "arm64" } as const;
@@ -124,13 +134,35 @@ it("finds SSH helpers in the installed Linux resource layout", () => {
mkdirSync(directory, { recursive: true });
const helper = join(directory, remoteHelperBinaryFilename(target));
writeFileSync(helper, "helper");
const otherDirectory = join(
root,
"usr",
"lib",
"Cline",
"bin",
"remote-helpers",
);
mkdirSync(otherDirectory, { recursive: true });
writeFileSync(
join(otherDirectory, remoteHelperBinaryFilename(target)),
"other channel",
);
expect(
resolveDesktopRemoteHelper(target, {
execPath: join(root, "usr", "bin", "code-sidecar"),
execPath: join(root, "usr", "bin", "cline-cli"),
cwd: tmpdir(),
env: {},
env: { CLINE_REMOTE_HELPER_DIRECTORY: directory },
}),
).toBe(helper);
// A missing binary in this channel must not fall back to another app.
rmSync(helper);
expect(
resolveDesktopRemoteHelper(target, {
execPath: join(root, "usr", "bin", "cline-cli"),
cwd: tmpdir(),
env: { CLINE_REMOTE_HELPER_DIRECTORY: directory },
}),
).toBeUndefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -1,15 +1,13 @@
import {
closeSync,
existsSync,
openSync,
readdirSync,
readSync,
} from "node:fs";
import { basename, dirname, join } from "node:path";
import { closeSync, existsSync, openSync, readdirSync, readSync } from "node:fs";
import { dirname, join } from "node:path";
import {
type RemoteHelperTarget,
remoteHelperBinaryFilename,
} from "@cline/core";
import { resolveDesktopCliPath } from "./cli-runtime";
/** Base name of the Cline CLI binary the desktop app bundles (Tauri externalBin). */
export const DESKTOP_CLI_BINARY_NAME = "cline-cli";
// Tauri's Linux bundles (deb, rpm, AppImage) install binaries under `usr/bin`
// and resources under `usr/lib/<productName>`. The product name differs per
@@ -36,8 +34,8 @@ const MACHO_CPU_TYPE: Record<RemoteHelperTarget["arch"], number> = {
};
// Published desktop builds are universal, but `package:desktop:mac` and local
// `tauri build` produce a thin host-arch sidecar that cannot run on a Mac of
// the other architecture. A fat binary starts with a big-endian magic; a thin
// `tauri build` produce a thin host-arch CLI that cannot run on a Mac of the
// other architecture. A fat binary starts with a big-endian magic; a thin
// 64-bit Mach-O starts with its little-endian magic followed by the cputype.
function machoRunsOn(path: string, arch: RemoteHelperTarget["arch"]): boolean {
const header = Buffer.alloc(8);
@@ -58,25 +56,23 @@ function machoRunsOn(path: string, arch: RemoteHelperTarget["arch"]): boolean {
);
}
// macOS bundles carry no dedicated darwin helper: Tauri signs only externalBin
// and the main binary, so a Mach-O under Contents/Resources would ship
// unsigned and fail notarization. The sidecar already runs the shared helper
// entrypoint (see index.ts) and is the signed, notarized, universal Mach-O in
// the bundle, so it serves both x64 and arm64 Mac remotes from a Mac. Under
// `tauri dev` the sidecar runs as a script, so use the compiled sidecar that
// beforeDevCommand builds for the matching architecture instead.
function macSidecarCandidates(
// macOS bundles carry no separate darwin copies of the CLI: Tauri signs only
// externalBin and the main binary, so a Mach-O under Contents/Resources would
// ship unsigned and fail notarization. The bundled CLI is the signed,
// notarized, universal Mach-O in the bundle, so it serves both x64 and arm64
// Mac remotes from a Mac. Under `tauri dev` the backend may run on a system
// Bun, so fall back to the CLI that beforeDevCommand compiles.
function macCliCandidates(
target: RemoteHelperTarget,
execPath: string,
desktopCliPath: string | undefined,
cwd: string,
): string[] {
const compiledSidecar = `code-sidecar-${target.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
const compiledCli = `${DESKTOP_CLI_BINARY_NAME}-${target.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
return [
...(basename(execPath).startsWith("code-sidecar") &&
machoRunsOn(execPath, target.arch)
? [execPath]
...(desktopCliPath && machoRunsOn(desktopCliPath, target.arch)
? [desktopCliPath]
: []),
join(cwd, "src-tauri", "bin", compiledSidecar),
join(cwd, "src-tauri", "bin", compiledCli),
join(
cwd,
"apps",
@@ -84,7 +80,7 @@ function macSidecarCandidates(
"desktop-app",
"src-tauri",
"bin",
compiledSidecar,
compiledCli,
),
];
}
@@ -111,12 +107,11 @@ export function resolveDesktopRemoteHelper(
? [join(env.CLINE_REMOTE_HELPER_DIRECTORY, filename)]
: []),
...(target.platform === "darwin" && platform === "darwin"
? macSidecarCandidates(target, execPath, cwd)
? macCliCandidates(target, resolveDesktopCliPath(env), cwd)
: []),
join(executableDirectory, "remote-helpers", filename),
join(executableDirectory, bundledPath),
join(executableDirectory, "..", "Resources", bundledPath),
...linuxResourceCandidates(executableDirectory, bundledPath),
join(cwd, "src-tauri", bundledPath),
join(cwd, "apps", "examples", "desktop-app", "src-tauri", bundledPath),
].find(existsSync);
@@ -1,8 +1,10 @@
!include "Win\RestartManager.nsh"
; The Hub re-executes code-sidecar.exe as a detached daemon and outlives the
; desktop. Stop users of both installed executables before replacing either:
; stopping only the sidecar first leaves the desktop able to spawn it again.
; The bundled cline-cli.exe runs the desktop backend and, as a detached
; daemon, the Hub, which outlives the desktop. Stop users of every installed
; executable before replacing any: stopping only the CLI first leaves the
; desktop able to spawn it again. code-sidecar.exe is the backend and Hub
; executable of earlier releases; its Hub may still be running on upgrade.
;
; Restart Manager identifies users of the exact files across architectures.
; Do not use Get-Process.Path: NSIS is x86 even for an x64 bundle, and x86
@@ -26,6 +28,11 @@
Goto end_session
${EndIf}
!insertmacro RestartManager_RegisterFile $1 "$INSTDIR\cline-cli.exe"
${If} $0 != 0
Goto end_session
${EndIf}
!insertmacro RestartManager_RegisterFile $1 "$INSTDIR\code-sidecar.exe"
${If} $0 != 0
Goto end_session
@@ -64,6 +71,8 @@
Abort
done:
; Nothing installs the earlier backend executable any more.
Delete "$INSTDIR\code-sidecar.exe"
Pop $4
Pop $3
Pop $2
+107 -50
View File
@@ -94,6 +94,7 @@ struct TrayMenuState {
struct AppContext {
launch_cwd: String,
workspace_root: String,
resource_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
@@ -361,7 +362,7 @@ impl DesktopBackendState {
let _ = Command::new("kill").arg(child.id().to_string()).status();
// Windows has no SIGTERM equivalent, so terminate outright.
// Reap the child too: TerminateProcess is quick, and the
// update-restart path needs the sidecar exe's file lock
// update-restart path needs the bundled CLI's file lock
// released before the NSIS installer replaces it.
#[cfg(not(unix))]
{
@@ -396,7 +397,7 @@ struct DesktopBackendReadyLine {
}
/// The release binary is a GUI-subsystem app (no console), so on Windows
/// every console-subsystem child (git, cmd, the sidecar) would otherwise
/// every console-subsystem child (git, cmd, the bundled CLI) would otherwise
/// allocate its own visible console window. Piped stdio does not prevent
/// that; only CREATE_NO_WINDOW does.
#[cfg(windows)]
@@ -456,9 +457,12 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
candidates.into_iter().find(|path| path.exists())
}
fn desktop_backend_binary_names() -> Vec<String> {
/// The bundled Cline CLI (a Tauri `externalBin`). It starts or reuses the
/// shared Hub, runs as the Hub daemon, and is the runtime the packaged
/// desktop backend script executes on, so the app needs no Node or Bun.
fn desktop_cli_binary_names() -> Vec<String> {
let extension = if cfg!(windows) { ".exe" } else { "" };
let bundled_name = format!("code-sidecar{extension}");
let bundled_name = format!("cline-cli{extension}");
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
if target_triple.is_empty() {
return vec![bundled_name];
@@ -466,72 +470,108 @@ fn desktop_backend_binary_names() -> Vec<String> {
vec![
bundled_name,
format!("code-sidecar-{target_triple}{extension}"),
format!("cline-cli-{target_triple}{extension}"),
]
}
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
if cfg!(debug_assertions) {
return None;
}
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from);
fn non_empty_env_path(name: &str) -> Option<PathBuf> {
non_empty_env(name).map(PathBuf::from)
}
fn desktop_app_bin_dir(context: &AppContext) -> PathBuf {
PathBuf::from(&context.workspace_root)
.join("apps")
.join("examples")
.join("desktop-app")
.join("src-tauri")
.join("bin")
}
fn resolve_desktop_cli_path(context: &AppContext) -> Option<PathBuf> {
let current_exe = std::env::current_exe().ok();
let mut candidates = Vec::new();
if let Some(path) = explicit {
if let Some(path) = non_empty_env_path("CLINE_DESKTOP_CLI_BIN") {
candidates.push(path);
}
for binary_name in desktop_backend_binary_names() {
candidates.push(
PathBuf::from(&context.workspace_root)
.join("apps")
.join("examples")
.join("desktop-app")
.join("src-tauri")
.join("bin")
.join(&binary_name),
);
for binary_name in desktop_cli_binary_names() {
if let Some(path) = current_exe
.as_ref()
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
{
candidates.push(path);
}
if let Some(path) = current_exe.as_ref().and_then(|path| {
path.parent()
.and_then(|parent| parent.parent())
.map(|parent| parent.join("Resources").join(&binary_name))
}) {
candidates.push(path);
}
candidates.push(desktop_app_bin_dir(context).join(&binary_name));
}
candidates.into_iter().find(|path| path.is_file())
}
candidates.into_iter().find(|path| path.exists())
fn desktop_backend_bundle_candidates(resource_dir: &Path) -> Vec<PathBuf> {
vec![resource_dir
.join("bin")
.join("desktop-backend")
.join("index.js")]
}
fn resolve_desktop_backend_bundle_path(context: &AppContext) -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = non_empty_env_path("CLINE_DESKTOP_BACKEND_BUNDLE") {
candidates.push(path);
}
candidates.extend(desktop_backend_bundle_candidates(&context.resource_dir));
candidates.push(
desktop_app_bin_dir(context)
.join("desktop-backend")
.join("index.js"),
);
candidates.into_iter().find(|path| path.is_file())
}
fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String> {
let mut command = if let Some(binary_path) = resolve_desktop_backend_binary_path(context) {
let mut command = Command::new(binary_path);
command.current_dir(&context.workspace_root);
command
} else if let Some(script_path) = resolve_desktop_backend_script_path(context) {
let cli_path = resolve_desktop_cli_path(context);
let script_path = if cfg!(debug_assertions) {
resolve_desktop_backend_script_path(context)
} else {
None
};
let mut command = if let Some(script_path) = script_path {
let mut command = Command::new("bun");
command
.arg("run")
.arg(script_path.to_string_lossy().to_string())
.current_dir(&context.workspace_root);
command
} else if let (Some(cli_path), Some(bundle_path)) = (
cli_path.as_ref(),
resolve_desktop_backend_bundle_path(context),
) {
let mut command = Command::new(cli_path);
command.arg("run").arg("--no-env-file").arg(&bundle_path);
// Run from the bundle's own directory so the runtime cannot pick up
// a workspace .env or bunfig.toml; the backend moves to the
// workspace root itself (CLINE_DESKTOP_WORKSPACE_ROOT).
if let Some(bundle_dir) = bundle_path.parent() {
command.current_dir(bundle_dir);
}
command
.env("BUN_BE_BUN", "1")
.env("CLINE_DESKTOP_WORKSPACE_ROOT", &context.workspace_root)
.env(
"CLINE_REMOTE_HELPER_DIRECTORY",
context.resource_dir.join("bin").join("remote-helpers"),
);
command
} else {
return Err(format!(
"desktop backend sidecar not found. checked binary/script under workspace_root={} and launch_cwd={}",
context.workspace_root, context.launch_cwd
"desktop backend not found: needs the bundled Cline CLI ({}) and backend bundle. checked under workspace_root={} and launch_cwd={}",
if cli_path.is_some() { "found" } else { "missing" },
context.workspace_root,
context.launch_cwd
));
};
if let Some(cli_path) = cli_path.as_ref() {
command.env("CLINE_DESKTOP_CLI_PATH", cli_path);
}
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -539,7 +579,7 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
hide_console_window(&mut command);
command
.spawn()
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
.map_err(|e| format!("failed to start desktop backend: {e}"))
}
fn ensure_desktop_backend_started(
@@ -803,7 +843,7 @@ fn wait_for_desktop_backend_endpoint(
.unwrap_or(true)
{
last_respawn = Some(Instant::now());
// A spawn error (sidecar binary missing) is permanent for this
// A spawn error (bundled CLI or backend missing) is permanent for this
// wait; report it instead of burning the rest of the window.
respawn()?;
}
@@ -899,7 +939,7 @@ fn apply_staged_update(app: &tauri::AppHandle) {
let update_state = app.state::<Arc<UpdateState>>();
// Neither restart() nor install() returns, so the run-loop Exit handler
// does not get a chance to stop the sidecar; shut it down explicitly
// first. On Windows this also releases the sidecar exe's file lock,
// first. On Windows this also releases the bundled CLI's file lock,
// which the NSIS installer needs in order to replace it.
backend_state.stop();
// Windows and Linux: install the bytes staged by the background cycle.
@@ -1489,10 +1529,6 @@ fn main() {
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let workspace_root = resolve_workspace_root(&launch_cwd);
let app_context = AppContext {
launch_cwd,
workspace_root,
};
tauri::Builder::default()
// Closing the window only hides it, so on Windows a second launch from
@@ -1506,10 +1542,14 @@ fn main() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(desktop_backend)
.manage(app_context)
.manage(Arc::new(UpdateState::default()))
.manage(DesktopActionState::default())
.setup(|app| {
.setup(move |app| {
app.manage(AppContext {
launch_cwd: launch_cwd.clone(),
workspace_root: workspace_root.clone(),
resource_dir: app.path().resource_dir()?,
});
if tauri::is_dev() {
if let (Some(window), Some(product_name)) = (
app.get_webview_window(MAIN_WINDOW_LABEL),
@@ -1626,6 +1666,23 @@ mod tests {
assert!(entitlements.contains("<key>com.apple.security.device.audio-input</key>"));
}
#[test]
fn backend_bundle_uses_only_the_running_apps_resource_directory() {
let relative = Path::new("bin").join("desktop-backend").join("index.js");
for resource_dir in [
Path::new("/Applications/Cline.app/Contents/Resources"),
Path::new("C:/Program Files/Cline"),
Path::new("/usr/lib/Cline"),
Path::new("/usr/lib/Cline Beta"),
Path::new("/usr/lib/Cline Nightly"),
] {
assert_eq!(
desktop_backend_bundle_candidates(resource_dir),
vec![resource_dir.join(&relative)]
);
}
}
#[test]
fn oversized_window_is_clamped_to_the_work_area() {
// 1920x1080 at 150% scaling: 1280x720 logical work area (minus a
@@ -1,10 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.45",
"version": "0.0.46",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
"beforeDevCommand": "bun run build:runtime && bun run dev:web",
"devUrl": "http://localhost:3125",
"beforeBuildCommand": "bun run dmg:background && bun run build",
"frontendDist": "../webview/out"
@@ -41,12 +41,13 @@
"shortDescription": "Cline AI coding agent",
"longDescription": "Cline is an AI coding agent. It reads your code, edits files, runs commands, and works through tasks with you in any project on your machine.",
"homepage": "https://cline.bot",
"externalBin": ["bin/code-sidecar"],
"externalBin": ["bin/cline-cli"],
"resources": [
"icons/app/*.png",
"icons/app/macos/*.png",
"bin/remote-helpers/*",
"bin/extensions/*",
"bin/desktop-backend/*",
"bin/extensions/**/*",
"bin/mxc/**/*"
],
"icon": [
@@ -1276,20 +1276,8 @@ function ChatThreadPane({
});
};
fetchFlags();
// Reflect Settings changes without restarting or switching accounts.
const unsubscribe = desktopClient.subscribe(
"feature_flags_changed",
(payload) => {
if (!cancelled) {
setCloudAgentsFlagEnabled(
Boolean((payload as { cloudAgents?: boolean })?.cloudAgents),
);
}
},
);
return () => {
cancelled = true;
unsubscribe();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [accountUserId]);
@@ -112,52 +112,3 @@ describe("SettingsView font size", () => {
expect(increaseButton?.disabled).toBe(true);
});
});
describe("SettingsView cloud sessions rollout", () => {
it.each([
{
caseName: "the rollout explicitly enables it",
featureFlags: { cloudAgents: false, cloudAgentsAvailable: true },
visible: true,
},
{
caseName: "feature flags are unavailable",
featureFlags: new Error("feature flags unavailable"),
visible: false,
},
])("shows the preview setting only when $caseName", async ({
featureFlags,
visible,
}) => {
invoke.mockImplementation(async (command: string) => {
if (command === "get_feature_flags") {
if (featureFlags instanceof Error) throw featureFlags;
return featureFlags;
}
if (command === "get_desktop_settings") {
return { cloudSessionsEnabled: false };
}
return {
telemetryOptOut: false,
autoUpdateEnabled: true,
};
});
await act(async () => {
root.render(
<SettingsView
onExportDiagnostics={vi.fn()}
onNavigateSection={vi.fn()}
section="General"
/>,
);
});
await vi.waitFor(() =>
expect(
container.querySelector(
'[role="switch"][aria-label="Cloud sessions"]',
) !== null,
).toBe(visible),
);
});
});
@@ -706,32 +706,6 @@ function GeneralSettingsContent({
const [keepChangesInProjectError, setKeepChangesInProjectError] = useState<
string | null
>(null);
const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false);
const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true);
const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false);
const [cloudSessionsError, setCloudSessionsError] = useState<string | null>(
null,
);
// The environment override can differ from the stored opt-in.
const [cloudSessionsEffective, setCloudSessionsEffective] = useState<
boolean | null
>(null);
// Keep the preview hidden until the rollout service explicitly enables it.
const [cloudSessionsAvailable, setCloudSessionsAvailable] = useState(false);
const refreshCloudSessionsEffective = useCallback(async () => {
try {
const flags = await desktopClient.invoke<{
cloudAgents?: boolean;
cloudAgentsAvailable?: boolean;
}>("get_feature_flags");
setCloudSessionsEffective(Boolean(flags.cloudAgents));
setCloudSessionsAvailable(flags.cloudAgentsAvailable === true);
} catch {
setCloudSessionsEffective(null);
setCloudSessionsAvailable(false);
}
}, []);
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
useEffect(() => subscribeToAppFontSize(setFontSize), []);
@@ -741,46 +715,22 @@ function GeneralSettingsContent({
setTelemetryError(null);
setAutoUpdateLoading(true);
setAutoUpdateError(null);
setCloudSessionsLoading(true);
setCloudSessionsError(null);
await Promise.all([
(async () => {
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
setKeepChangesInProject(settings.keepChangesInProject === true);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
})(),
(async () => {
try {
const desktopSettings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("get_desktop_settings");
setCloudSessionsEnabled(
Boolean(desktopSettings.cloudSessionsEnabled),
);
} catch (error) {
setCloudSessionsError(
error instanceof Error ? error.message : String(error),
);
} finally {
setCloudSessionsLoading(false);
}
})(),
refreshCloudSessionsEffective(),
]);
}, [refreshCloudSessionsEffective]);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
setKeepChangesInProject(settings.keepChangesInProject === true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
@@ -849,26 +799,6 @@ function GeneralSettingsContent({
}
};
const updateCloudSessionsEnabled = async (nextValue: boolean) => {
const previousValue = cloudSessionsEnabled;
setCloudSessionsEnabled(nextValue);
setCloudSessionsSaving(true);
setCloudSessionsError(null);
try {
const settings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue });
setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled));
await refreshCloudSessionsEffective();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCloudSessionsEnabled(previousValue);
setCloudSessionsError(message);
} finally {
setCloudSessionsSaving(false);
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
@@ -1104,46 +1034,6 @@ function GeneralSettingsContent({
}
/>
</div>
{cloudSessionsAvailable ? (
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="flex items-center gap-2 text-base font-semibold text-foreground">
Cloud sessions
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-primary">
Preview
</span>
</p>
<p className="text-sm text-muted-foreground">
Run Cline on your GitHub repositories in secure cloud sandboxes.
Adds a Cloud option to the new-session composer. Requires a
Cline account with GitHub connected.
</p>
{cloudSessionsError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update cloud sessions setting: {cloudSessionsError}
</p>
) : null}
{cloudSessionsEffective !== null &&
!cloudSessionsLoading &&
cloudSessionsEffective !== cloudSessionsEnabled ? (
<p className="mt-2 text-xs text-muted-foreground">
Cloud sessions are currently{" "}
{cloudSessionsEffective ? "enabled" : "disabled"} by the
CLINE_CODE_CLOUD_AGENTS environment override, which takes
precedence over this setting.
</p>
) : null}
</div>
<Switch
aria-label="Cloud sessions"
checked={cloudSessionsEnabled}
disabled={cloudSessionsLoading || cloudSessionsSaving}
onCheckedChange={(checked) =>
void updateCloudSessionsEnabled(checked)
}
/>
</div>
) : null}
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">Telemetry</p>
+6 -2
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.1.23",
"version": "4.1.24",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
@@ -379,6 +379,7 @@
"test:integration": "bun run compile-tests && vscode-test",
"test:unit": "bun scripts/run-bun-unit-tests.ts",
"test:vitest": "vitest run --config vitest.config.ts",
"test:cloud": "vitest run --config vitest.cloud.config.ts",
"test:vitest:watch": "vitest --config vitest.config.ts",
"test:bun": "bun scripts/run-bun-tests.ts",
"test:bun:unit": "bun scripts/run-bun-unit-tests.ts",
@@ -386,6 +387,7 @@
"test:sca-server": "bun --watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "bun scripts/testing-platform-orchestrator.ts",
"dev:mcp-oauth-test-server": "bun src/dev/mcp-oauth-test-server/server.ts",
"dev:cloud-sessions": "bun src/dev/local-cloud-server.ts",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e": "bun run build:production && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
@@ -430,6 +432,7 @@
"@types/sinon": "^21.0.0",
"@types/turndown": "^5.0.5",
"@types/vscode": "1.101.0",
"@types/ws": "^8.18.1",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
@@ -455,7 +458,8 @@
"ts-proto": "^2.6.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
"vitest": "^4.0.17",
"ws": "^8.20.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+84
View File
@@ -0,0 +1,84 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
// Cline Cloud sessions: run a task in a hosted sandbox instead of the local workspace.
service CloudService {
// GitHub App connection status and the repositories it can access, for the active account scope.
// Cached per account; `refresh` re-asks the control plane.
rpc getGitHubConnection(GitHubConnectionRequest) returns (GitHubConnection);
// Opens the GitHub App install / manage-access page in the browser
rpc connectGitHub(EmptyRequest) returns (Empty);
// Lists branches of an accessible repository
rpc getRepositoryBranches(RepositoryBranchesRequest) returns (CloudBranchList);
// Records one user choice about where new tasks run: Local/Cloud, a repository, or a branch
rpc chooseCloudTaskTarget(CloudTaskTargetChoice) returns (Empty);
// Opens the Cline Cloud dashboard page for a cloud session
rpc openCloudSessionDashboard(StringRequest) returns (Empty);
// Resolves live agent status for only the cloud sessions currently displayed
rpc resolveCloudSessionStatuses(CloudSessionStatusRequest) returns (CloudSessionStatusList);
}
message GitHubRepository {
int64 id = 1;
string name = 2;
string full_name = 3;
string url = 4;
string default_branch = 5;
}
message GitHubConnectionRequest {
Metadata metadata = 1;
bool refresh = 2;
}
message GitHubConnection {
bool signed_in = 1;
bool connected = 2;
string connect_url = 3;
repeated GitHubRepository repositories = 4;
// Set when the request failed (network / API error); other fields are then unreliable
optional string error = 5;
}
message RepositoryBranchesRequest {
Metadata metadata = 1;
int64 repository_id = 2;
optional string query = 3;
}
message CloudBranchList {
repeated string branches = 1;
}
// Exactly one field is set: the choice the user just made. Choosing a
// repository forgets the branch chosen for the previous one.
message CloudTaskTargetChoice {
Metadata metadata = 1;
oneof choice {
// "local" | "cloud"
string target = 2;
int64 repository_id = 3;
string branch = 4;
}
}
message CloudSessionStatusRequest {
Metadata metadata = 1;
repeated string session_ids = 2;
}
message CloudSessionStatusItem {
string session_id = 1;
string status = 2;
}
message CloudSessionStatusList {
repeated CloudSessionStatusItem statuses = 1;
}
+4 -1
View File
@@ -287,6 +287,8 @@ message Settings {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
optional string act_mode_cline_pass_model_id = 185;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
optional bool cloud_sessions_enabled = 188;
optional string open_telemetry_traces_exporter = 189;
}
message State {
@@ -431,7 +433,8 @@ message UpdateSettingsRequest {
optional bool show_feature_tips = 42;
optional string compaction_strategy = 44;
optional bool web_search_enabled = 45;
optional bool keep_changes_in_project = 46;
optional bool cloud_sessions_enabled = 46;
optional bool keep_changes_in_project = 47;
}
message UpdateTerminalConnectionTimeoutRequest {
+13
View File
@@ -54,6 +54,10 @@ message NewTaskRequest {
repeated string images = 3;
repeated string files = 4;
optional Settings task_settings = 5;
// "local" (default) or "cloud"; cloud runs the task in a Cline Cloud sandbox
optional string execution_target = 6;
optional string cloud_repo_url = 7;
optional string cloud_branch = 8;
}
// Request message for toggling task favorite status
@@ -88,6 +92,7 @@ message GetTaskHistoryRequest {
bool current_workspace_only = 5;
int32 limit = 6;
int32 offset = 7;
bool cloud_only = 8;
}
// Response for task history
@@ -113,6 +118,14 @@ message TaskItem {
// Provider id the task ran on. Empty for tasks recorded before this
// field existed and for legacy imports; consumers show cost in that case.
string api_provider = 13;
// "cloud" for Cline Cloud sessions; empty/"local" otherwise
string execution_target = 14;
// Cloud session status: provisioning | running | idle | completed | failed | cancelled | unknown | expired
string cloud_status = 15;
string cloud_repo_url = 16;
string cloud_branch = 17;
// True only when token/cost fields came from an authoritative cloud runtime snapshot.
bool cloud_usage_available = 18;
}
// Request for ask response operation
@@ -0,0 +1,70 @@
const accessToken = process.env.CLINE_OAUTH_TOKEN?.trim()
if (!accessToken) {
throw new Error("Set CLINE_OAUTH_TOKEN to a Cline OAuth access token.")
}
const apiBaseUrl = (process.env.CLINE_API_BASE_URL ?? "https://api.cline.bot").replace(/\/+$/, "")
const repositoryUrl = process.env.CLINE_REPO_URL ?? "https://github.com/cline/cline"
const branch = process.env.CLINE_BRANCH
const modelId = process.env.CLINE_MODEL_ID ?? "openai/gpt-5.6-sol"
const organizationId = process.env.CLINE_ORGANIZATION_ID
async function request(path, init = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
...init,
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
})
const body = response.status === 204 ? undefined : await response.json().catch(() => undefined)
if (!response.ok) {
throw new Error(`${init.method ?? "GET"} ${path} returned ${response.status}: ${JSON.stringify(body)}`)
}
return body?.data
}
const created = await request("/api/v1/session", {
method: "POST",
body: JSON.stringify({
modelId,
repoUrl: repositoryUrl,
...(branch ? { branch } : {}),
...(organizationId ? { organizationId } : {}),
}),
})
const sessionId = created?.sessionId
if (!sessionId) {
throw new Error(`Create response had no session id: ${JSON.stringify(created)}`)
}
console.log(JSON.stringify({ event: "created", sessionId, created }))
try {
const deadline = Date.now() + 10 * 60_000
while (Date.now() < deadline) {
const sessions = await request(
`/api/v1/session${organizationId ? `?organizationId=${encodeURIComponent(organizationId)}` : ""}`,
)
const record = sessions?.find((session) => session.id === sessionId)
const status = await request(`/api/v1/session/${encodeURIComponent(sessionId)}/status`)
console.log(
JSON.stringify({
event: "status",
sessionId,
sandboxId: record?.sandboxId,
taskId: record?.metadata?.taskId,
status,
}),
)
if (["ready", "active", "failed"].includes(status?.status)) {
break
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
} finally {
await request(`/api/v1/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch((error) => {
console.error(`Cleanup failed for ${sessionId}:`, error)
})
}
+13
View File
@@ -314,6 +314,19 @@ class ClineEndpoint {
mcpBaseUrl: "https://core-api.staging.int.cline.bot/v1/mcp",
}
case Environment.local:
// The local fixture's app and API share one listener. Resolve them together.
if (process.env.CLINE_LOCAL_CLOUD_URL) {
const url = new URL(process.env.CLINE_LOCAL_CLOUD_URL)
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.username || url.password) {
throw new ClineConfigurationError("CLINE_LOCAL_CLOUD_URL must be an HTTP loopback URL on 127.0.0.1")
}
return {
environment: Environment.local,
appBaseUrl: url.origin,
apiBaseUrl: url.origin,
mcpBaseUrl: `${url.origin}/v1/mcp`,
}
}
return {
environment: Environment.local,
appBaseUrl: "http://localhost:3000",
@@ -9,13 +9,23 @@ import type { Controller } from "../index"
* @returns User credits data response
*/
export async function getUserOrganizations(controller: Controller, _request: EmptyRequest): Promise<UserOrganizationsResponse> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Fetch user organizations from the account service
const organizations = await controller.accountService.fetchUserOrganizationsRPC()
const organizations = await Promise.race([
controller.accountService.fetchUserOrganizationsRPC(),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Account confirmation timed out. Check your connection and try again.")),
10_000,
)
}),
])
if (!organizations) throw new Error("Could not confirm the active account. Check your connection and try again.")
return UserOrganizationsResponse.create({
organizations:
@@ -29,7 +39,7 @@ export async function getUserOrganizations(controller: Controller, _request: Emp
}),
) || [],
})
} catch (error) {
throw error
} finally {
clearTimeout(timer)
}
}
@@ -1,15 +1,52 @@
import { UserOrganizationUpdateRequest } from "@shared/proto/cline/account"
import { describe, expect, it, vi } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"
import { getUserOrganizations } from "./getUserOrganizations"
import { setUserOrganization } from "./setUserOrganization"
describe("setUserOrganization", () => {
afterEach(() => vi.useRealTimers())
it("returns an unconfirmed timeout while the switch keeps running", async () => {
vi.useFakeTimers()
let finish!: () => void
const switchAccount = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve
}),
)
const refreshRemoteConfig = vi.fn(async () => {})
const controller = { accountService: { switchAccount }, refreshRemoteConfig }
const request = UserOrganizationUpdateRequest.create({ organizationId: "org-new" })
const first = expect(setUserOrganization(controller as never, request)).rejects.toThrow("not confirmed within 10 seconds")
await vi.advanceTimersByTimeAsync(10_000)
await first
expect(refreshRemoteConfig).not.toHaveBeenCalled()
finish()
await vi.advanceTimersByTimeAsync(0)
// The timed-out switch still finishes its work.
expect(refreshRemoteConfig).toHaveBeenCalledOnce()
})
it("does not turn unavailable organizations into confirmed Personal", async () => {
const fetchUserOrganizationsRPC = vi.fn().mockResolvedValue(undefined)
const controller = { accountService: { fetchUserOrganizationsRPC } }
await expect(getUserOrganizations(controller as never, {})).rejects.toThrow("Could not confirm")
fetchUserOrganizationsRPC.mockResolvedValue([])
await expect(getUserOrganizations(controller as never, {})).resolves.toEqual({ organizations: [] })
})
it("bounds confirmation even when obtaining credentials is stuck", async () => {
vi.useFakeTimers()
const controller = { accountService: { fetchUserOrganizationsRPC: () => new Promise(() => {}) } }
const checking = expect(getUserOrganizations(controller as never, {})).rejects.toThrow("confirmation timed out")
await vi.advanceTimersByTimeAsync(10_000)
await checking
})
it("refreshes through the authoritative SDK path after switching organizations", async () => {
const switchAccount = vi.fn().mockResolvedValue(undefined)
const refreshRemoteConfig = vi.fn().mockResolvedValue(undefined)
const controller = {
accountService: { switchAccount },
refreshRemoteConfig,
}
const controller = { accountService: { switchAccount }, refreshRemoteConfig }
await setUserOrganization(controller as never, UserOrganizationUpdateRequest.create({ organizationId: "org-new" }))
@@ -2,6 +2,8 @@ import { UserOrganizationUpdateRequest } from "@shared/proto/cline/account"
import { Empty } from "@shared/proto/cline/common"
import type { Controller } from "../index"
const SWITCH_TIMEOUT_MS = 10_000
/**
* Handles setting the user's active organization
* @param controller The controller instance
@@ -9,15 +11,31 @@ import type { Controller } from "../index"
* @returns Empty response
*/
export async function setUserOrganization(controller: Controller, request: UserOrganizationUpdateRequest): Promise<Empty> {
if (!controller.accountService) throw new Error("Account service not available")
// switchAccount serialises switches and tears down the previous account's
// cloud state itself. The response deadline below does not release that
// ownership: a timed-out PUT may still commit, and a later switch waits for
// it before deciding what to do.
const switching = controller.accountService.switchAccount(request.organizationId).then(() => controller.refreshRemoteConfig())
switching.catch(() => {})
let timer: ReturnType<typeof setTimeout> | undefined
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Switch to the specified organization using the account service
await controller.accountService.switchAccount(request.organizationId)
await controller.refreshRemoteConfig()
await Promise.race([
switching,
new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
"Account switch was not confirmed within 10 seconds and may still finish. Check Account before starting another task.",
),
),
SWITCH_TIMEOUT_MS,
)
}),
])
return {}
} catch (error) {
throw error
} finally {
clearTimeout(timer)
}
}
@@ -0,0 +1,15 @@
import type { CloudTaskTargetChoice } from "@shared/proto/cline/cloud"
import { Empty } from "@shared/proto/cline/common"
import type { Controller } from "../index"
/** Records the one choice the user just made about where new tasks run. */
export async function chooseCloudTaskTarget(controller: Controller, request: CloudTaskTargetChoice): Promise<Empty> {
if (request.target !== undefined) {
await controller.cloudTaskTarget.choose({ target: request.target === "cloud" ? "cloud" : "local" })
} else if (request.repositoryId !== undefined) {
await controller.cloudTaskTarget.choose({ repositoryId: Number(request.repositoryId) })
} else if (request.branch !== undefined) {
await controller.cloudTaskTarget.choose({ branch: request.branch })
}
return Empty.create()
}
@@ -0,0 +1,10 @@
import { Empty, type EmptyRequest } from "@shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import type { Controller } from "../index"
/** Opens the GitHub App install (or manage repository access) page in the browser. */
export async function connectGitHub(controller: Controller, _request: EmptyRequest): Promise<Empty> {
const url = await controller.cloudSessions.getGitHubInstallUrl().catch(() => controller.cloudSessions.githubConnectUrl())
await openExternal(url)
return Empty.create()
}
@@ -0,0 +1,15 @@
import { GitHubConnection, type GitHubConnectionRequest, GitHubRepository } from "@shared/proto/cline/cloud"
import type { Controller } from "../index"
/**
* GitHub App connection status for the active account scope, with the
* repositories the Cline GitHub App can access. Served from the same cache
* the task target resolves against, so the panel and the composer agree.
*/
export async function getGitHubConnection(controller: Controller, request: GitHubConnectionRequest): Promise<GitHubConnection> {
const status = await controller.cloudTaskTarget.connectionStatus(request.refresh)
return GitHubConnection.create({
...status,
repositories: status.repositories.map((repository) => GitHubRepository.create(repository)),
})
}
@@ -0,0 +1,10 @@
import { CloudBranchList, type RepositoryBranchesRequest } from "@shared/proto/cline/cloud"
import type { Controller } from "../index"
export async function getRepositoryBranches(
controller: Controller,
request: RepositoryBranchesRequest,
): Promise<CloudBranchList> {
const branches = await controller.cloudSessions.listBranches(Number(request.repositoryId), request.query)
return CloudBranchList.create({ branches })
}
@@ -0,0 +1,12 @@
import { isPersistedCloudSessionId } from "@shared/cloud/cloud-sessions"
import { Empty, type StringRequest } from "@shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import type { Controller } from "../index"
export async function openCloudSessionDashboard(controller: Controller, request: StringRequest): Promise<Empty> {
if (!isPersistedCloudSessionId(request.value)) {
throw new Error("The cloud session is not ready to open in the dashboard.")
}
await openExternal(controller.cloudSessions.dashboardUrl(request.value))
return Empty.create()
}
@@ -0,0 +1,13 @@
import { CloudSessionStatusItem, CloudSessionStatusList, type CloudSessionStatusRequest } from "@shared/proto/cline/cloud"
import type { Controller } from "../index"
/** Resolves live agent status for only the cloud sessions named by the visible UI surface. */
export async function resolveCloudSessionStatuses(
controller: Controller,
request: CloudSessionStatusRequest,
): Promise<CloudSessionStatusList> {
const statuses = await controller.resolveCloudSessionStatuses(request.sessionIds)
return CloudSessionStatusList.create({
statuses: statuses.map(({ sessionId, status }) => CloudSessionStatusItem.create({ sessionId, status })),
})
}
@@ -12,6 +12,11 @@ export const CLINE_PASS_PROVIDER_ID = "cline-pass"
* the state post that re-renders the settings UI — on it. Auth state changes
* propagate to the webview separately once the switch completes.
*
* switchAccount does nothing when the account is already Personal, waits for
* any switch already in flight before deciding, and tears down the previous
* account's cloud task and connections before the account changes, so this
* call is safe on every API configuration update.
*
* This is intentionally best-effort: selecting the provider should still be
* saved even if the account switch fails.
*/
@@ -40,6 +40,11 @@ export async function refreshClineRecommendedModels(): Promise<ClineRecommendedM
return pendingRefresh
}
/** The last successfully fetched list, without fetching; undefined before the first success. */
export function getCachedClineRecommendedModels(): ClineRecommendedModelsData | undefined {
return inMemoryCache?.data
}
export function resetClineRecommendedModelsCacheForTests(): void {
pendingRefresh = null
inMemoryCache = null
@@ -148,6 +148,12 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("worktreesEnabled", request.worktreesEnabled)
}
if (request.cloudSessionsEnabled !== undefined) {
controller.stateManager.setGlobalState("cloudSessionsEnabled", request.cloudSessionsEnabled)
// Merged History includes cloud rows only while cloud sessions are enabled.
controller.invalidateTaskHistoryCache()
}
// Update subagents setting
if (request.subagentsEnabled !== undefined) {
const wasEnabled = controller.stateManager.getGlobalSettingsKey("subagentsEnabled") ?? false
@@ -0,0 +1,51 @@
import { NewTaskRequest } from "@shared/proto/cline/task"
import { describe, expect, it, vi } from "vitest"
import type { Controller } from ".."
import { newTask } from "./newTask"
function makeController() {
const initTask = vi.fn().mockResolvedValue("task-1")
const controller = {
initTask,
stateManager: { getGlobalSettingsKey: () => ({ actions: {} }) },
} as unknown as Controller
return { controller, initTask }
}
describe("newTask execution target", () => {
it("refuses a cloud request that names no repository instead of starting locally", async () => {
const { controller, initTask } = makeController()
await expect(
newTask(controller, NewTaskRequest.create({ text: "hello", executionTarget: "cloud", cloudRepoUrl: " " })),
).rejects.toThrow("Choose a GitHub repository")
expect(initTask).not.toHaveBeenCalled()
})
it("passes the cloud target through when the repository is set", async () => {
const { controller, initTask } = makeController()
await newTask(
controller,
NewTaskRequest.create({
text: "hello",
executionTarget: "cloud",
cloudRepoUrl: "https://github.com/cline/fixture",
cloudBranch: "main",
}),
)
expect(initTask).toHaveBeenCalledWith("hello", [], [], undefined, expect.any(Object), {
repoUrl: "https://github.com/cline/fixture",
branch: "main",
})
})
it("starts locally when no execution target is given", async () => {
const { controller, initTask } = makeController()
await newTask(controller, NewTaskRequest.create({ text: "hello" }))
expect(initTask).toHaveBeenCalledWith("hello", [], [], undefined, expect.any(Object), undefined)
})
})
@@ -69,6 +69,23 @@ export async function newTask(controller: Controller, request: NewTaskRequest):
}).filter(([_, value]) => value !== undefined),
)
const taskId = await controller.initTask(request.text, request.images, request.files, undefined, filteredTaskSettings)
let cloudTarget: { repoUrl: string; branch?: string } | undefined
if (request.executionTarget === "cloud") {
const repoUrl = request.cloudRepoUrl?.trim()
// A cloud request without a repository is incomplete; running it locally
// instead would contradict the Cloud choice the user made.
if (!repoUrl) {
throw new Error("Choose a GitHub repository before starting a cloud task.")
}
cloudTarget = { repoUrl, branch: request.cloudBranch?.trim() || undefined }
}
const taskId = await controller.initTask(
request.text,
request.images,
request.files,
undefined,
filteredTaskSettings,
cloudTarget,
)
return String.create({ value: taskId || "" })
}
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "../openai-base-url-migration"
describe("migrateOpenAiCompatibleBaseUrlToV1", () => {
let tempDir: string
let storage: StorageContext
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openai-base-url-migration-"))
storage = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
for (const [baseUrl, expected] of [
["https://example.com", "https://example.com/v1"],
["http://localhost:8000/", "http://localhost:8000/v1"],
]) {
it(`adds /v1 to the host-only base URL ${baseUrl}`, () => {
storage.globalState.update("openAiBaseUrl", baseUrl)
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(expected)
})
}
for (const baseUrl of ["https://example.com/v1", "https://example.com/api/foo", "not a url"]) {
it(`leaves the base URL ${baseUrl} unchanged`, () => {
storage.globalState.update("openAiBaseUrl", baseUrl)
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(baseUrl)
})
}
it("runs only once, so a host-only URL saved afterwards stays as entered", () => {
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
it("marks itself done when there is no base URL, so a later host-only URL stays as entered", () => {
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
it("shares its marker across hosts through globalState.json", () => {
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
const otherHost = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
migrateOpenAiCompatibleBaseUrlToV1(otherHost)
expect(otherHost.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
})
@@ -0,0 +1,29 @@
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
const MIGRATED_KEY = "__openAiBaseUrlV1Migrated"
/**
* Earlier SDK builds sent requests for an OpenAI Compatible base URL with no
* path (`http://localhost:8000`) to `<host>/v1`; the URL is now used as entered.
* Save the `/v1` so existing setups keep reaching the same endpoint.
*
* Runs once across all hosts: the marker lives in the shared globalState.json
* and is set even when there is nothing to rewrite, so a host-only URL saved
* afterwards (in any host) stays as entered.
*/
export function migrateOpenAiCompatibleBaseUrlToV1(storage: StorageContext): void {
if (storage.globalState.get(MIGRATED_KEY)) {
return
}
const baseUrl = storage.globalState.get<string>("openAiBaseUrl")?.trim()
if (baseUrl && URL.canParse(baseUrl)) {
const url = new URL(baseUrl)
if (url.pathname === "/") {
url.pathname = "/v1"
storage.globalState.update("openAiBaseUrl", url.toString().replace(/\/$/, ""))
Logger.info("[Migration] Added /v1 to the host-only OpenAI Compatible base URL")
}
}
storage.globalState.update(MIGRATED_KEY, true)
}
@@ -57,6 +57,7 @@ async function makeAuthenticatedRequest<T>(endpoint: string, organizationId: str
const apiEndpoint = endpoint.replace("{id}", organizationId)
const url = new URL(apiEndpoint, ClineEnv.config().apiBaseUrl).toString()
const requestConfig: AxiosRequestConfig = {
timeout: 10_000,
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
@@ -0,0 +1,119 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { type LocalCloudEnvironment, startLocalCloudEnvironment } from "../test/cloud/local-cloud-environment"
export async function startLocalCloudDevelopment(options: { port?: number; tempDir?: string } = {}) {
const accessToken = "local-cloud-development-token"
const clineDir = await mkdtemp(path.join(options.tempDir ?? tmpdir(), "cline-local-cloud-profile-"))
const dataDir = path.join(clineDir, "data")
let environment: LocalCloudEnvironment | undefined
let disposal: Promise<void> | undefined
const dispose = (): Promise<void> => {
disposal ??= (async () => {
try {
await environment?.dispose()
} finally {
await rm(clineDir, { recursive: true, force: true })
}
})()
return disposal
}
// CLINE_LOCAL_CLOUD_MODEL_DELAY_MS holds every scripted model reply so a
// person can act (cancel, navigate) while the sandbox is still "thinking".
const modelDelayMs = Number(process.env.CLINE_LOCAL_CLOUD_MODEL_DELAY_MS ?? 0)
const beforeModelResponse =
modelDelayMs > 0
? (signal?: AbortSignal | null) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, modelDelayMs)
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer)
reject(signal.reason)
},
{ once: true },
)
})
: undefined
// CLINE_LOCAL_CLOUD_PROVISION_DELAY_MS keeps a new sandbox in `provisioning`
// for that long, like the hosted control plane, so cancelling during
// provisioning can be exercised.
const provisioningDelayMs = Number(process.env.CLINE_LOCAL_CLOUD_PROVISION_DELAY_MS ?? 0)
// CLINE_LOCAL_CLOUD_SEED_EXPIRED=1 starts with two Personal sessions whose
// sandboxes are already gone, so the read-only transcript view and the
// "no history captured" path can be exercised without waiting a day.
const seedExpiredSessions = process.env.CLINE_LOCAL_CLOUD_SEED_EXPIRED === "1"
// CLINE_LOCAL_CLOUD_INSUFFICIENT_CREDITS=1 makes every model reply the hosted
// API's 402 insufficient-credits response, so the out-of-credits card can be
// exercised without spending anything.
const insufficientCredits = process.env.CLINE_LOCAL_CLOUD_INSUFFICIENT_CREDITS === "1"
// CLINE_LOCAL_CLOUD_REFUSE_SOCKETS=1 refuses every session socket, so a cloud
// start fails after its sandbox is ready and the Retry path can be exercised.
const refuseSessionSockets = process.env.CLINE_LOCAL_CLOUD_REFUSE_SOCKETS === "1"
// CLINE_LOCAL_CLOUD_SUSPEND_IDLE_MS suspends a sandbox that long after its last
// client disconnects, like the hosted control plane does to resumable
// sandboxes, so reopening and resuming a suspended task can be exercised.
const suspendIdleMs = process.env.CLINE_LOCAL_CLOUD_SUSPEND_IDLE_MS
const suspendIdleAfterMs = suspendIdleMs ? Number(suspendIdleMs) : undefined
try {
environment = await startLocalCloudEnvironment({
...options,
port: options.port ?? 7777,
accessToken,
beforeModelResponse,
provisioningDelayMs,
seedExpiredSessions,
insufficientCredits,
refuseSessionSockets,
suspendIdleAfterMs,
})
const settingsDir = path.join(dataDir, "settings")
await mkdir(settingsDir, { recursive: true })
await writeFile(path.join(dataDir, "globalState.json"), JSON.stringify({ welcomeViewCompleted: true }))
await writeFile(
path.join(settingsDir, "providers.json"),
JSON.stringify({
version: 1,
lastUsedProvider: "cline",
providers: {
cline: {
settings: {
provider: "cline",
auth: {
accessToken: `workos:${accessToken}`,
accountId: "local-cloud-user",
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
},
tokenSource: "oauth",
updatedAt: new Date().toISOString(),
},
},
}),
)
const launchEnv = {
CLINE_ENVIRONMENT: "local",
CLINE_ENVIRONMENT_OVERRIDE: "local",
CLINE_LOCAL_CLOUD_URL: environment.apiBaseUrl,
CLINE_API_BASE_URL: environment.apiBaseUrl,
CLINE_CLOUD_SESSIONS: "1",
CLINE_DIR: clineDir,
CLINE_DATA_DIR: dataDir,
}
return {
environment,
clineDir,
launchEnv,
launchEnvironment: Object.entries(launchEnv)
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
.join(" "),
dispose,
}
} catch (error) {
await dispose()
throw error
}
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bun
import { startLocalCloudDevelopment } from "./local-cloud-development"
const development = await startLocalCloudDevelopment()
let disposing = false
async function dispose(): Promise<void> {
if (disposing) return
disposing = true
try {
await development.dispose()
process.exit(0)
} catch (error) {
console.error("Failed to dispose local cloud development resources:", error)
process.exit(1)
}
}
process.on("SIGINT", () => void dispose())
process.on("SIGTERM", () => void dispose())
console.log("Local cloud sessions fixture is ready.")
console.log(`API: ${development.environment.apiBaseUrl}`)
console.log(`Token: ${development.environment.accessToken}`)
console.log(`CLINE_DIR: ${development.clineDir}`)
console.log("")
console.log("Launch the extension host with:")
console.log(` ${development.launchEnvironment}`)
console.log("The fixture accepts only loopback connections and blocks unexpected model requests.")
console.log("Press Ctrl-C to dispose all sandboxes and exit.")
+2
View File
@@ -29,6 +29,7 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "./core/storage/openai-base-url-migration"
import {
cleanupMcpMarketplaceCatalogFromGlobalState,
cleanupOldApiKey,
@@ -87,6 +88,7 @@ export async function activate(context: vscode.ExtensionContext) {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const storageContext = createStorageContext({ workspacePath })
await exportVSCodeStorageToSharedFiles(context, storageContext)
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
// 4. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup and migrations are complete
+294 -3
View File
@@ -1,4 +1,8 @@
import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE } from "@shared/cloud/cloud-sessions"
import type { ClineMessage, TurnState } from "@shared/ExtensionMessage"
import { GetTaskHistoryRequest } from "@shared/proto/cline/task"
import { describe, expect, it, vi } from "vitest"
import { ClineError } from "@/services/error/ClineError"
import { telemetryService } from "@/services/telemetry"
import { isClineManagedProvider } from "@/shared/utils/cline"
import { Controller as SdkController } from "./SdkController"
@@ -42,6 +46,7 @@ describe("SDK remote-config coordination", () => {
const controller = {
stateManager: {
getGlobalSettingsKey: () => undefined,
getGlobalStateKey: () => undefined,
getRemoteConfigSettings: () => ({}),
setGlobalState: vi.fn(),
},
@@ -53,6 +58,7 @@ describe("SDK remote-config coordination", () => {
ensureWorkspaceManager: async () => undefined,
taskHistory: { listHistory: async () => [] },
sessions: { getActiveSession: () => undefined },
cloud: { getCurrentTaskInfo: () => undefined },
turnStateTracker: { get: () => undefined },
messageTranslatorState: { getMinter: () => ({ epoch: 1, nextSeq: () => 1 }) },
}
@@ -70,6 +76,7 @@ describe("SDK remote-config coordination", () => {
const controller = {
stateManager: {
getGlobalSettingsKey: () => undefined,
getGlobalStateKey: () => undefined,
getRemoteConfigSettings: () => ({}),
setGlobalState: vi.fn(),
},
@@ -88,6 +95,7 @@ describe("SDK remote-config coordination", () => {
},
},
sessions: { getActiveSession: () => undefined },
cloud: { getCurrentTaskInfo: () => undefined, getCloudModelId: () => "cloud-model" },
turnStateTracker: { get: () => undefined },
messageTranslatorState: { getMinter: () => minter },
getStateToPostToWebview: SdkController.prototype.getStateToPostToWebview,
@@ -290,6 +298,7 @@ describe("SDK remote-config coordination", () => {
messageTranslatorState: { clearTurnOutcome: vi.fn() },
messages: { appendAndEmit: vi.fn() },
sessions: { getActiveSession: () => undefined },
cloud: { isCloudSessionId: () => false },
postStateToWebview: vi.fn(async () => {}),
initTask: vi.fn(async () => "task-id"),
followups: { askResponse: vi.fn(async () => {}) },
@@ -316,21 +325,42 @@ describe("SDK remote-config coordination", () => {
it("restarts a new task with the original prompt when Retry is clicked", async () => {
const { controller, errorTask } = controllerShowingSignInError()
await errorTask.handleWebviewAskResponse("yesButtonClicked")
expect(controller.initTask).toHaveBeenCalledWith("original prompt", undefined, undefined)
expect(controller.initTask).toHaveBeenCalledWith(
"original prompt",
undefined,
undefined,
undefined,
undefined,
undefined,
)
expect(controller.followups.askResponse).not.toHaveBeenCalled()
})
it("restarts a new task with a revised prompt submitted from the composer", async () => {
const { controller, errorTask } = controllerShowingSignInError()
await errorTask.handleWebviewAskResponse("messageResponse", "revised prompt", ["img"])
expect(controller.initTask).toHaveBeenCalledWith("revised prompt", ["img"], undefined)
expect(controller.initTask).toHaveBeenCalledWith(
"revised prompt",
["img"],
undefined,
undefined,
undefined,
undefined,
)
expect(controller.followups.askResponse).not.toHaveBeenCalled()
})
it("keeps the original prompt when the revised submission has attachments but no text", async () => {
const { controller, errorTask } = controllerShowingSignInError()
await errorTask.handleWebviewAskResponse("messageResponse", " ", ["img"])
expect(controller.initTask).toHaveBeenCalledWith("original prompt", ["img"], undefined)
expect(controller.initTask).toHaveBeenCalledWith(
"original prompt",
["img"],
undefined,
undefined,
undefined,
undefined,
)
})
it("continues an existing conversation with a message submitted from the composer", async () => {
@@ -366,6 +396,114 @@ describe("SDK remote-config coordination", () => {
})
})
describe("after a cloud start fails before it has a session", () => {
const input = { prompt: "cloud prompt", images: ["img"], repoUrl: "https://github.com/cline/fixture", branch: "main" }
const cloudTarget = { repoUrl: input.repoUrl, branch: input.branch }
function controllerShowingCloudStartError(unconfirmedAsk?: ClineMessage) {
const controller = {
task: undefined as TaskProxy | undefined,
turnStateTracker: {
set: vi.fn(),
get: (): TurnState => ({ phase: "error", anchorTs: unconfirmedAsk?.ts, seq: 1 }),
},
messageTranslatorState: { clearTurnOutcome: vi.fn() },
messages: { appendAndEmit: vi.fn() },
sessions: { getActiveSession: () => undefined },
cloud: { isCloudSessionId: () => true, getCurrentTaskInfo: () => ({ status: "unknown" }) },
postStateToWebview: vi.fn(async () => {}),
initTask: vi.fn(async () => "ses-retried"),
followups: { askResponse: vi.fn(async () => {}) },
cancelTask: vi.fn(async () => {}),
askResponse(prompt?: string, images?: string[], files?: string[]) {
return SdkController.prototype.askResponse.call(controller as never, prompt, images, files)
},
}
const errorTask = createTaskProxy("cloud-provisioning-1", controller.askResponse, controller.cancelTask)
controller.task = errorTask
if (unconfirmedAsk) errorTask.messageStateHandler.addMessages([unconfirmedAsk])
else SdkController.prototype["offerCloudStartRetry"].call(controller as never, errorTask, input)
return { controller, errorTask }
}
it("keeps unconfirmed-create guidance and its anchor when a stale Retry reaches the controller", async () => {
const message = "Cloud session creation could not be confirmed. Check History or the cloud dashboard."
const ask: ClineMessage = {
ts: Date.now() + 1_000,
type: "ask",
ask: "api_req_failed",
text: new ClineError({ message, code: CLOUD_CREATION_UNCONFIRMED_ERROR_CODE }).serialize(),
}
const { controller, errorTask } = controllerShowingCloudStartError(ask)
await errorTask.handleWebviewAskResponse("yesButtonClicked")
expect(controller.initTask).not.toHaveBeenCalled()
expect(controller.followups.askResponse).not.toHaveBeenCalled()
expect(controller.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "error", text: message })],
expect.objectContaining({ type: "status" }),
)
expect(controller.messages.appendAndEmit.mock.calls[0]?.[0][0].ts).toBeGreaterThan(ask.ts)
expect(controller.turnStateTracker.set).toHaveBeenCalledWith("error", ask.ts)
})
it("starts the same cloud task again when Retry is clicked", async () => {
const { controller, errorTask } = controllerShowingCloudStartError()
await errorTask.handleWebviewAskResponse("yesButtonClicked")
expect(controller.initTask).toHaveBeenCalledWith(
"cloud prompt",
["img"],
undefined,
undefined,
undefined,
cloudTarget,
)
expect(controller.messages.appendAndEmit).not.toHaveBeenCalled()
})
it("keeps the original attachments when Retry arrives with the webview's empty image list", async () => {
const { controller, errorTask } = controllerShowingCloudStartError()
// Protobuf decodes the Retry request's omitted repeated images field as an empty array.
await errorTask.handleWebviewAskResponse("yesButtonClicked", "", [])
expect(controller.initTask).toHaveBeenCalledWith(
"cloud prompt",
["img"],
undefined,
undefined,
undefined,
cloudTarget,
)
})
it("starts a cloud task on the same target with a prompt typed into the composer", async () => {
const { controller, errorTask } = controllerShowingCloudStartError()
await errorTask.handleWebviewAskResponse("messageResponse", "revised prompt")
expect(controller.initTask).toHaveBeenCalledWith(
"revised prompt",
undefined,
undefined,
undefined,
undefined,
cloudTarget,
)
})
it("advances the turn phase when it refuses a response to a session that is gone", async () => {
const { controller, errorTask } = controllerShowingCloudStartError()
// The retry is consumed by the first response; a second one finds no session to send to.
await errorTask.handleWebviewAskResponse("yesButtonClicked")
controller.initTask.mockClear()
await errorTask.handleWebviewAskResponse("yesButtonClicked")
expect(controller.initTask).not.toHaveBeenCalled()
expect(controller.messages.appendAndEmit).toHaveBeenCalledOnce()
expect(controller.turnStateTracker.set).toHaveBeenCalledWith("error")
expect(controller.turnStateTracker.set.mock.invocationCallOrder[0]).toBeLessThan(
controller.postStateToWebview.mock.invocationCallOrder.at(-1)!,
)
})
})
it("waits for initial remote config before resuming an existing task", async () => {
const events: string[] = []
const controller = {
@@ -532,3 +670,156 @@ describe("resolveWorkspaceManagerPaths", () => {
expect(resolveWorkspaceManagerPaths([], " ")).toEqual([])
})
})
describe("starting a cloud task", () => {
it("leaves the turn phase to the coordinator so a start cancelled during policy loading stays idle", async () => {
const run = vi.fn(async () => undefined)
const controller = {
cloud: { beginCloudTask: vi.fn(() => run) },
waitForInitialRemoteConfig: vi.fn(async () => undefined),
turnStateTracker: { set: vi.fn() },
messageTranslatorState: { clearTurnOutcome: vi.fn() },
}
await SdkController.prototype.initTask.call(controller as never, "prompt", undefined, undefined, undefined, undefined, {
repoUrl: "https://github.com/cline/fixture",
})
expect(controller.cloud.beginCloudTask.mock.invocationCallOrder[0]).toBeLessThan(
controller.waitForInitialRemoteConfig.mock.invocationCallOrder[0],
)
expect(run).toHaveBeenCalledOnce()
expect(controller.turnStateTracker.set).not.toHaveBeenCalled()
expect(controller.messageTranslatorState.clearTurnOutcome).not.toHaveBeenCalled()
})
})
describe("cancelling a provisioning cloud task", () => {
it("returns to the home view instead of offering Resume Task", async () => {
const phases: string[] = []
const controller = {
turnStateTracker: { set: (phase: string) => phases.push(phase) },
cloud: { cancelPendingStartFor: vi.fn(() => true) },
clearTask: vi.fn(async () => phases.push("cleared")),
taskControl: { cancelTask: vi.fn() },
}
await SdkController.prototype.cancelTask.call(controller as never)
expect(phases).toEqual(["resumable", "cleared"])
expect(controller.taskControl.cancelTask).not.toHaveBeenCalled()
})
it("cancels the pending cloud start when the user starts a new task", async () => {
const cancelPendingStart = vi.fn(() => true)
const controller = {
turnStateTracker: { set: vi.fn() },
cloud: { cancelPendingStart },
taskControl: { clearTask: vi.fn(async () => undefined) },
postStateToWebview: vi.fn(async () => undefined),
}
await SdkController.prototype.clearTask.call(controller as never)
expect(cancelPendingStart).toHaveBeenCalledOnce()
expect(controller.taskControl.clearTask).toHaveBeenCalledOnce()
})
})
describe("getTaskHistory with Cloud Only", () => {
function record(sessionId: string, updatedAt: number, executionTarget?: "cloud") {
return {
sessionId,
prompt: `task ${sessionId}`,
startedAt: new Date(updatedAt).toISOString(),
updatedAt: new Date(updatedAt).toISOString(),
metadata: executionTarget ? { executionTarget } : {},
}
}
it("finds cloud tasks that sit behind a full page of newer local tasks", async () => {
// 60 local tasks are newer than the 2 cloud tasks, so a page of 50 merged
// records holds no cloud task at all.
const history = [
...Array.from({ length: 60 }, (_, i) => record(`local-${i}`, 2_000_000 - i)),
record("ses-a", 1_000_000, "cloud"),
record("ses-b", 999_999, "cloud"),
]
const listHistory = vi.fn(async ({ limit = history.length, offset = 0 }: { limit?: number; offset?: number }) =>
history.slice(offset, offset + limit),
)
const controller = {
task: undefined,
taskHistory: { listHistory },
cloud: { getCurrentTaskInfo: () => undefined },
getWorkspaceRoot: async () => "/workspace",
}
const page = await SdkController.prototype.getTaskHistory.call(
controller as never,
GetTaskHistoryRequest.create({ cloudOnly: true, limit: 50, offset: 0 }),
)
expect(page.tasks.map((task) => task.id)).toEqual(["ses-a", "ses-b"])
expect(page.hasMore).toBe(false)
})
it("pages the filtered cloud tasks, not the merged list", async () => {
const history = [
...Array.from({ length: 5 }, (_, i) => record(`local-${i}`, 2_000_000 - i)),
...Array.from({ length: 3 }, (_, i) => record(`ses-${i}`, 1_000_000 - i, "cloud")),
]
const listHistory = vi.fn(async ({ limit = history.length, offset = 0 }: { limit?: number; offset?: number }) =>
history.slice(offset, offset + limit),
)
const controller = {
task: undefined,
taskHistory: { listHistory },
cloud: { getCurrentTaskInfo: () => undefined },
getWorkspaceRoot: async () => "/workspace",
}
const page = (offset: number) =>
SdkController.prototype.getTaskHistory.call(
controller as never,
GetTaskHistoryRequest.create({ cloudOnly: true, limit: 2, offset }),
)
const first = await page(0)
const second = await page(2)
expect(first.tasks.map((task) => task.id)).toEqual(["ses-0", "ses-1"])
expect(first.hasMore).toBe(true)
expect(second.tasks.map((task) => task.id)).toEqual(["ses-2"])
expect(second.hasMore).toBe(false)
})
})
describe("cloud tasks stay in the cloud", () => {
const stateManager = { getGlobalSettingsKey: (key: string) => (key === "mode" ? "plan" : undefined) }
it("refuses to rebuild a cloud task's conversation as a local session", async () => {
const startNewSession = vi.fn()
const controller = {
task: { taskId: "ses-cloud", messageStateHandler: { getClineMessages: () => [] } },
sessions: { getActiveSession: () => undefined, startNewSession },
stateManager,
}
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 1, text: "edited" }),
).rejects.toThrow("not available for cloud tasks")
expect(startNewSession).not.toHaveBeenCalled()
})
it("renders a displayed cloud task in Act while the saved local mode is Plan", () => {
const mode = (taskId: string | undefined) =>
SdkController.prototype["getDisplayedTaskMode"].call({
task: taskId ? { taskId } : undefined,
stateManager,
} as never)
expect(mode("ses-cloud")).toBe("act")
expect(mode("local-task")).toBe("plan")
expect(mode(undefined)).toBe("plan")
})
})
+265 -31
View File
@@ -26,6 +26,7 @@ import { formatDisplayUserInput, type RemoteConfig, type RemoteConfigBundle } fr
import type { ApiConfiguration } from "@shared/api"
import type { ChatContent } from "@shared/ChatContent"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
import { CLOUD_CREATION_UNCONFIRMED_ERROR_CODE, CLOUD_SESSION_MODE, isCloudSessionId } from "@shared/cloud/cloud-sessions"
import { mentionRegexGlobal } from "@shared/context-mentions"
import type { ClineApiReqInfo, ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import type { HistoryItem } from "@shared/HistoryItem"
@@ -44,6 +45,8 @@ import { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalMan
import { ExtensionRegistryInfo } from "@/registry"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import { UrlContentFetcher } from "@/services/browser/UrlContentFetcher"
import { CloudSessionsService } from "@/services/cloud/CloudSessionsService"
import { isCloudSessionsFeatureAvailable, isCloudSessionsFeatureEnabled } from "@/services/cloud/cloudSessionsFeature"
import { ClineError } from "@/services/error/ClineError"
import { McpHub } from "@/services/mcp/McpHub"
import { telemetryService } from "@/services/telemetry"
@@ -51,6 +54,7 @@ import type { ClineExtensionContext } from "@/shared/cline"
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { resolveDataDirFromEnv } from "@/shared/storage/storage-context"
import { isClineManagedProvider } from "@/shared/utils/cline"
import { arePathsEqual, getDesktopDir } from "@/utils/path"
import { ClineAccountService } from "./account-service"
@@ -74,6 +78,8 @@ import {
getCheckpointRunCountForMessage,
isVisibleCheckpointUserMessage,
} from "./sdk-checkpoints"
import { type CloudTaskInput, SdkCloudSessionCoordinator } from "./sdk-cloud-session-coordinator"
import { SdkCloudTaskTarget } from "./sdk-cloud-task-target"
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
import { SdkDiffEditCoordinator } from "./sdk-diff-edit-coordinator"
import { SdkFollowupCoordinator } from "./sdk-followup-coordinator"
@@ -164,12 +170,17 @@ function historyItemToTaskResponse(item: HistoryItem): TaskResponse {
}
/**
* The prompt a "Sign in to Cline" error offers to retry, and the task that
* shows the error.
* A task start that failed before it had a session, kept so the footer's
* Retry (or a prompt typed into the composer) can run it again: a local start
* refused with "Sign in to Cline", or a cloud start whose sandbox never came
* up. `task` is the task view that shows the error.
*/
interface ClineAuthRetry {
interface StartRetry {
task: TaskProxy
prompt: string
images?: string[]
/** Set for a cloud start; the retry runs on the same repository and branch. */
cloudTarget?: CloudTarget
/**
* The error was shown on a task that already had a conversation, such as
* a failed edit or checkpoint restore, rather than on a new task that
@@ -178,6 +189,12 @@ interface ClineAuthRetry {
hasConversation: boolean
}
/** Where a cloud task runs; the composer submits it and a retry reuses it. */
interface CloudTarget {
repoUrl: string
branch?: string
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
@@ -202,6 +219,9 @@ export class Controller {
private taskStart: SdkTaskStartCoordinator
private compaction: SdkCompactionCoordinator
private sessionEvents: SdkSessionEventCoordinator
private cloud!: SdkCloudSessionCoordinator
readonly cloudSessions: CloudSessionsService
readonly cloudTaskTarget: SdkCloudTaskTarget
private sessionHistory: SdkSessionHistoryLoader
private readonly sdkTelemetry: VscodeSdkTelemetryHandle
private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate()
@@ -244,7 +264,7 @@ export class Controller {
// Private state kept for stub compatibility
private backgroundCommandRunning = false
private backgroundCommandTaskId?: string
private pendingClineAuthRetry?: ClineAuthRetry
private pendingStartRetry?: StartRetry
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
// Timer for periodic remote config fetching (enterprise policy enforcement)
@@ -342,7 +362,7 @@ export class Controller {
// start a new session, so start metadata never goes stale the way
// a mid-task model-only switch does for models below.
() => this.getSessionProviderId() ?? this.getActiveProviderId(),
() => (this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"),
() => this.getDisplayedTaskMode(),
() => this.lastKnownWorkspaceRoot,
// Model backing the active turn — lets error reshaping recognize
// retired cline-free/ models (the error payload itself never names one).
@@ -509,6 +529,13 @@ export class Controller {
// History rendering mints ids from the shared authority so regenerated history ids
// never overlap live-session ids.
getMinter: () => this.messageTranslatorState.getMinter(),
// Resolved lazily: the cloud coordinator is constructed after the task coordinators it depends on.
cloud: {
isCloudSessionId: (id) => this.cloud.isCloudSessionId(id),
list: () => this.cloud.listHistoryRecords(),
find: (id) => this.cloud.findHistoryRecord(id),
delete: (id) => this.cloud.deleteSession(id),
},
})
this.mode = new SdkModeCoordinator({
stateManager: this.stateManager,
@@ -634,7 +661,7 @@ export class Controller {
buildStartSessionInput,
createHistoryItemFromSession,
clearTask: async () => {
this.pendingClineAuthRetry = undefined
this.pendingStartRetry = undefined
await this.taskControl.clearTask()
},
setTask: (task) => {
@@ -651,6 +678,62 @@ export class Controller {
captureProviderApiError: (event) => this.captureProviderFailure(event),
postStateToWebview: () => this.postStateToWebview(),
})
this.cloudSessions = new CloudSessionsService({
getAuthToken: () => this.authService.getAuthToken(),
getActiveOrganizationId: () => this.authService.getActiveOrganizationId(),
})
const getAccountScope = () => {
const userId = this.authService.getInfo().user?.uid
return userId ? `${userId}:${this.authService.getActiveOrganizationId() ?? ""}` : undefined
}
this.cloudTaskTarget = new SdkCloudTaskTarget({
cloudSessions: this.cloudSessions,
stateManager: this.stateManager,
getAccountScope,
getWorkspaceRoot: () => this.lastKnownWorkspaceRoot,
postStateToWebview: () => this.postStateToWebview(),
})
this.cloud = new SdkCloudSessionCoordinator({
cloudSessions: this.cloudSessions,
stateManager: this.stateManager,
sessions: this.sessions,
sessionConfigBuilder: this.sessionConfigBuilder,
messages: this.messages,
getMinter: () => this.messageTranslatorState.getMinter(),
getTask: () => this.task,
setTask: (task) => {
this.task = task
},
onAskResponse: (text, images, files) => this.askResponse(text, images, files),
onCancelTask: () => this.cancelTask(),
clearTask: async () => {
this.pendingStartRetry = undefined
return this.taskControl.clearTask()
},
onStartFailed: (task, input) => this.offerCloudStartRetry(task, input),
claimTaskViewGeneration: () => this.taskControl.claimTaskViewGeneration(),
requestToolApproval: (request) => this.interactions.handleRequestToolApproval(request),
getAuthToken: () => this.authService.getAuthToken(),
isSignedIn: () => !!this.authService.getInfo().user?.uid,
isEnabled: () => isCloudSessionsFeatureEnabled(),
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
clearTurnOutcome: () => this.messageTranslatorState.clearTurnOutcome(),
postStateToWebview: () => this.postStateToWebview(),
showChatView: async () => {
const { sendChatButtonClickedEvent } = await import("@core/controller/ui/subscribeToChatButtonClicked")
await sendChatButtonClickedEvent()
},
invalidateHistoryCache: () => this.taskHistory.invalidateCache(),
resolveContextMentions: (text) => this.resolveContextMentions(text),
telemetry: this.sdkTelemetry.telemetry,
pendingStartsDir: path.join(resolveDataDirFromEnv(), "cloud-pending-starts"),
getAccountScope,
})
// Every account change, whichever path requests it, tears down the
// previous account's cloud task and connections first and holds cloud
// reads until the change has settled.
this.accountService.onAccountChange((change) => this.cloud.reset(change))
this.compaction = new SdkCompactionCoordinator({
stateManager: this.stateManager,
sessions: this.sessions,
@@ -958,6 +1041,7 @@ export class Controller {
this.mcpHub?.clearToolListChangeCallback()
await this.diffEdits.discardAllPreviews("controller dispose")
await this.clearTask()
await this.cloud.dispose()
await this.sessions.dispose("SdkController.dispose")
await this.taskHistory.dispose()
this.mcpHub?.dispose?.()
@@ -1298,6 +1382,17 @@ export class Controller {
})
}
/** A cloud start failed before it had a session; the footer's Retry runs the same input again. */
private offerCloudStartRetry(task: TaskProxy, input: CloudTaskInput): void {
this.pendingStartRetry = {
task,
prompt: input.prompt,
images: input.images,
cloudTarget: { repoUrl: input.repoUrl, branch: input.branch },
hasConversation: false,
}
}
/**
* Emit a proper auth error for the 'cline' provider when the user is not
* logged in. The message sequence drives ErrorRow to render the
@@ -1318,7 +1413,7 @@ export class Controller {
() => this.cancelTask(),
)
}
this.pendingClineAuthRetry = task === undefined ? undefined : { task: this.task, prompt: task, hasConversation }
this.pendingStartRetry = task === undefined ? undefined : { task: this.task, prompt: task, hasConversation }
const clineError = new ClineError(
{ message: CLINE_ACCOUNT_AUTH_ERROR_MESSAGE, status: 401 },
@@ -1440,7 +1535,23 @@ export class Controller {
files?: string[],
historyItem?: HistoryItem,
taskSettings?: Partial<Settings>,
cloudTarget?: CloudTarget,
): Promise<string | undefined> {
if (cloudTarget) {
// Register the cloud start before the first await so a Cancel that
// arrives while remote config is still loading finds it and stops it.
// The coordinator moves the turn phase to streaming only once the
// start is still current and its task view is installed.
const startCloudTask = this.cloud.beginCloudTask({
prompt: prompt ?? "",
images,
repoUrl: cloudTarget.repoUrl,
branch: cloudTarget.branch,
})
await this.waitForInitialRemoteConfig()
this.pendingStartRetry = undefined
return startCloudTask()
}
await this.waitForInitialRemoteConfig()
// A new task is starting — the agent is about to stream.
this.turnStateTracker.set("streaming")
@@ -1449,6 +1560,18 @@ export class Controller {
return this.taskStart.initTask(prompt, images, files, historyItem, taskSettings)
}
/**
* Plan/act mode governing the displayed task. A cloud task always runs in
* Act, so its completion rows render as Act output even while the user's
* saved local mode is Plan.
*/
private getDisplayedTaskMode(): Mode {
if (this.task && isCloudSessionId(this.task.taskId)) {
return CLOUD_SESSION_MODE
}
return this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
}
async reinitExistingTaskFromId(taskId: string): Promise<void> {
await this.waitForInitialRemoteConfig()
this.turnStateTracker.set("streaming")
@@ -1457,10 +1580,15 @@ export class Controller {
}
async cancelTask(): Promise<void> {
// Fence first: mark resumable before aborting so any straggler events from the aborted
// turn land on the wrong side of the UI mode. (Full fence-before-abort epoch bump lands
// in S6; this sets the authoritative phase now.)
// Fence first: mark resumable and invalidate provisioning before any abort await,
// so straggler work cannot restore the cancelled turn's streaming state.
this.turnStateTracker.set("resumable")
if (this.cloud.cancelPendingStartFor(this.task)) {
// The sandbox never started and its record is being deleted, so there is
// nothing to resume: return to the home view instead of offering Resume Task.
await this.clearTask()
return
}
await this.taskControl.cancelTask()
}
@@ -1516,9 +1644,12 @@ export class Controller {
}
async clearTask(): Promise<void> {
this.pendingClineAuthRetry = undefined
this.pendingStartRetry = undefined
// No active task — UI returns to idle (input enabled, no buttons/thinking).
this.turnStateTracker.set("idle")
// A cloud task still provisioning has no SDK session for taskControl to end;
// stop it here so it cannot re-install its view after the user left.
this.cloud.cancelPendingStart()
await this.taskControl.clearTask()
await this.postStateToWebview()
}
@@ -1537,25 +1668,76 @@ export class Controller {
* return immediately so the webview stays responsive.
*/
async askResponse(prompt?: string, images?: string[], files?: string[]): Promise<void> {
// The sign-in retry answers only the next response to the task that
// showed the error; a task opened since then does not inherit it.
const retry = this.pendingClineAuthRetry
this.pendingClineAuthRetry = undefined
// A start retry answers only the next response to the task that showed
// the error; a task opened since then does not inherit it.
const retry = this.pendingStartRetry
this.pendingStartRetry = undefined
if (retry && retry.task === this.task) {
const askResponse = this.task.taskState.askResponse
if (askResponse === "yesButtonClicked") {
await this.initTask(retry.prompt, images, files)
// A Retry click carries no attachments of its own (protobuf decodes the
// omitted field as an empty list), so the failed start's attachments stand.
await this.initTask(
retry.prompt,
images?.length ? images : retry.images,
files,
undefined,
undefined,
retry.cloudTarget,
)
return
}
// A task that failed before it had a session has nothing to
// continue, so a prompt typed in the composer starts it again. With
// attachments but no text, the original prompt is kept.
if (askResponse === "messageResponse" && !retry.hasConversation) {
await this.initTask(prompt?.trim() ? prompt : retry.prompt, images, files)
await this.initTask(
prompt?.trim() ? prompt : retry.prompt,
images,
files,
undefined,
undefined,
retry.cloudTarget,
)
return
}
}
if (this.task && this.cloud.isCloudSessionId(this.task.taskId) && !this.sessions.getActiveSession()) {
// No sandbox to send to: still provisioning (only reachable through the
// extension API, the composer is disabled), expired, or unconfirmed. Refusing the
// response is itself a turn outcome: the footer holds its Retry claim
// until the turn phase advances, so set the phase even though it does
// not change.
const provisioning = this.cloud.getCurrentTaskInfo()?.status === "provisioning"
const anchorTs = this.turnStateTracker.get().anchorTs
const anchor = this.task.messageStateHandler.getClineMessages().find((message) => message.ts === anchorTs)
const anchoredError =
anchor?.type === "ask" && anchor.ask === "api_req_failed" ? ClineError.parse(anchor.text) : undefined
const guidance =
anchoredError?._error.code === CLOUD_CREATION_UNCONFIRMED_ERROR_CODE ? anchoredError.message : undefined
this.messages.appendAndEmit(
[
{
ts: Math.max(Date.now(), (anchorTs ?? 0) + 1),
type: "say",
say: "error",
text:
guidance ??
(provisioning
? "The cloud sandbox is still starting. Send your message again once it is running."
: "This cloud session is no longer running. Start a new cloud task to continue."),
partial: false,
},
],
{ type: "status", payload: { sessionId: this.task.taskId, status: "error" } },
)
if (guidance) this.turnStateTracker.set("error", anchorTs)
else this.turnStateTracker.set("error")
await this.postStateToWebview()
return
}
const turnStateBefore = this.turnStateTracker.get()
// Answering an ask / continuing after completion / resuming a cancelled task all kick off a
@@ -1593,6 +1775,12 @@ export class Controller {
if (!currentTask) {
throw new Error("No active task to edit")
}
// The rebuilt conversation below is a local session on this machine's
// workspace. A cloud task's transcript lives in its sandbox, so rebuilding
// it here would silently move the task out of the cloud.
if (isCloudSessionId(currentTask.taskId)) {
throw new Error("Editing an earlier message is not available for cloud tasks yet.")
}
const clineMessages = currentTask.messageStateHandler.getClineMessages()
const targetIndex = clineMessages.findIndex((message) => message.ts === input.messageTs)
@@ -1985,7 +2173,9 @@ export class Controller {
* replace it.
*/
async showTaskWithId(taskId: string): Promise<TaskResponse> {
const historyItem = await this.taskControl.showTaskWithId(taskId)
const historyItem = this.cloud.isCloudSessionId(taskId)
? await this.cloud.openCloudTask(taskId)
: await this.taskControl.showTaskWithId(taskId)
if (!historyItem) {
throw new Error(`Task not found in history: ${taskId}`)
}
@@ -2015,7 +2205,10 @@ export class Controller {
// (which can hold enterprise secrets) is actually deleted on sign-out.
const organizationId = this.authService.getActiveOrganizationId() ?? undefined
await this.taskControl.cancelClineTaskOnSignOut(isClineManagedProvider(sessionProviderId))
await this.authService.handleDeauth(LogoutReason.USER_INITIATED)
if (this.task && this.cloud.isCloudSessionId(this.task.taskId)) {
await this.clearTask()
}
await this.cloud.reset(() => this.authService.handleDeauth(LogoutReason.USER_INITIATED))
// Invalidate BEFORE clearing: a refresh that already fetched under the
// signed-in identity must not republish the policy (and re-create the
// secret-bearing caches) after the clear. The clear itself runs under the
@@ -2079,15 +2272,18 @@ export class Controller {
}
async getTaskHistory(request: GetTaskHistoryRequest): Promise<TaskHistoryArray> {
const { favoritesOnly, currentWorkspaceOnly, searchQuery, sortBy } = request
const { favoritesOnly, currentWorkspaceOnly, searchQuery, sortBy, cloudOnly } = request
const limit = request.limit > 0 ? Math.min(request.limit, 100) : 50
const offset = request.offset > 0 ? request.offset : 0
const workspacePath = currentWorkspaceOnly ? await this.getWorkspaceRoot() : undefined
const sessionHistory = await this.taskHistory.listHistory({
hydrate: false,
limit: limit + 1,
offset,
})
// Cloud tasks are a small minority of the merged list, so page them after
// filtering: a page cut from the merged list first can hold none of them
// while cloud tasks exist further down. Other filters keep the cheaper
// pre-filter pagination the local-only history has always used.
const paginateAfterFilter = !!cloudOnly
const sessionHistory = await this.taskHistory.listHistory(
paginateAfterFilter ? { hydrate: false } : { hydrate: false, limit: limit + 1, offset },
)
let filteredTasks = sessionHistory.filter((item) => {
const ts = dateStringToTimestamp(item.updatedAt ?? item.endedAt ?? item.startedAt)
@@ -2103,6 +2299,10 @@ export class Controller {
return false
}
if (cloudOnly && metadataString(item.metadata, "executionTarget") !== "cloud") {
return false
}
if (currentWorkspaceOnly && workspacePath) {
const sessionWorkspacePath = item.cwd ?? item.workspaceRoot
if (!sessionWorkspacePath || !arePathsEqual(sessionWorkspacePath, workspacePath)) {
@@ -2149,7 +2349,10 @@ export class Controller {
}
})
const hasMore = sessionHistory.length > limit
if (paginateAfterFilter) {
filteredTasks = filteredTasks.slice(offset)
}
const hasMore = paginateAfterFilter ? filteredTasks.length > limit : sessionHistory.length > limit
const tasks = filteredTasks.slice(0, limit).map((item) => {
const metadata = item.metadata
return {
@@ -2168,6 +2371,11 @@ export class Controller {
isLegacy:
metadataBoolean(metadata, "legacyTask") === true ||
metadataBoolean(metadata, "migratedFromLegacyTask") === true,
executionTarget: metadataString(metadata, "executionTarget") ?? "",
cloudStatus: metadataString(metadata, "cloudStatus") ?? "",
cloudRepoUrl: metadataString(metadata, "repoUrl") ?? "",
cloudBranch: metadataString(metadata, "branch") ?? "",
cloudUsageAvailable: metadataBoolean(metadata, "usageAvailable") ?? false,
}
})
@@ -2176,7 +2384,8 @@ export class Controller {
.getClineMessages()
.find((message) => message.type === "say" && message.say === "task" && message.text)
const matchesSearch = !searchQuery || taskMessage?.text?.toLowerCase().includes(searchQuery.toLowerCase())
if (taskMessage?.text && matchesSearch) {
const currentCloudTask = this.cloud.getCurrentTaskInfo()
if (taskMessage?.text && matchesSearch && (!cloudOnly || currentCloudTask)) {
tasks.unshift({
id: this.task.taskId,
task: formatDisplayUserInput(taskMessage.text),
@@ -2191,6 +2400,11 @@ export class Controller {
modelId: this.task.api?.getModel?.().id ?? "",
apiProvider: "",
isLegacy: false,
executionTarget: currentCloudTask ? "cloud" : "",
cloudStatus: currentCloudTask?.status ?? "",
cloudRepoUrl: currentCloudTask?.repoUrl ?? "",
cloudBranch: currentCloudTask?.branch ?? "",
cloudUsageAvailable: false,
})
}
}
@@ -2198,6 +2412,14 @@ export class Controller {
return TaskHistoryArray.create({ tasks: tasks.slice(0, limit), hasMore })
}
resolveCloudSessionStatuses(sessionIds: Iterable<string>) {
return this.cloud.resolveStatuses(sessionIds)
}
invalidateTaskHistoryCache(): void {
this.taskHistory.invalidateCache()
}
async exportTaskWithId(id: string): Promise<void> {
const taskDirPath = await this.taskHistory.getTaskDirPath(id)
if (!taskDirPath) {
@@ -2218,7 +2440,6 @@ export class Controller {
await this.clearTask()
const taskHistory = await this.taskHistory.listHistory({ hydrate: false })
const totalTasks = taskHistory.length
const userChoice = (
await HostProvider.window.showMessage(
@@ -2269,9 +2490,7 @@ export class Controller {
const tasksDeleted = await this.taskHistory.deleteAllTaskHistory()
await this.postStateToWebview()
return DeleteAllTaskHistoryCount.create({
tasksDeleted: tasksDeleted || totalTasks,
})
return DeleteAllTaskHistoryCount.create({ tasksDeleted })
}
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
@@ -2387,6 +2606,7 @@ export class Controller {
.getClineMessages()
.find((message) => message.type === "say" && message.say === "task" && message.text)
if (taskMessage?.text) {
const currentCloudTask = this.cloud.getCurrentTaskInfo()
mergedTaskHistoryById.set(snapshotTask.taskId, {
id: snapshotTask.taskId,
ts: taskMessage.ts || Date.now(),
@@ -2398,6 +2618,14 @@ export class Controller {
totalCost: 0,
modelId: snapshotTask.api?.getModel?.().id,
cwdOnTaskInitialization: await this.getWorkspaceRoot(),
...(currentCloudTask
? {
executionTarget: "cloud" as const,
cloudStatus: currentCloudTask.status,
cloudRepoUrl: currentCloudTask.repoUrl,
cloudBranch: currentCloudTask.branch,
}
: {}),
})
}
}
@@ -2436,6 +2664,7 @@ export class Controller {
}
throw new Error("Task changed while building webview state")
}
const currentCloudTask = this.cloud.getCurrentTaskInfo()
return {
...state,
currentTaskItem: snapshotTask?.taskId
@@ -2444,6 +2673,11 @@ export class Controller {
taskHistory: processedTaskHistory,
turnState: this.turnStateTracker.get(),
queuedPrompts,
cloudSessionsAvailable: isCloudSessionsFeatureAvailable(),
cloudSessionsEnabled: isCloudSessionsFeatureEnabled(),
cloudTaskTarget: isCloudSessionsFeatureEnabled() ? this.cloudTaskTarget.view() : undefined,
currentCloudTask,
cloudModelId: isCloudSessionsFeatureEnabled() || currentCloudTask ? this.cloud.getCloudModelId() : undefined,
stateVersion: minter.nextSeq(),
epoch: minter.epoch,
}
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { getUserOrganizations } from "@/core/controller/account/getUserOrganizations"
import { ClineAccountService } from "./account-service"
const mocks = vi.hoisted(() => ({ request: vi.fn(), restore: vi.fn(), activeOrganizationId: "org-old" as string | undefined }))
vi.mock("axios", () => ({ default: { request: mocks.request } }))
vi.mock("./auth-service", () => ({
AuthService: {
getInstance: () => ({
getAuthToken: async () => "fixture-token",
getActiveOrganizationId: () => mocks.activeOrganizationId,
restoreRefreshTokenAndRetrieveAuthInfo: mocks.restore,
}),
},
}))
function deferred<T = void>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
vi.mock("@/config", () => ({ ClineEnv: { config: () => ({ apiBaseUrl: "http://127.0.0.1:1" }) } }))
vi.mock("@/services/EnvUtils", () => ({ buildBasicClineHeaders: async () => ({}) }))
vi.mock("@/shared/net", () => ({ getAxiosSettings: () => ({}) }))
describe("switchAccount owns the account boundary", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.restore.mockResolvedValue(undefined)
mocks.activeOrganizationId = "org-old"
})
it("runs the account request inside the installed boundary", async () => {
const order: string[] = []
mocks.request.mockImplementation(async () => {
order.push("request")
return { status: 200, data: { success: true, data: "" } }
})
const service = new ClineAccountService()
service.onAccountChange(async (change) => {
order.push("teardown")
await change()
order.push("reopen")
})
await service.switchAccount("org-new")
expect(order).toEqual(["teardown", "request", "reopen"])
})
it("serialises overlapping switches and re-checks the target once the pending one settles", async () => {
// The first switch is in flight; the second must not observe the cached
// account and decide it has nothing to do.
const firstRequest = deferred<{ status: number; data: { success: boolean; data: string } }>()
mocks.request
.mockImplementationOnce(() => firstRequest.promise)
.mockImplementation(async () => ({ status: 200, data: { success: true, data: "" } }))
mocks.restore.mockImplementation(async () => {
mocks.activeOrganizationId = "org-new"
})
const service = new ClineAccountService()
const first = service.switchAccount("org-new")
const second = service.switchAccount(undefined)
await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(1))
firstRequest.resolve({ status: 200, data: { success: true, data: "" } })
await Promise.all([first, second])
expect(mocks.request).toHaveBeenCalledTimes(2)
expect(mocks.request).toHaveBeenLastCalledWith(expect.objectContaining({ data: { organizationId: null } }))
})
it("restores the server account without tearing down cloud state when the cached account already matches", async () => {
// Another client on the same login may have moved the server's active
// account; the request must still be sent, but the cloud state here is
// scoped by the cached account, which is not changing.
mocks.request.mockResolvedValue({ status: 200, data: { success: true, data: "" } })
const boundary = vi.fn(async (change: () => Promise<void>) => change())
const service = new ClineAccountService()
service.onAccountChange(boundary)
await service.switchAccount("org-old")
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ data: { organizationId: "org-old" } }))
expect(boundary).not.toHaveBeenCalled()
})
})
describe("account confirmation", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.restore.mockResolvedValue(undefined)
mocks.activeOrganizationId = "org-old"
})
it("propagates an unavailable profile through the real service and RPC handler", async () => {
mocks.request.mockRejectedValue(new Error("offline"))
const controller = { accountService: new ClineAccountService() }
await expect(getUserOrganizations(controller as never, {})).rejects.toThrow("Could not confirm")
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ timeout: 10_000, method: "GET" }))
})
it("waits for the server's answer to the mutation and reconciles auth after a transport failure", async () => {
mocks.request.mockRejectedValue(new Error("offline"))
await expect(new ClineAccountService().switchAccount("org-new")).rejects.toThrow("offline")
expect(mocks.request).toHaveBeenCalledWith(
expect.objectContaining({ timeout: 0, method: "PUT", data: { organizationId: "org-new" } }),
)
expect(mocks.restore).toHaveBeenCalledOnce()
})
})
+59 -18
View File
@@ -23,11 +23,24 @@ import { AuthService } from "./auth-service"
export class ClineAccountService {
private static instance: ClineAccountService
private _authService: AuthService
private accountChangeBoundary: ((change: () => Promise<void>) => Promise<void>) | undefined
private pendingSwitch: Promise<void> | undefined
constructor() {
this._authService = AuthService.getInstance()
}
/**
* Installs the boundary every account change runs inside. The controller
* uses it to tear down the previous account's cloud task and connections
* before `change` sends the request, and to hold cloud reads until it has
* settled. Owned here so that every path that changes the account (the
* Account view, ClinePass selection, remote-config discovery) runs it.
*/
onAccountChange(boundary: (change: () => Promise<void>) => Promise<void>): void {
this.accountChangeBoundary = boundary
}
/**
* Returns the singleton instance of ClineAccountService
*/
@@ -61,6 +74,7 @@ export class ClineAccountService {
throw new Error("No Cline account auth token found")
}
const requestConfig: AxiosRequestConfig = {
timeout: 10_000,
...config,
headers: {
Authorization: `Bearer ${clineAccountAuthToken}`,
@@ -256,29 +270,56 @@ export class ClineAccountService {
}
/**
* Switches the active account to the specified organization or personal account.
* Switches the active account to the specified organization, or to the
* personal account when none is given. This is the only path that changes
* the account. Switches are serialised: one requested while another is in
* flight waits for it, then decides against the account as it is then, so
* a caller that read a stale account cannot tear down cloud state it does
* not need to. The account changes when this resolves; when the cached
* account changes, its cloud state is gone before the request is sent.
*/
async switchAccount(organizationId?: string): Promise<void> {
try {
await this.authenticatedRequest<string>(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: {
organizationId: organizationId || null,
},
})
const activeOrgId = this._authService.getActiveOrganizationId()
if (activeOrgId !== organizationId) {
switchAccount(organizationId?: string): Promise<void> {
const previous = this.pendingSwitch ?? Promise.resolve()
const switching = previous.then(
() => this.switchAccountNow(organizationId),
() => this.switchAccountNow(organizationId),
)
this.pendingSwitch = switching
return switching.finally(() => {
if (this.pendingSwitch === switching) this.pendingSwitch = undefined
})
}
private async switchAccountNow(organizationId: string | undefined): Promise<void> {
// The server's active account is shared by every client on this login, so
// another client may have moved it; the request is always sent to restore
// it. The cloud state held here was scoped by the cached account, so it
// is torn down only when that cached account changes.
const changesCachedAccount = (this._authService.getActiveOrganizationId() ?? undefined) !== (organizationId || undefined)
const change = async () => {
try {
await this.authenticatedRequest<string>(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, {
method: "PUT",
// No client deadline: the server can still commit a PUT the client gave
// up on, and only its answer triggers the auth refresh below that brings
// this window onto the new account. setUserOrganization bounds the UI wait.
timeout: 0,
headers: {
"Content-Type": "application/json",
},
data: {
organizationId: organizationId || null,
},
})
// Force a refresh of the auth info after switching
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
} catch (error) {
Logger.error("Error switching account:", error)
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
throw error
}
} catch (error) {
Logger.error("Error switching account:", error)
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
throw error
}
await (changesCachedAccount && this.accountChangeBoundary ? this.accountChangeBoundary(change) : change())
}
private getCurrentUser() {
@@ -6,6 +6,7 @@ import * as LlmsModels from "@cline/llms"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import * as netModule from "@/shared/net"
import {
buildResumeSessionInput,
buildSessionConfig,
@@ -17,6 +18,7 @@ import {
normalizeSdkBaseUrl,
resolveApiKey,
resolveAzureProviderConfig,
resolveProviderFetch,
updateHistoryItem,
} from "./cline-session-factory"
import { parseProviderId } from "./model-catalog/provider-id"
@@ -269,6 +271,13 @@ describe("normalizeSdkBaseUrl", () => {
expect(normalizeSdkBaseUrl("openai", " https://example.com/custom ")).toBe("https://example.com/custom")
})
it("does not append a default path to OpenAI Compatible base URLs", () => {
expect(normalizeSdkBaseUrl("openai", "https://example.com")).toBe("https://example.com")
expect(normalizeSdkBaseUrl("openai", "http://localhost:8000/")).toBe("http://localhost:8000/")
expect(normalizeSdkBaseUrl("openai-compatible", "https://example.com")).toBe("https://example.com")
expect(normalizeSdkBaseUrl("openai", "https://example.com/v1")).toBe("https://example.com/v1")
})
it("inherits the AskSage default /server path when the custom URL has no path", () => {
expect(normalizeSdkBaseUrl("asksage", "https://asksage.internal.example")).toBe("https://asksage.internal.example/server")
expect(normalizeSdkBaseUrl("asksage", "https://asksage.internal.example/custom")).toBe(
@@ -277,6 +286,54 @@ describe("normalizeSdkBaseUrl", () => {
})
})
// ---------------------------------------------------------------------------
// resolveProviderFetch
// ---------------------------------------------------------------------------
describe("resolveProviderFetch", () => {
it("gives local model servers the idle-timeout-free fetch", () => {
for (const providerId of ["lmstudio", "ollama", "openai-compatible", "litellm"]) {
expect(resolveProviderFetch(providerId, undefined)).toBe(netModule.fetchWithoutIdleTimeouts)
}
for (const baseUrl of [
"http://localhost:1234/v1",
"http://127.0.0.1:4000",
"http://0.0.0.0:11434",
"http://[::1]:8000/v1",
"http://[::ffff:127.0.0.1]:1234/v1",
"http://[::ffff:192.168.1.20]:8000/v1",
"http://192.168.1.20:8000/v1",
"http://10.0.0.5:11434",
"http://172.20.0.3:8000",
"http://100.101.102.103:8000",
"http://[fd12::1]:8000",
"http://gpu-box:8000/v1",
"http://host.docker.internal:1234/v1",
"http://studio.local:1234/v1",
]) {
expect(resolveProviderFetch("openai-compatible", baseUrl)).toBe(netModule.fetchWithoutIdleTimeouts)
}
})
it("keeps the proxy-aware fetch for remote gateways", () => {
for (const baseUrl of [
"https://litellm.corp.example.com/v1",
"https://8.8.8.8/v1",
"http://172.32.0.1:8000",
"https://[2001:db8::1]/v1",
"not a url",
]) {
expect(resolveProviderFetch("litellm", baseUrl)).toBe(netModule.fetch)
}
})
it("keeps the proxy-aware fetch for everything else", () => {
for (const providerId of ["anthropic", "cline", "openrouter", "bedrock"]) {
expect(resolveProviderFetch(providerId, undefined)).toBe(netModule.fetch)
}
})
})
// ---------------------------------------------------------------------------
// normalizeProviderReasoningSettings
// ---------------------------------------------------------------------------
@@ -717,6 +774,29 @@ describe("buildSessionConfig", () => {
})
})
// Regression test for cline/cline#13464: undici's default 5-minute
// idle-body timeout (UND_ERR_BODY_TIMEOUT) killed LM Studio requests
// mid-stream even while the local server was actively generating tokens.
it("gives LM Studio the fetch that ignores undici's idle timeouts", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "lmstudio",
actModeApiModelId: "qwen3-coder-30b",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect((config.providerConfig as { fetch?: typeof fetch }).fetch).toBe(netModule.fetchWithoutIdleTimeouts)
})
it("keeps the idle timeouts for hosted providers", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "moonshot",
actModeApiModelId: "kimi-k3",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect((config.providerConfig as { fetch?: typeof fetch }).fetch).toBe(netModule.fetch)
})
it("inherits the base provider's legacy apiLine for coding variants", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "zai-coding-plan",
@@ -1470,6 +1550,39 @@ describe("buildSessionConfig", () => {
expect(planConfig.systemPrompt).not.toContain("switch_to_act_mode")
expect(planConfig.systemPrompt).toContain("Plan/Act toggle")
})
it("builds a coherent remote runtime model and platform snapshot", async () => {
const config = await buildSessionConfig({
cwd: "/workspace",
workspaceRoot: "/workspace",
mode: "act",
runtime: {
modelSelection: { providerId: "cline", modelId: "fixture-cloud-model" },
platform: "linux",
},
})
expect(config).toMatchObject({
providerId: "cline",
modelId: "fixture-cloud-model",
cwd: "/workspace",
workspaceRoot: "/workspace",
mode: "act",
providerConfig: {
providerId: "cline",
modelId: "fixture-cloud-model",
},
extensionContext: {
workspace: {
rootPath: "/workspace",
cwd: "/workspace",
platform: "linux",
mode: "act",
},
},
})
expect(config.systemPrompt).toContain("# Plan / Act Modes")
})
})
// ---------------------------------------------------------------------------
+72 -7
View File
@@ -8,6 +8,7 @@
//
// The factory does NOT handle UI concerns — that's the SdkController's job.
import { BlockList, isIP } from "node:net"
import {
buildWorkspaceMetadata,
type ClineCoreStartInput,
@@ -41,7 +42,7 @@ import { StateManager } from "@/core/storage/StateManager"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { getDistinctId } from "@/services/logging/distinctId"
import { fetch } from "@/shared/net"
import { fetch, fetchWithoutIdleTimeouts } from "@/shared/net"
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
import { buildAgentHooks } from "./hooks-adapter"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
@@ -76,6 +77,11 @@ export interface SessionConfigInput {
workspaceRoot?: string
/** Current mode (act/plan) */
mode?: Mode
/** Runtime identity when execution happens outside the extension host. */
runtime?: {
modelSelection: { providerId: string; modelId: string }
platform: string
}
}
/** Active session state tracked by the factory */
@@ -604,7 +610,14 @@ export function normalizeSdkBaseUrl(providerId: string, baseUrl: unknown): strin
return undefined
}
const providerDefaultBaseUrl = MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.baseUrl
const sdkProviderId = toSdkProviderId(providerId)
// OpenAI Compatible endpoints are user-supplied, so use the URL as entered
// rather than inheriting the OpenAI default `/v1` path.
if (sdkProviderId === "openai-compatible") {
return trimmed
}
const providerDefaultBaseUrl = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]?.provider.baseUrl
if (!providerDefaultBaseUrl) {
return trimmed
}
@@ -732,6 +745,56 @@ export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: s
}
}
/**
* Providers whose base URL commonly points at a local model server. Those
* can spend longer than undici's 300s idle timeouts producing the first
* token, so they get `fetchWithoutIdleTimeouts` when the base URL is on the
* local network. Remote gateways (and every other provider) keep the
* proxy-aware fetch: on VS Code it honours `http.proxy`, which
* `fetchWithoutIdleTimeouts` cannot, and it keeps the dead-connection cap.
*/
const LOCAL_SERVER_PROVIDERS = new Set(["lmstudio", "ollama", "openai-compatible", "litellm"])
const LOCAL_NETWORK_ADDRESSES = new BlockList()
for (const [address, prefix] of [
["127.0.0.0", 8],
["0.0.0.0", 32],
["10.0.0.0", 8],
["172.16.0.0", 12],
["192.168.0.0", 16],
["169.254.0.0", 16],
["100.64.0.0", 10],
] as const) {
LOCAL_NETWORK_ADDRESSES.addSubnet(address, prefix, "ipv4")
}
LOCAL_NETWORK_ADDRESSES.addAddress("::1", "ipv6")
LOCAL_NETWORK_ADDRESSES.addSubnet("fc00::", 7, "ipv6")
LOCAL_NETWORK_ADDRESSES.addSubnet("fe80::", 10, "ipv6")
// An unset base URL means the provider's localhost default.
function isLocalNetworkUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) {
return true
}
let host: string
try {
host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "").toLowerCase()
} catch {
return false
}
// BlockList matches IPv4-mapped IPv6 (`[::ffff:127.0.0.1]`) against the
// IPv4 rules, so no unwrapping is needed.
const family = isIP(host)
if (family) {
return LOCAL_NETWORK_ADDRESSES.check(host, family === 6 ? "ipv6" : "ipv4")
}
return !host.includes(".") || /\.(localhost|local|internal)$/.test(host)
}
export function resolveProviderFetch(sdkProviderId: string, baseUrl: string | undefined): typeof globalThis.fetch {
return LOCAL_SERVER_PROVIDERS.has(sdkProviderId) && isLocalNetworkUrl(baseUrl) ? fetchWithoutIdleTimeouts : fetch
}
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
anthropic: "anthropicBaseUrl",
@@ -880,7 +943,9 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// builds or other hosts may carry SDK catalog spellings (e.g.
// `openai-compatible`); fold them back to the legacy spelling the
// provider-keyed maps below are keyed by.
const modeProvider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
const modeProvider =
input.runtime?.modelSelection.providerId ??
(mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider)
providerId = modeProvider ? toLegacyApiProvider(modeProvider) : modeProvider
if (providerId) {
@@ -888,7 +953,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
apiKey = resolveApiKey(providerId, apiConfig)
// Resolve model ID
modelId = resolveModelId(providerId, mode, apiConfig)
modelId = input.runtime?.modelSelection.modelId ?? resolveModelId(providerId, mode, apiConfig)
// Resolve base URL
baseUrl = resolveBaseUrl(providerId, apiConfig)
@@ -1013,7 +1078,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
metadata: workspaceMetadata,
mode: mode === "plan" ? "plan" : "act",
providerId,
platform: process.platform,
platform: input.runtime?.platform ?? process.platform,
// The extension never exposes switch_to_act_mode (unlike the CLI):
// matching the legacy extension, the user must flip the Plan/Act
// toggle themselves, so the plan contract must not tell the model to
@@ -1097,7 +1162,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// straight from providerConfig — notably the compaction summarizer, which
// otherwise falls back to a small default output cap (CLINE-2911).
...(maxTokensPerTurn !== undefined ? { maxOutputTokens: maxTokensPerTurn } : {}),
fetch,
fetch: resolveProviderFetch(sdkProviderId, baseUrl),
}
const config: CoreSessionConfig = {
@@ -1142,7 +1207,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
cwd,
workspaceName: resolveWorkspaceName(workspaceRoot),
ide: "VS Code",
platform: process.platform,
platform: input.runtime?.platform ?? process.platform,
mode: mode === "plan" ? "plan" : "act",
},
logger: sdkLogger,
@@ -0,0 +1,70 @@
import { randomUUID } from "node:crypto"
import { mkdtempSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"
import * as os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { PendingStartJournal, type PendingStartRecord } from "./cloud-pending-starts"
const EXITED_PID = 2_147_483_646
let dir: string
beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), "cloud-pending-starts-"))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
function writeJournal(pid: number, records: PendingStartRecord[], ageMs = 0): string {
const file = path.join(dir, `${pid}-${randomUUID()}.json`)
writeFileSync(file, JSON.stringify(records))
const time = new Date(Date.now() - ageMs)
utimesSync(file, time, time)
return file
}
describe("PendingStartJournal", () => {
it("keeps its records in a file of its own until they are removed", () => {
const journal = new PendingStartJournal(dir)
journal.add({ sessionId: "ses-a", account: "user:" })
journal.add({ sessionId: "ses-b", account: "user:" })
journal.remove("ses-a")
const [name] = readdirSync(dir)
expect(name).toMatch(new RegExp(`^${process.pid}-`))
expect(JSON.parse(readFileSync(path.join(dir, name), "utf8"))).toEqual([{ sessionId: "ses-b", account: "user:" }])
journal.remove("ses-b")
expect(readdirSync(dir)).toEqual([])
})
it("treats an earlier lifetime of this pid as abandoned instead of skipping or overwriting it", () => {
const earlier = writeJournal(process.pid, [{ sessionId: "ses-earlier" }])
const journal = new PendingStartJournal(dir)
journal.add({ sessionId: "ses-current" })
const { journals, liveOwners } = journal.abandoned()
expect(liveOwners).toBe(false)
expect(journals.map((abandoned) => abandoned.records)).toEqual([[{ sessionId: "ses-earlier" }]])
expect(JSON.parse(readFileSync(earlier, "utf8"))).toEqual([{ sessionId: "ses-earlier" }])
expect(readdirSync(dir)).toHaveLength(2)
})
it("never takes the journal of a running process, however long ago it was written", () => {
writeJournal(process.ppid, [{ sessionId: "ses-live" }], 24 * 60 * 60 * 1000)
expect(new PendingStartJournal(dir).abandoned()).toMatchObject({ journals: [], liveOwners: true })
})
it("rewrites an abandoned journal with only what is still unresolved", () => {
const file = writeJournal(EXITED_PID, [{ sessionId: "ses-done" }, { sessionId: "ses-retry" }])
const [abandoned] = new PendingStartJournal(dir).abandoned().journals
abandoned.settle([{ sessionId: "ses-retry" }])
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual([{ sessionId: "ses-retry" }])
abandoned.settle([])
expect(readdirSync(dir)).toEqual([])
})
})
+112
View File
@@ -0,0 +1,112 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import path from "node:path"
/** A sandbox a cloud start created and has not yet sent the first prompt to. */
export interface PendingStartRecord {
sessionId: string
/** Account scope (user and organization) the start ran under; only that scope may settle the record. */
account?: string
/** Control-plane endpoint the sandbox was created on. */
endpoint?: string
}
/** Records left by an extension host that has exited, with the means to rewrite what is still unresolved. */
export interface AbandonedJournal {
records: PendingStartRecord[]
settle(unresolved: PendingStartRecord[]): void
}
const JOURNAL_FILE = /^(\d+)-([0-9a-f-]+)\.json$/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
// EPERM means the process exists but belongs to someone else; only ESRCH proves it is gone.
return (error as NodeJS.ErrnoException).code !== "ESRCH"
}
}
/**
* Write-ahead record of the sandboxes this extension host is starting, so a
* later host can delete them if this one exits first. A terminating extension
* host cannot reach the network, so it cannot clean them up itself.
*
* Each extension-host lifetime writes its own file, `<pid>-<lifetime id>.json`,
* so concurrent windows never overwrite each other's records and a later host
* that is given the same pid never mistakes an old file for its own. A file
* whose pid is alive is never taken: the owner may only be paused, and a pid
* reused by another process only delays recovery until that process exits.
*/
export class PendingStartJournal {
private readonly fileName = `${process.pid}-${randomUUID()}.json`
private readonly records = new Map<string, PendingStartRecord>()
constructor(private readonly dir: string) {}
/** Throws when the record cannot be written, so the start fails and cleans up while it still can. */
add(record: PendingStartRecord): void {
this.records.set(record.sessionId, record)
this.write()
}
/** Throws when the record cannot be dropped, so a start never sends its prompt while recovery could still delete it. */
remove(sessionId: string): void {
if (this.records.delete(sessionId)) this.write()
}
/**
* Journals whose host lifetime has ended: its process exited, or it is an
* earlier lifetime of this process's pid. `liveOwners` is set when a journal
* was skipped because its process is still running.
*/
abandoned(): { journals: AbandonedJournal[]; liveOwners: boolean } {
let names: string[]
try {
names = readdirSync(this.dir)
} catch {
return { journals: [], liveOwners: false }
}
const journals: AbandonedJournal[] = []
let liveOwners = false
for (const name of names) {
const match = JOURNAL_FILE.exec(name)
if (!match || name === this.fileName) continue
const file = path.join(this.dir, name)
const pid = Number(match[1])
if (pid !== process.pid && isProcessAlive(pid)) {
liveOwners = true
continue
}
let records: PendingStartRecord[] = []
try {
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"))
if (Array.isArray(parsed)) {
records = parsed.filter((record): record is PendingStartRecord => typeof record?.sessionId === "string")
}
} catch {}
journals.push({
records,
settle: (unresolved) => {
try {
if (unresolved.length > 0) writeFileSync(file, JSON.stringify(unresolved))
else rmSync(file, { force: true })
} catch {}
},
})
}
return { journals, liveOwners }
}
private write(): void {
const file = path.join(this.dir, this.fileName)
if (this.records.size === 0) {
rmSync(file, { force: true })
return
}
mkdirSync(this.dir, { recursive: true })
writeFileSync(file, JSON.stringify([...this.records.values()]))
}
}
@@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { SdkSessionHost } from "./session-host"
const runtime = vi.hoisted(() => ({
listeners: [] as Array<(event: unknown) => void>,
runTurn: vi.fn(),
pendingList: vi.fn(),
pendingUpdate: vi.fn(),
pendingDelete: vi.fn(),
listSessions: vi.fn(),
updateSessionConnection: vi.fn(),
readSessionMessages: vi.fn(),
startSession: vi.fn(async (input: { config: { sessionId: string } }) => ({ sessionId: input.config.sessionId })),
}))
vi.mock("@cline/core", () => ({
RemoteRuntimeHost: class {
pendingPrompts = { list: runtime.pendingList, update: runtime.pendingUpdate, delete: runtime.pendingDelete }
connect = vi.fn(async () => undefined)
listSessions = runtime.listSessions
updateSessionConnection = runtime.updateSessionConnection
readSessionMessages = runtime.readSessionMessages
startSession = runtime.startSession
subscribe(listener: (event: unknown) => void) {
runtime.listeners.push(listener)
return vi.fn()
}
runTurn = runtime.runTurn
dispose = vi.fn(async () => undefined)
},
}))
const { CloudSessionHost, mapAgentFinishReason } = await import("./cloud-session-host")
describe("CloudSessionHost status", () => {
beforeEach(() => {
runtime.listeners.length = 0
runtime.runTurn.mockReset()
runtime.pendingList.mockReset().mockResolvedValue([])
runtime.pendingUpdate.mockReset()
runtime.pendingDelete.mockReset()
runtime.listSessions.mockReset().mockResolvedValue([{ sessionId: "inner-session", status: "idle" }])
runtime.updateSessionConnection.mockReset().mockResolvedValue(undefined)
runtime.readSessionMessages.mockReset().mockResolvedValue([])
runtime.startSession.mockClear()
})
it("rebuilds the runtime a resumed sandbox lost from its saved conversation before the first turn", async () => {
const saved = [{ role: "user", content: "first prompt" }]
runtime.updateSessionConnection.mockRejectedValueOnce(
Object.assign(new Error("session not found: inner-session"), { code: "session_not_found" }),
)
runtime.readSessionMessages.mockResolvedValue(saved)
runtime.runTurn.mockResolvedValue({ finishReason: "completed" })
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
restoreConfig: async () => ({ providerId: "cline", modelId: "sandbox-model", systemPrompt: "guidance" }) as never,
})
await host.send({ sessionId: "ses-outer", prompt: "follow-up" })
await host.send({ sessionId: "ses-outer", prompt: "another" })
expect(runtime.startSession).toHaveBeenCalledOnce()
expect(runtime.startSession).toHaveBeenCalledWith(
expect.objectContaining({
initialMessages: saved,
config: expect.objectContaining({ sessionId: "inner-session", modelId: "sandbox-model" }),
toolPolicies: { "*": { enabled: true, autoApprove: true } },
}),
)
expect(runtime.updateSessionConnection).toHaveBeenCalledOnce()
expect(runtime.runTurn).toHaveBeenCalledTimes(2)
})
it("leaves a live runtime alone and surfaces other probe failures", async () => {
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
restoreConfig: async () => ({ providerId: "cline", modelId: "sandbox-model" }) as never,
})
runtime.updateSessionConnection.mockRejectedValueOnce(new Error("connection closed"))
await expect(host.send({ sessionId: "ses-outer", prompt: "follow-up" })).rejects.toThrow("connection closed")
expect(runtime.runTurn).not.toHaveBeenCalled()
runtime.runTurn.mockResolvedValue({ finishReason: "completed" })
await host.send({ sessionId: "ses-outer", prompt: "follow-up" })
expect(runtime.startSession).not.toHaveBeenCalled()
expect(runtime.runTurn).toHaveBeenCalledOnce()
})
it("restores the runtime on the next send after a turn finds it missing", async () => {
const missing = Object.assign(new Error("session not found: inner-session"), { code: "session_not_found" })
runtime.readSessionMessages.mockResolvedValue([{ role: "user", content: "first prompt" }])
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
restoreConfig: async () => ({ providerId: "cline", modelId: "sandbox-model" }) as never,
})
runtime.runTurn.mockRejectedValueOnce(missing)
await expect(host.send({ sessionId: "ses-outer", prompt: "follow-up" })).rejects.toThrow(missing.message)
expect(runtime.startSession).not.toHaveBeenCalled()
runtime.updateSessionConnection.mockRejectedValueOnce(missing)
runtime.runTurn.mockResolvedValue({ finishReason: "completed" })
await host.send({ sessionId: "ses-outer", prompt: "retry" })
expect(runtime.startSession).toHaveBeenCalledOnce()
expect(runtime.runTurn).toHaveBeenCalledTimes(2)
})
it.each([
["completed", "completed"],
["aborted", "cancelled"],
["error", "failed"],
["max_iterations", "failed"],
["mistake_limit", "failed"],
] as const)("maps %s to %s", (reason, expected) => {
expect(mapAgentFinishReason(reason)).toBe(expected)
})
it("sends every turn in Act mode even when the caller asks for Plan", async () => {
runtime.runTurn.mockResolvedValue({ finishReason: "completed" })
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
})
await host.send({ sessionId: "ses-outer", prompt: "continue", mode: "plan" })
expect(runtime.runTurn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "inner-session", mode: "act" }))
})
it("subscribes to the task it discovers so a completion by another client is seen", async () => {
runtime.listSessions.mockResolvedValue([{ sessionId: "inner-session", status: "running" }])
runtime.pendingList.mockResolvedValue([])
const onStatusChange = vi.fn()
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
onStatusChange,
})
expect(host.status).toBe("running")
// A status-only connection has sent no command for this task; the Hub
// only streams a session's events to clients subscribed to it.
expect(runtime.pendingList).toHaveBeenCalledWith({ sessionId: "inner-session" })
for (const listener of runtime.listeners) {
listener({ type: "ended", payload: { sessionId: "inner-session", reason: "completed" } })
}
expect(host.status).toBe("completed")
expect(onStatusChange).toHaveBeenCalledWith("completed")
})
it("does not claim to change the model the sandbox runs on", async () => {
runtime.listSessions.mockResolvedValue([])
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
})
await host.start({ config: { providerId: "cline", modelId: "sandbox-model" }, interactive: true } as never)
// The Hub protocol has no command to change a running session's model,
// so the lifecycle must see no capability and the composer must keep
// naming the model the sandbox was started on.
expect((host as SdkSessionHost).updateSessionModel).toBeUndefined()
expect(host.sessionModelId).toBe("sandbox-model")
})
it("refreshes a retained host without letting an older snapshot overwrite a live event", async () => {
runtime.listSessions.mockResolvedValueOnce([{ sessionId: "inner-session", status: "completed" }])
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1",
getAuthToken: async () => "token",
})
let resolve!: (sessions: unknown[]) => void
runtime.listSessions.mockReturnValueOnce(
new Promise((done) => {
resolve = done
}),
)
const refreshing = host.refreshStatus()
for (const listener of runtime.listeners)
listener({ type: "status", payload: { sessionId: "inner-session", status: "running" } })
resolve([{ sessionId: "inner-session", status: "completed" }])
expect(await refreshing).toBe("running")
runtime.listSessions.mockResolvedValueOnce([{ sessionId: "inner-session", status: "completed" }])
expect(await host.refreshStatus()).toBe("completed")
runtime.listSessions.mockRejectedValueOnce(new Error("offline"))
await expect(host.refreshStatus()).rejects.toThrow("offline")
expect(host.status).toBe("unknown")
await host.dispose()
})
it("leaves running state when runTurn rejects without a terminal event", async () => {
const error = new Error("connection closed")
runtime.runTurn.mockRejectedValue(error)
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
})
await expect(host.send({ sessionId: "ses-outer", prompt: "continue" })).rejects.toBe(error)
expect(host.status).toBe("unknown")
})
it.each(["update", "delete"] as const)("maps pending-prompt %s across the cloud id boundary", async (action) => {
const mutate = action === "update" ? runtime.pendingUpdate : runtime.pendingDelete
mutate.mockResolvedValue({ sessionId: "inner-session", prompts: [] })
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1/session",
getAuthToken: async () => "token",
})
const input = { sessionId: "ses-outer", promptId: "prompt-1" }
const result =
action === "update" ? await host.pendingPrompts("update", input) : await host.pendingPrompts("delete", input)
expect(mutate).toHaveBeenCalledWith({ ...input, sessionId: "inner-session" })
expect(result).toEqual({ sessionId: "ses-outer", prompts: [] })
})
it.each([
"completed",
"aborted",
"error",
"max_iterations",
"mistake_limit",
] as const)("preserves %s across both idle/done orderings and a late RPC rejection", async (reason) => {
for (const idleFirst of [false, true]) {
let reject!: (error: Error) => void
runtime.runTurn.mockImplementationOnce(
() =>
new Promise((_resolve, fail) => {
reject = fail
}),
)
const host = await CloudSessionHost.connect({
outerSessionId: "ses-outer",
taskId: "inner-session",
socketUrl: "ws://127.0.0.1:1",
getAuthToken: async () => "token",
})
const sending = host.send({ sessionId: "ses-outer", prompt: "continue" })
const idle = { type: "status", payload: { sessionId: "inner-session", status: "idle" } }
const done = { type: "agent_event", payload: { sessionId: "inner-session", event: { type: "done", reason } } }
for (const event of idleFirst ? [idle, done] : [done, idle]) {
for (const listener of runtime.listeners) listener(event)
}
reject(new Error("reply lost"))
await expect(sending).rejects.toThrow("reply lost")
expect(host.status).toBe(mapAgentFinishReason(reason))
await host.dispose()
}
})
})
+485
View File
@@ -0,0 +1,485 @@
// CloudSessionHost — an SdkSessionHost backed by a Cline Cloud sandbox.
//
// A cloud session has two ids: the outer `ses-…` record owned by the Cline
// Cloud control plane (what extension history and the task view use as their
// identifier), and the canonical `tsk-…` Hub session named by the control plane. This host dials the
// pod's Hub through the api.cline.bot WebSocket proxy with the user's Cline
// account token, maps outer <-> inner ids, and re-emits Hub events under the
// outer id so the rest of the extension (session lifecycle, event coordinator,
// message translator, chat view) works exactly as it does for local sessions.
//
// No local tool executors are attached: editing, commands and file reads all
// run inside the sandbox. Tools are auto-approved there (the sandbox is
// isolated and the session must keep going after VS Code closes), so the
// approval capability only surfaces anything the pod still insists on asking.
import {
type CompareCheckpointInput,
type CompareCheckpointResult,
type CoreSessionEvent,
type HookEventPayload,
type ITelemetryService,
type PendingPromptMutationResult,
type PendingPromptsDeleteInput,
type PendingPromptsListInput,
type PendingPromptsUpdateInput,
RemoteRuntimeHost,
type RestoreInput,
type RestoreResult,
type SendSessionInput,
type SessionAccumulatedUsage,
type SessionCompactionState,
type SessionHistoryRecord,
type SessionPendingPrompt,
type SessionRecord,
type StartSessionInput,
type StartSessionResult,
} from "@cline/core"
import { buildCloudSessionSystemPrompt } from "@cline/core/cloud"
import type { AgentResult, ToolApprovalRequest, ToolApprovalResult } from "@cline/shared"
import { CLOUD_SESSION_MODE, CLOUD_WORKSPACE_ROOT, type CloudSessionStatus } from "@shared/cloud/cloud-sessions"
import { Logger } from "@/shared/services/Logger"
import type { SdkInitialMessages, SdkSessionHost } from "./session-host"
export interface CloudSessionHostOptions {
outerSessionId: string
/** Canonical `tsk-…` id shared by runtime requests, transcripts and history snapshots. */
taskId: string
/** A temporary (standard) sandbox's agent is told to push its work as it goes. */
sandboxType?: "standard" | "resumable"
socketUrl: string
getAuthToken: () => Promise<string | null | undefined>
requestToolApproval?: (request: ToolApprovalRequest) => Promise<ToolApprovalResult>
telemetry?: ITelemetryService
onStatusChange?: (status: CloudSessionStatus) => void
/** Sandbox workspace root. Hosted sandboxes use /workspace. */
workspaceRoot?: string
/**
* The session config to rebuild the conversation's runtime with when a
* resumed sandbox has the conversation saved but no longer running.
*/
restoreConfig?: () => Promise<StartSessionInput["config"]>
}
const AUTO_APPROVE_ALL = { "*": { enabled: true, autoApprove: true } }
function isMissingSessionError(error: unknown, sessionId: string): boolean {
const { code, message } = (error ?? {}) as { code?: unknown; message?: unknown }
return code === "session_not_found" || message === `session not found: ${sessionId}`
}
export function mapAgentStatus(status: string): CloudSessionStatus | undefined {
switch (status) {
case "running":
case "pending":
return "running"
case "completed":
return "completed"
case "failed":
case "error":
return "failed"
case "idle":
return "idle"
case "cancelled":
case "aborted":
return "cancelled"
default:
return undefined
}
}
export function mapAgentFinishReason(reason: AgentResult["finishReason"] | string): CloudSessionStatus {
switch (reason) {
case "completed":
return "completed"
case "aborted":
return "cancelled"
case "error":
case "max_iterations":
case "mistake_limit":
return "failed"
default:
return "unknown"
}
}
function unsupported(operation: string): never {
throw new Error(`${operation} is not supported for cloud sessions`)
}
export class CloudSessionHost implements SdkSessionHost {
readonly isCloud = true as const
readonly runtimeAddress: string | undefined
readonly outerSessionId: string
private readonly taskId: string
private innerSessionId: string | undefined
private agentStatus: CloudSessionStatus = "idle"
private modelId: string | undefined
private readonly host: RemoteRuntimeHost
private readonly statusUnsubscribe: () => void
private disposed = false
private statusObservation = {}
/** Whether the inner session is known to have a live runtime on this connection's Hub. */
private innerSessionLive = false
private constructor(
private readonly options: CloudSessionHostOptions,
host: RemoteRuntimeHost,
) {
this.outerSessionId = options.outerSessionId
this.taskId = options.taskId
this.host = host
this.runtimeAddress = options.socketUrl
// Track the agent's activity for the whole life of the connection, not
// just while a task view is subscribed: the registry uses it to show
// running/finished in History and to notify when a background cloud
// task completes.
this.statusUnsubscribe = host.subscribe((event) => this.trackStatus(event))
}
static async connect(options: CloudSessionHostOptions): Promise<CloudSessionHost> {
const workspaceRoot = options.workspaceRoot ?? CLOUD_WORKSPACE_ROOT
const host = new RemoteRuntimeHost({
endpoint: options.socketUrl,
clientType: "vscode-cloud-session",
displayName: "Cline for VS Code (cloud session)",
workspaceRoot,
cwd: workspaceRoot,
telemetry: options.telemetry,
capabilities: options.requestToolApproval ? { requestToolApproval: options.requestToolApproval } : undefined,
resolveConnectionHeaders: async () => {
const token = (await options.getAuthToken())?.trim()
if (!token) {
throw new Error("Sign in to Cline to connect to this cloud session.")
}
return { Authorization: `Bearer ${token}` }
},
})
await host.connect()
const cloudHost = new CloudSessionHost(options, host)
try {
await cloudHost.discoverInnerSession()
} catch (error) {
await cloudHost.dispose("discoveryFailed").catch(() => {})
throw error
}
return cloudHost
}
get status(): CloudSessionStatus {
return this.agentStatus
}
get sessionModelId(): string | undefined {
return this.modelId
}
/** Attach only to the canonical conversation named by the control plane. */
private async discoverInnerSession(): Promise<void> {
const observation = this.statusObservation
const sessions = await this.host.listSessions(100)
if (this.disposed || this.statusObservation !== observation) return
const task = sessions.find((session) => session.sessionId === this.taskId)
if (task) {
this.innerSessionId = task.sessionId
this.modelId = typeof task.model === "string" ? task.model : undefined
const mapped = mapAgentStatus(String(task.status ?? ""))
this.setStatus(mapped ?? "unknown", false)
// The Hub streams a session's events only to clients subscribed to it,
// and it subscribes a client when it first sends a command for that
// session. A connection opened only to watch status has sent none, so
// list the task's pending prompts to open the stream; otherwise a turn
// finished by another client would leave this host on "running".
await this.host.pendingPrompts.list({ sessionId: task.sessionId }).catch(() => undefined)
} else if (this.innerSessionId) {
this.setStatus("unknown", false)
}
}
/** Re-read a retained connection after its control-plane record changed. */
async refreshStatus(): Promise<CloudSessionStatus> {
const observation = this.statusObservation
try {
// The coordinator applies this snapshot only while its record is still
// current; a snapshot is not a new activity/completion event.
await this.discoverInnerSession()
} catch (error) {
if (!this.disposed && this.statusObservation === observation) this.setStatus("unknown", false)
throw error
}
return this.status
}
private trackStatus(event: CoreSessionEvent): void {
if (!this.innerSessionId || event.payload.sessionId !== this.innerSessionId) {
return
}
this.statusObservation = {}
if (event.type === "status") {
const mapped = mapAgentStatus(event.payload.status)
// "idle" is the resting state after any turn; keep the more specific
// completed/failed/cancelled outcome until the agent runs again.
if (mapped && !(mapped === "idle" && ["completed", "failed", "cancelled"].includes(this.agentStatus))) {
this.setStatus(mapped)
}
} else if (event.type === "ended") {
this.setStatus(mapAgentFinishReason(event.payload.reason))
} else if (event.type === "agent_event") {
const agentEvent = event.payload.event
if (agentEvent.type === "done") {
this.setStatus(mapAgentFinishReason(agentEvent.reason))
} else if (agentEvent.type === "error" && agentEvent.recoverable === false) {
this.setStatus("failed")
} else if (this.agentStatus !== "running") {
this.setStatus("running")
}
} else if (event.type === "chunk" && this.agentStatus !== "running") {
this.setStatus("running")
}
}
private setStatus(status: CloudSessionStatus, notify = true): void {
this.statusObservation = {}
if (this.agentStatus === status) {
return
}
this.agentStatus = status
if (notify) this.options.onStatusChange?.(status)
}
private toInner(sessionId: string): string {
if (sessionId !== this.outerSessionId && sessionId !== this.innerSessionId) {
Logger.warn(`[CloudSessionHost] Unexpected session id ${sessionId} for cloud session ${this.outerSessionId}`)
}
if (!this.innerSessionId) {
throw new Error("This cloud session has no conversation yet.")
}
return this.innerSessionId
}
private remap(event: CoreSessionEvent): CoreSessionEvent | undefined {
if (!this.innerSessionId || event.payload.sessionId !== this.innerSessionId) {
return undefined
}
return { ...event, payload: { ...event.payload, sessionId: this.outerSessionId } } as CoreSessionEvent
}
// ---- SdkSessionHost ----
async start(input: StartSessionInput): Promise<StartSessionResult> {
if (this.innerSessionId) {
throw new Error("This cloud session already has a conversation.")
}
// The control plane snapshots the outer session and canonical task ids
// together. Keep that task id for every artifact this Hub session writes.
const plannedId = this.taskId
this.innerSessionId = plannedId
this.setStatus("running")
try {
const result = await this.host.startSession({
...input,
config: this.sandboxSessionConfig(input.config, plannedId),
toolPolicies: AUTO_APPROVE_ALL,
})
this.innerSessionId = result.sessionId
this.innerSessionLive = true
this.modelId = input.config.modelId
return { ...result, sessionId: this.outerSessionId }
} catch (error) {
this.innerSessionId = undefined
this.setStatus("failed")
throw error
}
}
private sandboxSessionConfig(config: StartSessionInput["config"], sessionId: string): StartSessionInput["config"] {
const workspaceRoot = this.options.workspaceRoot ?? CLOUD_WORKSPACE_ROOT
// The same sandbox guidance (GitHub access, work branch) every cloud client sends.
const guidance = buildCloudSessionSystemPrompt({
id: this.outerSessionId,
sandboxType: this.options.sandboxType,
metadata: { taskId: this.taskId },
})
return {
...config,
sessionId,
cwd: config.cwd?.trim() || workspaceRoot,
workspaceRoot,
systemPrompt: config.systemPrompt ? `${guidance}\n\n${config.systemPrompt}` : guidance,
}
}
/**
* A resumed sandbox restarts its Hub with the conversation saved on disk but
* no runtime, so a turn would fail with "session not found". Rebuild the
* runtime from the saved messages before the first turn on this connection.
*/
private async ensureInnerSessionLive(
sessionId: string,
restoreConfig: NonNullable<CloudSessionHostOptions["restoreConfig"]>,
): Promise<void> {
try {
await this.host.updateSessionConnection(sessionId, {})
} catch (error) {
if (!isMissingSessionError(error, sessionId)) throw error
const initialMessages = await this.host.readSessionMessages(sessionId)
if (initialMessages.length === 0) throw new Error("This cloud session's saved conversation is unavailable.")
const config = this.sandboxSessionConfig(await restoreConfig(), sessionId)
try {
await this.host.startSession({ config, initialMessages, interactive: true, toolPolicies: AUTO_APPROVE_ALL })
} catch (restoreError) {
// Another client restored it first.
if ((restoreError as { code?: unknown })?.code !== "session_already_exists") throw restoreError
}
}
this.innerSessionLive = true
}
async send(input: SendSessionInput): Promise<AgentResult | undefined> {
const sessionId = this.toInner(input.sessionId)
const { restoreConfig } = this.options
if (restoreConfig && !this.innerSessionLive) await this.ensureInnerSessionLive(sessionId, restoreConfig)
this.setStatus("running")
try {
return await this.host.runTurn({
...input,
sessionId,
// The sandbox runtime was built for Act; every turn must say the same.
mode: CLOUD_SESSION_MODE,
// Local file paths mean nothing inside the sandbox; images travel as data URLs.
userFiles: undefined,
})
} catch (error) {
// The Hub can restart under this connection when another client resumes
// the sandbox; let the next send restore the runtime.
if (isMissingSessionError(error, sessionId)) this.innerSessionLive = false
// A rejected RPC proves only that this client stopped observing the turn;
// the sandbox may still be running after a transport loss.
if (!this.disposed && (this.agentStatus === "running" || this.agentStatus === "idle")) {
this.setStatus("unknown")
}
throw error
}
}
async getAccumulatedUsage(sessionId: string): Promise<SessionAccumulatedUsage | undefined> {
const summary = await this.host.getAccumulatedUsage?.(this.toInner(sessionId))
return summary?.usage
}
async abort(sessionId: string, reason?: unknown): Promise<void> {
try {
await this.host.abort(this.toInner(sessionId), reason)
} catch (error) {
if (error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("aborted"))) {
return
}
throw error
}
}
/**
* "Stopping" a cloud session from the task view must not stop the agent:
* the whole point is that it keeps running after the user moves on. The
* connection is kept by the registry; the caller only unsubscribes.
*/
async stop(_sessionId: string): Promise<void> {}
async dispose(_reason?: string): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.statusUnsubscribe()
await this.host.dispose()
}
async get(sessionId: string): Promise<SessionRecord | undefined> {
if (!this.innerSessionId) {
return undefined
}
const record = await this.host.getSession(this.toInner(sessionId))
return record ? { ...record, sessionId: this.outerSessionId } : undefined
}
async list(): Promise<SessionHistoryRecord[]> {
return []
}
async listHistory(): Promise<SessionHistoryRecord[]> {
return []
}
async delete(_sessionId: string): Promise<boolean> {
return unsupported("Deleting the sandbox conversation")
}
async readMessages(sessionId: string): Promise<SdkInitialMessages> {
if (!this.innerSessionId) {
return []
}
return (await this.host.readSessionMessages(this.toInner(sessionId))) as SdkInitialMessages
}
async readLiveMessages(sessionId: string): Promise<SdkInitialMessages> {
return this.readMessages(sessionId)
}
async updateSessionCompactionState(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }> {
return this.host.updateSessionCompactionState(this.toInner(sessionId), state)
}
async restore(_input: RestoreInput): Promise<RestoreResult> {
return unsupported("Checkpoint restore")
}
async compareCheckpoint(_input: CompareCheckpointInput): Promise<CompareCheckpointResult> {
return unsupported("Checkpoint comparison")
}
async update(
sessionId: string,
updates: { prompt?: string | null; metadata?: Record<string, unknown> | null; title?: string | null },
): Promise<{ updated: boolean }> {
if (!this.innerSessionId) {
return { updated: false }
}
return this.host.updateSession(this.toInner(sessionId), updates)
}
async handleHookEvent(payload: HookEventPayload): Promise<void> {
return this.host.dispatchHookEvent(payload)
}
pendingPrompts(action: "list", input: PendingPromptsListInput): Promise<SessionPendingPrompt[]>
pendingPrompts(action: "update", input: PendingPromptsUpdateInput): Promise<PendingPromptMutationResult>
pendingPrompts(action: "delete", input: PendingPromptsDeleteInput): Promise<PendingPromptMutationResult>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async pendingPrompts(action: any, input: any): Promise<any> {
const mapped = { ...input, sessionId: this.toInner(input.sessionId) }
switch (action) {
case "list":
return await this.host.pendingPrompts.list(mapped)
case "update":
return { ...(await this.host.pendingPrompts.update(mapped)), sessionId: this.outerSessionId }
case "delete":
return { ...(await this.host.pendingPrompts.delete(mapped)), sessionId: this.outerSessionId }
default:
throw new Error(`Unsupported pending prompt action: ${String(action)}`)
}
}
subscribe(listener: (event: CoreSessionEvent) => void): () => void {
return this.host.subscribe((event) => {
const remapped = this.remap(event)
if (remapped) {
listener(remapped)
}
})
}
// updateSessionModel is deliberately absent: the Hub protocol has no command
// to change a running session's model, so the lifecycle reports the
// capability as unavailable and sessionModelId keeps naming the model the
// sandbox was started on.
}
@@ -1482,6 +1482,27 @@ describe("translateSessionEvent — agent_event error", () => {
const result = translateSessionEvent(event, state)
expect(result.turnComplete).toBe(true)
expect(state.wasErrorSeen()).toBe(true)
expect(result.messages.map((message) => message.ask ?? message.say)).toEqual(["api_req_started", "api_req_failed"])
expect(result.messages[1].text).toBe("stream failed before assistant output")
})
it("renders a failure carried only by done(reason:'error') once, as a credit error when it is one", () => {
const state = new MessageTranslatorState()
const done = (text: string): CoreSessionEvent => ({
type: "agent_event",
payload: {
sessionId: "session-1",
event: { type: "done", reason: "error", text, iterations: 1 } as AgentEvent,
},
})
const first = translateSessionEvent(done("Not enough credits available"), state)
const failed = first.messages.filter((message) => message.ask === "api_req_failed")
expect(failed).toHaveLength(1)
expect(JSON.parse(failed[0].text ?? "{}")).toMatchObject({ code: "insufficient_credits" })
// An "error" event already rendered this turn's failure.
expect(translateSessionEvent(done("Not enough credits available"), state).messages).toEqual([])
})
it("does not record an error outcome for a successful done event", () => {
@@ -2532,6 +2553,24 @@ describe("translateSessionEvent — accumulated text streaming (S6-21 fix)", ()
expect(end.messages[0].ts).toBe(streamingTs)
})
it("accumulates text chunks that carry no running total, as a cloud session sends them", () => {
const state = new MessageTranslatorState()
const chunk = (text: string): CoreSessionEvent => ({
type: "agent_event",
payload: { sessionId: "s1", event: { type: "content_start", contentType: "text", text } as AgentEvent },
})
const end = (text: string): CoreSessionEvent => ({
type: "agent_event",
payload: { sessionId: "s1", event: { type: "content_end", contentType: "text", text } as AgentEvent },
})
expect(translateSessionEvent(chunk("Hello "), state).messages[0].text).toBe("Hello ")
expect(translateSessionEvent(chunk("world"), state).messages[0].text).toBe("Hello world")
translateSessionEvent(end("Hello world"), state)
// The next text block starts from nothing.
expect(translateSessionEvent(chunk("Next"), state).messages[0].text).toBe("Next")
})
it("accumulates reasoning deltas into text for webview rendering", () => {
const state = new MessageTranslatorState()
+57 -50
View File
@@ -125,6 +125,8 @@ function normalizeUsageEvent(usageEvent: {
export class MessageTranslatorState {
/** Current streaming text message timestamp (used for dedup) */
private streamingTextTs: number | undefined
/** Streaming text so far, for sources whose deltas carry no `accumulated` */
private streamingText = ""
/** Current streaming reasoning message timestamp */
private streamingReasoningTs: number | undefined
/** Accumulated streaming reasoning text (SDK reasoning events are deltas) */
@@ -224,10 +226,21 @@ export class MessageTranslatorState {
return this.streamingTextTs
}
/**
* The text streamed so far after this chunk. A local session reports the
* running total as `accumulated`; a Hub-hosted (cloud) session forwards
* only the chunk, so the total is built here.
*/
appendStreamingText(chunk: string, accumulated: string | undefined): string {
this.streamingText = accumulated ?? this.streamingText + chunk
return this.streamingText
}
/** Clear streaming text (content ended) */
clearStreamingText(): number {
const ts = this.streamingTextTs ?? this.nextTs()
this.streamingTextTs = undefined
this.streamingText = ""
return ts
}
@@ -531,6 +544,7 @@ export class MessageTranslatorState {
*/
reset(): void {
this.streamingTextTs = undefined
this.streamingText = ""
this.streamingReasoningTs = undefined
this.streamingToolTs = undefined
this.streamingToolInput = undefined
@@ -1305,6 +1319,33 @@ function finalizeDanglingCompaction(
messages.push(buildCompactionMessage({ status, mode: "auto" }, ts))
}
/**
* Renders a turn failure: an api_req_started carrying the error, so the
* request row shows it via ErrorRow instead of a spinner, then
* ask:"api_req_failed" as the last message, so the webview offers recovery
* (Retry, Buy Credits, Sign In). The error text is reshaped into the
* ClineError JSON that ErrorRow parses to pick a special card, e.g. the
* Cline provider's 402 `insufficient_credits` body.
*/
function pushTurnErrorRows(
messages: ClineMessage[],
state: MessageTranslatorState,
error: { message?: string; status?: number; code?: string },
errorClass?: ProviderErrorClass,
): void {
const errorPayload = reshapeErrorForWebview(error, state.activeProviderId(), state.activeModelId(), errorClass)
messages.push(
{
ts: state.nextTs(),
type: "say",
say: "api_req_started",
text: JSON.stringify({ streamingFailedMessage: errorPayload } satisfies ClineApiReqInfo),
partial: false,
},
{ ts: state.nextTs(), type: "ask", ask: "api_req_failed", text: errorPayload, partial: false },
)
}
function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState): ClineMessage[] {
const messages: ClineMessage[] = []
@@ -1312,18 +1353,16 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
case "content_start": {
switch (event.contentType) {
case "text": {
// The SDK emits MULTIPLE content_start events for streaming text.
// Each has `text` (the delta) and `accumulated` (full text so far).
// We use `accumulated` so the webview can update the message in-place
// with the growing text, giving smooth streaming. Using `text` (delta)
// would cause a "flip book" effect where each update replaces the
// previous content with just the new chunk.
// The SDK emits MULTIPLE content_start events for streaming text,
// each carrying a chunk. The row must show the full text so far so
// the webview updates it in place; showing only the chunk gives a
// "flip book" effect where each update replaces the previous one.
const ts = state.getStreamingTextTs()
messages.push({
ts,
type: "say",
say: "text",
text: event.accumulated ?? event.text ?? "",
text: state.appendStreamingText(event.text ?? "", event.accumulated),
partial: true,
})
break
@@ -1933,9 +1972,16 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
finalizeDanglingCompaction(state, messages, "cancelled")
// A turn can terminate with done(reason:"error") without a separate
// "error" event — record the error outcome here too so turn end still
// resolves to the "error" phase (Retry / Start New Task).
if (event.reason === "error") {
// "error" event: a Hub-hosted (cloud) session forwards only the run's
// terminal result, whose text is the failure message. Record the
// error outcome so turn end resolves to the "error" phase (Retry /
// Start New Task), and render the failure unless an "error" event
// already did.
if (event.reason === "error" && !state.wasErrorSeen() && !state.isSuppressedToolApprovalDenial(event.text)) {
state.clearTurnFinalText()
state.setErrorSeen()
pushTurnErrorRows(messages, state, { message: event.text || undefined })
} else if (event.reason === "error") {
state.setErrorSeen()
}
@@ -1992,46 +2038,7 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
// (footer shows Retry / Start New Task) instead of awaiting_followup.
state.setErrorSeen()
// Serialize the error message for the webview's ErrorRow to parse.
// The webview uses ClineError.parse() on the `api_req_failed` text to
// detect special error types (insufficient credits, spend limit, auth,
// quota exceeded) and render appropriate UI (e.g. "Add Credits" button).
//
// The error object from the SDK is a standard JS Error. Its `message`
// may contain JSON from the API (e.g. Cline provider's 402 response with
// `code: "insufficient_credits"`). We try to reshape it into the
// ClineError-serialized format the webview expects so that ErrorRow
// can render the correct UI (Buy Credits button, etc.).
const errorPayload = reshapeErrorForWebview(
event.error,
state.activeProviderId(),
state.activeModelId(),
event.errorClass,
)
// Emit an api_req_started with streamingFailedMessage so the
// RequestStartRow renders the error via ErrorRow. This replaces
// the spinner on the last API request row.
messages.push({
ts: state.nextTs(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
streamingFailedMessage: errorPayload,
} satisfies ClineApiReqInfo),
partial: false,
})
// Emit ask:"api_req_failed" as the LAST message so the webview
// shows error recovery UI (Retry button, Add Credits button,
// Sign In button, etc.) instead of a stuck "Thinking..." spinner.
messages.push({
ts: state.nextTs(),
type: "ask",
ask: "api_req_failed",
text: errorPayload,
partial: false,
})
pushTurnErrorRows(messages, state, event.error, event.errorClass)
break
}
+4 -5
View File
@@ -15,7 +15,6 @@ import { Logger } from "@shared/services/Logger"
import type { Mode } from "@shared/storage/types"
import { reasoningEffortFromThinkingBudget } from "@shared/utils/reasoning-support"
import { ExtensionRegistryInfo } from "@/registry"
import { fetch } from "@/shared/net"
import { buildBedrockProviderConfig } from "./bedrock-config"
import {
type HostClientContext,
@@ -24,6 +23,7 @@ import {
resolveClineRequestClientContext,
resolveModelId,
resolveOllamaProviderConfig,
resolveProviderFetch,
resolveVertexProviderConfig,
} from "./cline-session-factory"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
@@ -129,7 +129,6 @@ export function buildSdkProviderConfig(
mode === "plan" ? configuration.planModeThinkingBudgetTokens : configuration.actModeThinkingBudgetTokens
const vertexProviderConfig = providerId === "vertex" ? resolveVertexProviderConfig(configuration) : undefined
const sdkProviderId = toSdkProviderId(providerId)
const headers = resolveRequestHeaders(sdkProviderId, options?.client)
@@ -140,9 +139,9 @@ export function buildSdkProviderConfig(
baseUrl,
...(headers ? { headers } : {}),
...(vertexProviderConfig ?? {}),
// Use the proxy-aware fetch so gateway providers respect corporate proxy
// configuration (see .clinerules/network.md).
fetch,
// Proxy-aware fetch (see .clinerules/network.md); local model servers get
// the variant without undici's idle timeouts.
fetch: resolveProviderFetch(sdkProviderId, baseUrl),
// Bedrock needs its region + structured AWS auth options forwarded to the
// SDK gateway. Without these, a pasted Bedrock API key / region is dropped.
...(providerId === "bedrock" ? buildBedrockProviderConfig(configuration, mode) : {}),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { HostProvider } from "@/hosts/host-provider"
import { CloudSessionsService } from "@/services/cloud/CloudSessionsService"
import { CloudSessionHost } from "./cloud-session-host"
import { MessageIdMinter } from "./message-id-minter"
import { SdkCloudSessionCoordinator, type SdkCloudSessionCoordinatorOptions } from "./sdk-cloud-session-coordinator"
import { SdkSessionLifecycle } from "./sdk-session-lifecycle"
import type { TaskProxy } from "./task-proxy"
vi.mock("@/hosts/host-provider", () => ({ HostProvider: { window: { showMessage: vi.fn(async () => ({})) } } }))
vi.mock("@/core/storage/StateManager", () => ({ StateManager: { get: () => ({ getGlobalSettingsKey: () => undefined }) } }))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
type WaitPoint = "create" | "readiness" | "connect" | "lifecycle" | "prompt" | "delete"
function fixture(waitPoint: WaitPoint, organizationId?: string) {
const barrier = deferred<void>()
const entered = deferred<void>()
let scope = { token: "fixture-origin", endpoint: "http://127.0.0.1:1111", organizationId }
const origin = scope
const successor = { token: "fixture-successor", endpoint: "http://127.0.0.1:2222", organizationId: "org-successor" }
let deletionStatus = 204
let readinessStatus = "active"
const deleted = new Set<string>()
const requests: Array<{ method: string; url: string; token: string | null; body?: string }> = []
const record = {
id: "ses-owned",
status: "active",
repoContext: {},
metadata: { taskId: "tsk-owned" },
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
}
const currentRecord = () =>
scope === origin ? record : { ...record, id: "ses-successor", metadata: { ...record.metadata, taskId: "tsk-successor" } }
const wait = async (point: WaitPoint) => {
if (waitPoint === point) {
entered.resolve()
await barrier.promise
}
}
const service = new CloudSessionsService({
get apiBaseUrl() {
return scope.endpoint
},
appBaseUrl: "http://127.0.0.1:1111",
getAuthToken: async () => scope.token,
getActiveOrganizationId: () => scope.organizationId,
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const method = init?.method ?? "GET"
requests.push({
method,
url,
token: new Headers(init?.headers).get("Authorization"),
body: typeof init?.body === "string" ? init.body : undefined,
})
if (method === "POST") {
await wait("create")
return Response.json({
data: {
sessionId: currentRecord().id,
status: waitPoint === "readiness" ? "provisioning" : "active",
sandboxUrl: origin.endpoint,
},
})
}
if (method === "DELETE") {
await wait("delete")
if (deletionStatus === 204) {
deleted.add(decodeURIComponent(url.slice(url.lastIndexOf("/") + 1)))
return new Response(null, { status: 204 })
}
return Response.json({ error: "untrusted private server detail" }, { status: deletionStatus })
}
if (url.endsWith("/status")) {
await wait("readiness")
return Response.json({ data: { status: readinessStatus } })
}
if (method === "PATCH") return Response.json({ data: record })
return Response.json({ data: [currentRecord()].filter((listed) => !deleted.has(listed.id)) })
}) as typeof fetch,
})
const host = {
start: vi.fn(async () => {
await wait("lifecycle")
return { sessionId: record.id }
}),
stop: async () => {},
dispose: vi.fn(async () => {}),
subscribe: () => () => {},
send: vi.fn(async () => {}),
} as unknown as CloudSessionHost
vi.spyOn(CloudSessionHost, "connect").mockImplementation(async () => {
await wait("connect")
return host
})
const lifecycle = new SdkSessionLifecycle({
mcpHub: {} as never,
requestToolApproval: vi.fn(),
askQuestion: vi.fn(),
onSessionEvent: vi.fn(),
onSendComplete: vi.fn(),
onSendError: vi.fn(),
})
let task: TaskProxy | undefined
let viewGeneration = 0
const options = {
cloudSessions: service,
stateManager: {
getApiConfiguration: () => ({ actModeApiProvider: "cline", actModeClineModelId: "fixture-model" }),
getGlobalSettingsKey: () => "act",
getGlobalStateKey: () => ({}),
setGlobalState: vi.fn(),
},
sessionConfigBuilder: {
build: vi.fn(async () => ({
providerId: "cline",
modelId: "fixture-model",
apiKey: "fixture-key",
providerConfig: {
providerId: "cline",
modelId: "fixture-model",
apiKey: "fixture-key",
knownModels: { "catalog-model": { id: "catalog-model" } },
},
knownModels: { "catalog-model": { id: "catalog-model" } },
cwd: "/workspace",
workspaceRoot: "/workspace",
systemPrompt: "normal Cline guidance",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
})),
},
sessions: lifecycle,
messages: { appendAndEmit: vi.fn(), finalizeMessagesForSave: (messages: unknown) => messages },
getMinter: () => new MessageIdMinter(),
getTask: () => task,
setTask: (next: TaskProxy | undefined) => {
task = next
},
onAskResponse: vi.fn(),
onCancelTask: vi.fn(),
clearTask: async () => {
task = undefined
const claimed = ++viewGeneration
return () => claimed !== viewGeneration
},
onStartFailed: vi.fn(),
claimTaskViewGeneration: () => {
const claim = ++viewGeneration
return () => claim !== viewGeneration
},
requestToolApproval: vi.fn(),
getAuthToken: async () => scope.token,
isSignedIn: () => true,
isEnabled: () => true,
resetMessageTranslator: vi.fn(),
setTurnPhase: vi.fn(),
clearTurnOutcome: vi.fn(),
postStateToWebview: vi.fn(async () => {}),
showChatView: vi.fn(async () => {}),
invalidateHistoryCache: vi.fn(),
resolveContextMentions: async (prompt: string) => {
await wait("prompt")
return prompt
},
} satisfies { [Key in keyof SdkCloudSessionCoordinatorOptions]: unknown }
const coordinator = new SdkCloudSessionCoordinator(options as unknown as SdkCloudSessionCoordinatorOptions)
const changeScope = vi.fn(async () => {
scope = successor
})
return {
coordinator,
service,
host,
lifecycle,
options,
requests,
origin,
successor,
record,
entered,
barrier,
changeScope,
get scope() {
return scope
},
setDeletionStatus: (status: number) => {
deletionStatus = status
},
setReadinessStatus: (status: string) => {
readinessStatus = status
},
}
}
const startInput = { prompt: "fixture task", repoUrl: "https://github.com/cline/fixture" }
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
vi.useRealTimers()
})
describe("originating-account cloud cleanup", () => {
it.each([
"create",
"readiness",
"connect",
"lifecycle",
"prompt",
] as const)("drains %s and its DELETE through the real service before switching account", async (waitPoint) => {
const f = fixture(waitPoint, waitPoint === "create" ? undefined : "org-origin")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
expect(f.changeScope).not.toHaveBeenCalled()
f.barrier.resolve()
await Promise.all([starting, switching])
const deletion = f.requests.filter((request) => request.method === "DELETE")
expect(deletion).toEqual([
{
method: "DELETE",
url: `${f.origin.endpoint}/api/v1/session/${f.record.id}`,
token: `Bearer ${f.origin.token}`,
body: undefined,
},
])
expect(
f.requests.every(
(request) => request.token === `Bearer ${f.origin.token}` && request.url.startsWith(f.origin.endpoint),
),
).toBe(true)
expect(JSON.parse(f.requests[0].body ?? "{}").organizationId).toBe(f.origin.organizationId)
expect(f.changeScope).toHaveBeenCalledOnce()
expect(f.host.send).not.toHaveBeenCalled()
expect(await f.coordinator.listHistoryRecords()).toMatchObject([{ sessionId: "ses-successor" }])
await f.coordinator.dispose()
})
it("keeps the original account on timeout and can explicitly retry after cleanup settles", async () => {
vi.useFakeTimers()
const f = fixture("create")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
const rejected = expect(switching).rejects.toThrow("Your account has not changed")
await vi.advanceTimersByTimeAsync(15_000)
await rejected
expect(f.scope).toBe(f.origin)
expect(f.changeScope).not.toHaveBeenCalled()
f.barrier.resolve()
await starting
await f.coordinator.reset(f.changeScope)
expect(f.requests.find((request) => request.method === "DELETE")?.token).toBe(`Bearer ${f.origin.token}`)
expect(f.changeScope).toHaveBeenCalledOnce()
await f.coordinator.dispose()
})
it("drops a cancelled start's record that History listed while it was provisioning", async () => {
const f = fixture("readiness")
f.setReadinessStatus("provisioning")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
// A state post during provisioning lists the account's sessions, which now
// include the record the create returned; the start has no entry for it yet.
await f.coordinator.listHistoryRecords()
f.options.invalidateHistoryCache.mockClear()
expect(f.coordinator.cancelPendingStart()).toBe(true)
f.barrier.resolve()
expect(await starting).toBe(f.record.id)
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(f.options.invalidateHistoryCache).toHaveBeenCalled()
expect(await f.coordinator.listHistoryRecords()).toEqual([])
await f.coordinator.dispose()
})
it("cancels a still-provisioning start instead of waiting for it to boot", async () => {
const f = fixture("readiness", "org-origin")
f.setReadinessStatus("provisioning")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
f.barrier.resolve()
// The user left the provisioning view for a local task before the change.
f.options.setTask({ taskId: "local-task" } as TaskProxy)
await f.coordinator.reset(f.changeScope)
expect(await starting).toBe(f.record.id)
expect(f.changeScope).toHaveBeenCalledOnce()
expect(f.requests.filter((request) => request.method === "DELETE")).toEqual([
expect.objectContaining({ token: `Bearer ${f.origin.token}` }),
])
await f.coordinator.dispose()
})
it("reports rejected cleanup once without exposing response text or retrying as the successor", async () => {
const f = fixture("create")
f.setDeletionStatus(401)
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
f.barrier.resolve()
await Promise.all([starting, switching])
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(HostProvider.window.showMessage).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ message: expect.stringContaining("account that started it") }),
)
expect(JSON.stringify(vi.mocked(HostProvider.window.showMessage).mock.calls)).not.toContain("private server detail")
expect(await f.coordinator.listHistoryRecords()).toMatchObject([{ sessionId: "ses-successor" }])
await f.coordinator.dispose()
})
it("has one cleanup owner when readiness fails after provisioning returns an id", async () => {
const f = fixture("readiness")
f.setReadinessStatus("failed")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
f.barrier.resolve()
await Promise.all([starting, switching])
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(HostProvider.window.showMessage).not.toHaveBeenCalled()
await f.coordinator.dispose()
})
it("keeps an ordinary successful start running rather than cleaning its sandbox", async () => {
const f = fixture("prompt")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
f.barrier.resolve()
expect(await starting).toBe(f.record.id)
expect(f.options.sessionConfigBuilder.build).toHaveBeenCalledWith({
cwd: "/workspace",
workspaceRoot: "/workspace",
mode: "act",
runtime: {
modelSelection: { providerId: "cline", modelId: "fixture-model" },
platform: "linux",
},
})
expect(f.host.start).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
systemPrompt: "normal Cline guidance",
mode: "act",
checkpoint: { enabled: false },
}),
}),
)
expect(f.host.send).toHaveBeenCalledOnce()
expect(f.requests.some((request) => request.method === "DELETE")).toBe(false)
await f.coordinator.reset(f.changeScope)
expect(f.requests.some((request) => request.method === "DELETE")).toBe(false)
await f.coordinator.dispose()
})
it("never sends the user's provider key or the local model catalog into the sandbox", async () => {
const f = fixture("prompt")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
f.barrier.resolve()
expect(await starting).toBe(f.record.id)
const config = vi.mocked(f.host.start).mock.calls[0]?.[0]?.config
expect(config?.providerConfig).toMatchObject({ providerId: "cline", modelId: "fixture-model" })
expect(JSON.stringify(config)).not.toContain("fixture-key")
expect(JSON.stringify(config)).not.toContain("catalog-model")
await f.coordinator.dispose()
})
it("includes explicit History deletion in the same account boundary", async () => {
const f = fixture("delete")
const deleting = f.coordinator.deleteSession(f.record.id)
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
expect(f.changeScope).not.toHaveBeenCalled()
f.barrier.resolve()
await Promise.all([deleting, switching])
expect(f.requests[0].token).toBe(`Bearer ${f.origin.token}`)
expect(f.changeScope).toHaveBeenCalledOnce()
await f.coordinator.dispose()
})
it.each(["lifecycle", "prompt"] as const)("cleans up a rejected %s wait without touching successor state", async (point) => {
const f = fixture(point)
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
f.barrier.reject(new Error("fixture failure"))
await Promise.all([starting, switching])
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(f.requests.every((request) => request.token === `Bearer ${f.origin.token}`)).toBe(true)
expect(await f.coordinator.listHistoryRecords()).toMatchObject([{ sessionId: "ses-successor" }])
await f.coordinator.dispose()
})
it("keeps old connectors enrolled after a timed-out transition and concurrent retry", async () => {
vi.useFakeTimers()
const f = fixture("connect")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const first = f.coordinator.reset(f.changeScope)
const rejected = expect(first).rejects.toThrow("Your account has not changed")
await vi.advanceTimersByTimeAsync(15_000)
await rejected
const second = f.coordinator.reset(f.changeScope)
f.barrier.resolve()
await Promise.all([starting, second])
expect(f.changeScope).toHaveBeenCalledOnce()
expect(f.requests.find((request) => request.method === "DELETE")?.token).toBe(`Bearer ${f.origin.token}`)
await f.coordinator.dispose()
})
it("cleans up an in-flight create during controller disposal", async () => {
const f = fixture("create")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const disposing = f.coordinator.dispose()
f.barrier.resolve()
await Promise.all([starting, disposing])
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(await f.coordinator.listHistoryRecords()).toEqual([])
})
it("delays successor starts until originating cleanup and account mutation finish", async () => {
const f = fixture("create")
const first = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
const second = f.coordinator.beginCloudTask(startInput)()
f.barrier.resolve()
await Promise.all([first, switching, second])
const posts = f.requests.filter((request) => request.method === "POST")
expect(posts.map((request) => request.token)).toEqual([`Bearer ${f.origin.token}`, `Bearer ${f.successor.token}`])
const deletedAt = f.requests.findIndex((request) => request.method === "DELETE")
const newPostAt = f.requests.findIndex(
(request) => request.method === "POST" && request.token === `Bearer ${f.successor.token}`,
)
expect(deletedAt).toBeLessThan(newPostAt)
expect(f.host.send).toHaveBeenCalledOnce()
await f.coordinator.dispose()
})
it("does not suppress a rejected explicit History deletion", async () => {
const f = fixture("delete")
f.setDeletionStatus(403)
const deleting = f.coordinator.deleteSession(f.record.id)
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
const rejected = expect(deleting).rejects.toMatchObject({ status: 403 })
f.barrier.resolve()
await Promise.all([rejected, switching])
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
await f.coordinator.dispose()
})
it("rejects deletion superseded before it can enter the account scope", async () => {
const f = fixture("delete")
const deleting = f.coordinator.deleteSession(f.record.id)
const rejected = expect(deleting).rejects.toThrow("account scope changed")
await Promise.all([rejected, f.coordinator.reset(f.changeScope)])
expect(f.requests).toEqual([])
await f.coordinator.dispose()
})
it("allows controller teardown to continue after its deadline without dropping late cleanup", async () => {
vi.useFakeTimers()
const f = fixture("create")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const disposing = f.coordinator.dispose()
await vi.advanceTimersByTimeAsync(15_000)
await disposing
f.barrier.resolve()
await starting
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(f.requests.every((request) => request.token === `Bearer ${f.origin.token}`)).toBe(true)
})
it("cleans ordinary pre-send failures and leaves a truthful error phase", async () => {
const f = fixture("prompt")
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
f.barrier.reject(new Error("cannot resolve prompt"))
expect(await starting).toBeUndefined()
expect(f.options.onStartFailed).toHaveBeenCalledOnce()
expect(f.options.onStartFailed).toHaveBeenCalledWith(expect.objectContaining({ taskId: f.record.id }), startInput)
expect(f.options.setTurnPhase).toHaveBeenLastCalledWith("error")
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
expect(f.lifecycle.getActiveSession()).toBeUndefined()
await f.coordinator.dispose()
})
it("keeps originating cleanup enrolled until its DELETE response settles", async () => {
const f = fixture("delete")
vi.spyOn(f.options, "resolveContextMentions").mockRejectedValue(new Error("prompt failure"))
const starting = f.coordinator.beginCloudTask(startInput)()
await f.entered.promise
const switching = f.coordinator.reset(f.changeScope)
expect(f.changeScope).not.toHaveBeenCalled()
f.barrier.resolve()
await Promise.all([starting, switching])
expect(f.changeScope).toHaveBeenCalledOnce()
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
await f.coordinator.dispose()
})
it("keeps service-owned readiness cleanup when no coordinator callback takes ownership", async () => {
const f = fixture("readiness")
f.setReadinessStatus("failed")
const creating = f.service.createSession({ modelId: "fixture-model", repoUrl: startInput.repoUrl })
const rejected = expect(creating).rejects.toMatchObject({ code: "session_failed" })
await f.entered.promise
f.barrier.resolve()
await rejected
expect(f.requests.filter((request) => request.method === "DELETE")).toHaveLength(1)
await f.coordinator.dispose()
})
})
@@ -0,0 +1,229 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { type CloudRepository, CloudSessionError } from "@/services/cloud/CloudSessionsService"
import {
LOOKUP_RETRY_MS,
REPOSITORIES_MAX_AGE_MS,
SdkCloudTaskTarget,
type SdkCloudTaskTargetOptions,
WORKSPACE_MAX_AGE_MS,
} from "./sdk-cloud-task-target"
const mocks = vi.hoisted(() => ({
workspaceDefaults: vi.fn<(cwd: string) => Promise<{ repoUrl?: string; branch?: string }>>(async () => ({})),
}))
vi.mock("@/services/cloud/workspace-cloud-defaults", () => ({
resolveWorkspaceCloudDefaults: (cwd: string) => mocks.workspaceDefaults(cwd),
}))
afterEach(() => {
vi.restoreAllMocks()
mocks.workspaceDefaults.mockReset()
mocks.workspaceDefaults.mockResolvedValue({})
})
const cline: CloudRepository = {
id: 7,
name: "cline",
fullName: "cline/cline",
url: "https://github.com/cline/cline",
defaultBranch: "main",
}
const other: CloudRepository = {
id: 9,
name: "other",
fullName: "acme/other",
url: "https://github.com/acme/other",
defaultBranch: "trunk",
}
/** In-memory global state; share one between targets to model one install seen by two accounts. */
function makeStateManager(globalState: Record<string, unknown> = {}) {
return {
getGlobalStateKey: (key: string) => globalState[key],
setGlobalState: (key: string, value: unknown) => {
globalState[key] = value
},
} as never
}
function makeTarget(overrides: Partial<SdkCloudTaskTargetOptions> = {}) {
const globalState: Record<string, unknown> = {}
const cloudSessions = {
getGitHubConnection: vi.fn(async () => ({ connected: true, connectUrl: "", repositories: [cline, other] })),
listBranches: vi.fn(async (_repositoryId: number, query?: string) => (query ? [query] : ["main"])),
githubConnectUrl: () => "https://app.test/connect",
}
let now = 1_000_000
const options: SdkCloudTaskTargetOptions = {
cloudSessions: cloudSessions as never,
stateManager: makeStateManager(globalState),
getAccountScope: () => "user-1:",
getWorkspaceRoot: () => "/workspace",
postStateToWebview: vi.fn(async () => undefined),
now: () => now,
...overrides,
}
return { target: new SdkCloudTaskTarget(options), cloudSessions, options, globalState, advance: (ms: number) => (now += ms) }
}
/** Waits for the background lookups a view() started to land. */
async function settle() {
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
describe("SdkCloudTaskTarget", () => {
it("stores only the user's choice and resolves the rest on read", async () => {
const { target, globalState } = makeTarget()
mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/x" })
await target.choose({ target: "cloud" })
expect(globalState.cloudTaskTarget).toEqual({ target: "cloud" })
// The first read starts the lookups; nothing is written. The branch
// check needs the repository list, so it starts on the read after that
// lands (each state post converges one step).
expect(target.view()).toEqual({ target: "cloud" })
await settle()
expect(target.view()).toMatchObject({ repositoryId: 7, branch: "main" })
await settle()
expect(globalState.cloudTaskTarget).toEqual({ target: "cloud" })
expect(target.view()).toEqual({
target: "cloud",
repositoryId: 7,
repoUrl: "https://github.com/cline/cline",
branch: "feature/x",
workspaceRepoUrl: "https://github.com/cline/cline",
})
})
it("keeps Local when a lookup lands after the user chose it", async () => {
const { target, cloudSessions, options } = makeTarget()
let release!: () => void
cloudSessions.getGitHubConnection.mockReturnValue(
new Promise((resolve) => {
release = () => resolve({ connected: true, connectUrl: "", repositories: [cline] })
}),
)
await target.choose({ target: "cloud" })
target.view()
await target.choose({ target: "local" })
release()
await settle()
expect(target.view()).toEqual({ target: "local" })
// The landed lookup posted state, and that state carries Local.
expect(options.postStateToWebview).toHaveBeenCalled()
})
it("does not select a repository another account chose, nor carry its branch over", async () => {
const globalState: Record<string, unknown> = {}
const first = makeTarget({ stateManager: makeStateManager(globalState) })
await first.target.choose({ repositoryId: other.id })
await first.target.choose({ branch: "release" })
first.target.view()
await settle()
expect(first.target.view()).toMatchObject({ repositoryId: other.id, branch: "release" })
// Same install, another account: only cline/cline is reachable.
const second = makeTarget({ stateManager: makeStateManager(globalState), getAccountScope: () => "user-2:" })
second.cloudSessions.getGitHubConnection.mockResolvedValue({ connected: true, connectUrl: "", repositories: [cline] })
second.target.view()
await settle()
expect(second.target.view()).toMatchObject({ repositoryId: cline.id, branch: "main" })
expect(globalState.cloudTaskTarget).toEqual({ target: "cloud", repositoryId: other.id, branch: "release" })
})
it("suggests the workspace branch only once GitHub confirms it, and retries a failed check later", async () => {
const { target, cloudSessions, advance } = makeTarget()
mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/gone" })
cloudSessions.listBranches.mockRejectedValueOnce(new Error("offline"))
await target.choose({ target: "cloud" })
target.view()
await settle()
expect(target.view().branch).toBe("main")
expect(cloudSessions.listBranches).toHaveBeenCalledTimes(1)
// Too soon: the failed check is left alone.
target.view()
await settle()
expect(cloudSessions.listBranches).toHaveBeenCalledTimes(1)
advance(LOOKUP_RETRY_MS)
cloudSessions.listBranches.mockResolvedValueOnce([])
target.view()
await settle()
expect(cloudSessions.listBranches).toHaveBeenCalledTimes(2)
expect(cloudSessions.listBranches).toHaveBeenLastCalledWith(7, "feature/gone")
// GitHub no longer has it, whatever the local remote-tracking ref says.
expect(target.view().branch).toBe("main")
})
it("attaches a chosen branch to the repository the view resolved", async () => {
const { target } = makeTarget()
mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/acme/other" })
await target.choose({ target: "cloud" })
target.view()
await settle()
expect(target.view()).toMatchObject({ repositoryId: other.id, branch: "trunk" })
await target.choose({ branch: "hotfix" })
expect(target.view()).toMatchObject({ repositoryId: other.id, branch: "hotfix" })
})
it("reports the connection per account and refreshes on request", async () => {
const { target, cloudSessions } = makeTarget()
expect(await target.connectionStatus()).toMatchObject({ signedIn: true, connected: true })
await target.connectionStatus()
expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(1)
await target.connectionStatus(true)
expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(2)
cloudSessions.getGitHubConnection.mockRejectedValueOnce(new CloudSessionError("authentication_required", "expired"))
expect(await target.connectionStatus(true)).toMatchObject({ signedIn: false, connectUrl: "https://app.test/connect" })
})
it("re-reads repositories and the workspace checkout once they age, keeping the old answer meanwhile", async () => {
const { target, cloudSessions, advance } = makeTarget()
mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/a" })
await target.choose({ target: "cloud" })
target.view()
await settle()
target.view()
await settle()
expect(target.view()).toMatchObject({ repositoryId: 7, branch: "feature/a" })
expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(1)
expect(mocks.workspaceDefaults).toHaveBeenCalledTimes(1)
// The user checks out another branch in the terminal and revokes the
// GitHub App's access to the repository in the browser.
mocks.workspaceDefaults.mockResolvedValue({ repoUrl: "https://github.com/cline/cline", branch: "feature/b" })
cloudSessions.getGitHubConnection.mockResolvedValue({ connected: true, connectUrl: "", repositories: [other] })
advance(WORKSPACE_MAX_AGE_MS)
// The aged read still answers from cache and reloads behind it.
expect(target.view()).toMatchObject({ repositoryId: 7, branch: "feature/a" })
await settle()
target.view()
await settle()
expect(target.view()).toMatchObject({ repositoryId: 7, branch: "feature/b" })
expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(1)
advance(REPOSITORIES_MAX_AGE_MS)
target.view()
await settle()
expect(cloudSessions.getGitHubConnection).toHaveBeenCalledTimes(2)
expect(target.view()).toMatchObject({ repositoryId: other.id, branch: "trunk" })
})
it("is Local with no lookups when the user has not chosen Cloud", async () => {
const { target, cloudSessions } = makeTarget()
expect(target.view()).toEqual({ target: "local" })
await settle()
expect(cloudSessions.getGitHubConnection).not.toHaveBeenCalled()
expect(mocks.workspaceDefaults).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,268 @@
// SdkCloudTaskTarget — the one writer and the one reader of "where does the
// next task run".
//
// The user makes three kinds of choice (Local/Cloud, a repository, a branch)
// and each is stored as itself. Everything else the panel shows and the
// composer submits — which repository the account can actually reach, which
// branch to start on — is resolved here from the stored choice, the active
// account's repositories and the workspace's git remote, on every state post.
// Nothing derived is ever written back, so no background lookup can overwrite
// a choice the user made while it was in flight.
import { type CloudTaskTargetSelection, type CloudTaskTargetView, normalizeGitHubRemoteUrl } from "@shared/cloud/cloud-sessions"
import type { StateManager } from "@/core/storage/StateManager"
import {
type CloudRepository,
CloudSessionError,
type CloudSessionsService,
type GitHubConnectionResult,
} from "@/services/cloud/CloudSessionsService"
import { resolveWorkspaceCloudDefaults } from "@/services/cloud/workspace-cloud-defaults"
/** GitHub App connection for the active account, or `signedIn: false` when there is none. */
export interface GitHubConnectionStatus extends GitHubConnectionResult {
signedIn: boolean
/** Set when the request failed (network / API error); other fields are then unreliable. */
error?: string
}
export type CloudTaskTargetChoice = { target: "local" | "cloud" } | { repositoryId: number } | { branch: string }
export interface SdkCloudTaskTargetOptions {
cloudSessions: CloudSessionsService
stateManager: StateManager
/** Identity of the active account (user and organization), or undefined when signed out. */
getAccountScope: () => string | undefined
/** Primary workspace root as last observed; undefined without a workspace. */
getWorkspaceRoot: () => string | undefined
postStateToWebview: () => Promise<void>
now?: () => number
}
/** How long a failed lookup is left alone before a read retries it. */
export const LOOKUP_RETRY_MS = 30_000
/**
* How long a loaded input is trusted before a read re-asks in the background.
* Repository access changes in the browser (GitHub App install page) and the
* git checkout changes in the terminal; neither notifies the extension, so a
* read older than this refreshes and the next state post carries the answer.
*/
export const REPOSITORIES_MAX_AGE_MS = 60_000
export const WORKSPACE_MAX_AGE_MS = 10_000
interface WorkspaceDefaults {
repoUrl?: string
branch?: string
}
/**
* One input to the resolved target, cached under the key it was loaded for
* (account scope, workspace root, repository+branch). A read under a new
* key discards the old value and starts a load; a load whose slot has been
* replaced meanwhile is dropped, by identity, not by key.
*/
class Lookup<T> {
private key: string | undefined
private value: T | undefined
private loadedAt: number | undefined
private pending: Promise<void> | undefined
private failedAt: number | undefined
/**
* @param maxAgeMs how long a loaded value is trusted; a read after that
* returns the old value and reloads in the background. Omit for inputs that
* only change with the key.
*/
constructor(
private readonly onLoaded: () => void,
private readonly now: () => number,
private readonly maxAgeMs?: number,
) {}
/** The cached value for `key`, starting a load in the background when there is none or it has aged out. */
read(key: string, load: () => Promise<T>): T | undefined {
this.start(key, load, false)
return this.value
}
/** The value for `key`, loaded now when there is none; `refresh` discards the cached one first. */
async get(key: string, load: () => Promise<T>, refresh: boolean): Promise<T | undefined> {
await this.start(key, load, refresh)
return this.value
}
private start(key: string, load: () => Promise<T>, refresh: boolean): Promise<void> {
if (this.key !== key || refresh) {
this.key = key
this.value = undefined
this.loadedAt = undefined
this.pending = undefined
this.failedAt = undefined
}
if (this.pending) {
return this.pending
}
if (this.value !== undefined && !this.aged()) {
return Promise.resolve()
}
if (this.failedAt !== undefined && this.now() - this.failedAt < LOOKUP_RETRY_MS) {
return Promise.resolve()
}
const pending: Promise<void> = load().then(
(value) => {
if (this.pending !== pending) {
return
}
this.pending = undefined
this.value = value
this.loadedAt = this.now()
this.onLoaded()
},
() => {
if (this.pending !== pending) {
return
}
this.pending = undefined
this.failedAt = this.now()
},
)
this.pending = pending
return pending
}
private aged(): boolean {
return this.maxAgeMs !== undefined && this.loadedAt !== undefined && this.now() - this.loadedAt >= this.maxAgeMs
}
}
export class SdkCloudTaskTarget {
private readonly connection: Lookup<GitHubConnectionStatus>
private readonly workspace: Lookup<WorkspaceDefaults>
private readonly workspaceBranch: Lookup<boolean>
constructor(private readonly options: SdkCloudTaskTargetOptions) {
// A landed input posts state so the panel and composer see the new view.
const onLoaded = () => void options.postStateToWebview().catch(() => {})
const now = () => options.now?.() ?? Date.now()
this.connection = new Lookup(onLoaded, now, REPOSITORIES_MAX_AGE_MS)
this.workspace = new Lookup(onLoaded, now, WORKSPACE_MAX_AGE_MS)
// A branch either exists on GitHub or it does not; a stale "yes" fails at
// provisioning with a clear error, and the key changes with the checkout.
this.workspaceBranch = new Lookup(onLoaded, now)
}
// ---- Writes: one field per user choice ----
async choose(choice: CloudTaskTargetChoice): Promise<void> {
const current = this.selection()
let next: CloudTaskTargetSelection
if ("target" in choice) {
next = { ...current, target: choice.target }
} else if ("repositoryId" in choice) {
// A branch belongs to the repository it was chosen for.
next = { target: "cloud", repositoryId: choice.repositoryId }
} else {
// The branch attaches to the repository the user is looking at, which
// the view resolved and the stored selection may not name.
next = {
target: "cloud",
repositoryId: this.view().repositoryId ?? current.repositoryId,
branch: choice.branch.trim() || undefined,
}
}
this.options.stateManager.setGlobalState("cloudTaskTarget", next)
await this.options.postStateToWebview()
}
private selection(): CloudTaskTargetSelection {
return this.options.stateManager.getGlobalStateKey("cloudTaskTarget") ?? { target: "local" }
}
// ---- Reads: the resolved target, from whatever inputs are known now ----
/**
* The target as it stands. An input not yet known is requested in the
* background and the state is posted again when it arrives, so the view
* converges without anything being written. Local starts no lookups.
*/
view(): CloudTaskTargetView {
const selection = this.selection()
if (selection.target !== "cloud") {
return { target: "local" }
}
const workspace = this.workspaceDefaults()
const view: CloudTaskTargetView = { target: "cloud", workspaceRepoUrl: workspace?.repoUrl }
const repositories = this.cachedConnection()?.repositories ?? []
const repository =
repositories.find((repo) => repo.id === selection.repositoryId) ??
repositories.find((repo) => this.isWorkspaceRepository(repo, workspace)) ??
repositories[0]
if (!repository) {
return view
}
view.repositoryId = repository.id
view.repoUrl = normalizeGitHubRemoteUrl(repository.url) ?? repository.url
const chosenBranch = selection.repositoryId === repository.id ? selection.branch : undefined
const workspaceBranch =
workspace?.branch && this.isWorkspaceRepository(repository, workspace)
? this.confirmedWorkspaceBranch(repository, workspace.branch)
: undefined
view.branch = chosenBranch ?? workspaceBranch ?? repository.defaultBranch ?? undefined
return view
}
private isWorkspaceRepository(repo: CloudRepository, workspace: WorkspaceDefaults | undefined): boolean {
return !!workspace?.repoUrl && normalizeGitHubRemoteUrl(repo.url) === workspace.repoUrl
}
// ---- Inputs, each cached under the key it depends on ----
/**
* The active account's GitHub App connection and repositories, for the
* Account view and the onboarding card. `refresh` re-asks the control plane.
* The cache is keyed by account scope, so another account's repositories
* are never returned as this one's.
*/
async connectionStatus(refresh = false): Promise<GitHubConnectionStatus> {
const scope = this.options.getAccountScope()
const status = scope && (await this.connection.get(scope, () => this.fetchConnection(), refresh))
return status || this.signedOut()
}
private cachedConnection(): GitHubConnectionStatus | undefined {
const scope = this.options.getAccountScope()
return scope ? this.connection.read(scope, () => this.fetchConnection()) : undefined
}
private async fetchConnection(): Promise<GitHubConnectionStatus> {
try {
return { signedIn: true, ...(await this.options.cloudSessions.getGitHubConnection()) }
} catch (error) {
if (error instanceof CloudSessionError && error.code === "authentication_required") {
return this.signedOut()
}
return { ...this.signedOut(), signedIn: true, error: error instanceof Error ? error.message : String(error) }
}
}
private signedOut(): GitHubConnectionStatus {
return { signedIn: false, connected: false, connectUrl: this.options.cloudSessions.githubConnectUrl(), repositories: [] }
}
private workspaceDefaults(): WorkspaceDefaults | undefined {
const cwd = this.options.getWorkspaceRoot()
return cwd ? this.workspace.read(cwd, () => resolveWorkspaceCloudDefaults(cwd)) : undefined
}
/**
* The workspace branch, once GitHub confirms `repository` has it. A local
* remote-tracking ref can outlive the branch on GitHub, so GitHub is asked
* by name rather than trusting the ref or the first page of branches.
*/
private confirmedWorkspaceBranch(repository: CloudRepository, branch: string): string | undefined {
const exists = this.workspaceBranch.read(`${repository.id}:${branch}`, async () =>
(await this.options.cloudSessions.listBranches(repository.id, branch)).includes(branch),
)
return exists ? branch : undefined
}
}
@@ -34,9 +34,13 @@ describe("SdkFollowupCoordinator", () => {
const { coordinator, options } = makeCoordinator()
options.interactions.resolvePendingAskQuestion.mockReturnValue(true)
await coordinator.askResponse("answer")
await coordinator.askResponse("answer", ["data:image/png;base64,abc"], ["/workspace/spec.md"])
expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith("answer")
expect(options.interactions.resolvePendingAskQuestion).toHaveBeenCalledWith(
"answer",
["data:image/png;base64,abc"],
["/workspace/spec.md"],
)
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
})
@@ -73,7 +73,7 @@ export class SdkFollowupCoordinator {
return
}
if (this.options.interactions.resolvePendingAskQuestion(prompt)) {
if (this.options.interactions.resolvePendingAskQuestion(prompt, images, files)) {
return
}
@@ -1,5 +1,6 @@
import type { AgentEvent } from "@cline/shared"
import { describe, expect, it, vi } from "vitest"
import { processFilesIntoText } from "@/integrations/misc/extract-text"
import { MessageTranslatorState, translateSessionEvent } from "./message-translator"
import { SdkInteractionCoordinator, type ToolApprovalRequest } from "./sdk-interaction-coordinator"
import { SdkMessageCoordinator } from "./sdk-message-coordinator"
@@ -14,6 +15,10 @@ vi.mock("@core/storage/disk", () => ({
saveClineMessages: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("@/integrations/misc/extract-text", () => ({
processFilesIntoText: vi.fn(async (files: string[]) => `Files attached by the user:\n\n${files.join("\n")}`),
}))
describe("SdkInteractionCoordinator", () => {
it("emits a tool approval ask and resolves approval from askResponse state", async () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
@@ -358,6 +363,59 @@ describe("SdkInteractionCoordinator", () => {
])
})
it.each([
{ prompt: "Use this design", expectedText: [{ type: "text", text: "Use this design" }] },
{ prompt: undefined, expectedText: [] },
])("resolves ask_question with attached images and files: $prompt", async ({ prompt, expectedText }) => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const coordinator = new SdkInteractionCoordinator({
messages: new SdkMessageCoordinator({ getTask: () => task }),
getSessionId: () => "session-123",
postStateToWebview: vi.fn().mockResolvedValue(undefined),
})
const images = ["data:image/jpeg;base64,abc"]
const files = ["/workspace/spec.md"]
const answerPromise = coordinator.handleAskQuestion("Which layout?", ["A", "B"], undefined)
await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1))
expect(coordinator.resolvePendingAskQuestion(prompt, images, files)).toBe(true)
await expect(answerPromise).resolves.toEqual([
...expectedText,
{ type: "image", mediaType: "image/jpeg", data: "abc" },
{ type: "text", text: "Files attached by the user:\n\n/workspace/spec.md" },
])
expect(task.messageStateHandler.getClineMessages()).toMatchObject([
{ type: "ask", ask: "followup" },
{ type: "say", say: "user_feedback", text: prompt ?? "", images, files },
])
})
it("settles an answered ask_question on cancel while its attachments are still loading", async () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const coordinator = new SdkInteractionCoordinator({
messages: new SdkMessageCoordinator({ getTask: () => task }),
getSessionId: () => "session-123",
postStateToWebview: vi.fn().mockResolvedValue(undefined),
})
let finishLoading!: (text: string) => void
vi.mocked(processFilesIntoText).mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
finishLoading = resolve
}),
)
const answerPromise = coordinator.handleAskQuestion("Which spec?", ["A", "B"], undefined)
await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1))
expect(coordinator.resolvePendingAskQuestion("Use this spec", undefined, ["/workspace/huge.pdf"])).toBe(true)
coordinator.clearPending("Cancelled")
await expect(answerPromise).resolves.toBe("")
finishLoading("Files attached by the user:\n\n/workspace/huge.pdf")
await expect(answerPromise).resolves.toBe("")
})
it("shows an error row and stops immediately when the mistake limit is reached", async () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const setTurnPhase = vi.fn()
@@ -1,6 +1,8 @@
import type { ConsecutiveMistakeLimitContext, ConsecutiveMistakeLimitDecision } from "@cline/shared"
import type { AskQuestionExecutor } from "@cline/core"
import type { ConsecutiveMistakeLimitContext, ConsecutiveMistakeLimitDecision, ImageContent, TextContent } from "@cline/shared"
import type { ClineAskQuestion, ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import { processFilesIntoText } from "@/integrations/misc/extract-text"
import { Logger } from "@/shared/services/Logger"
import { MessageIdMinter } from "./message-id-minter"
import { buildToolApprovalAskMessage } from "./message-translator"
@@ -9,6 +11,24 @@ import { buildToolApprovalDenialReason } from "./tool-approval-denial"
const TOOL_APPROVAL_DELIVERY_FAILED_REASON = "The tool approval request could not be displayed, so the tool was not executed."
type AskQuestionAnswer = Awaited<ReturnType<AskQuestionExecutor>>
async function buildAnswerWithAttachments(
text: string,
images: string[] = [],
files: string[] = [],
): Promise<Array<TextContent | ImageContent>> {
const filesText = files.length > 0 ? await processFilesIntoText(files) : ""
return [
...(text ? [{ type: "text" as const, text }] : []),
...images.map((dataUrl): ImageContent => {
const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/)
return { type: "image", mediaType: match?.[1] ?? "image/png", data: match?.[2] ?? dataUrl }
}),
...(filesText ? [{ type: "text" as const, text: filesText }] : []),
]
}
export interface ToolApprovalRequest {
agentId: string
conversationId: string
@@ -52,7 +72,9 @@ export interface SdkInteractionCoordinatorOptions {
}
export class SdkInteractionCoordinator {
private pendingAskResolve: ((answer: string) => void) | undefined
private pendingAskResolve: ((answer: AskQuestionAnswer) => void) | undefined
// An answered question whose attachments are still loading; cancellation must still settle it.
private loadingAskResolve: ((answer: AskQuestionAnswer) => void) | undefined
private pendingToolApprovalResolve: ((result: { approved: boolean; reason?: string }) => void) | undefined
private pendingToolApprovalMessage:
| {
@@ -152,7 +174,7 @@ export class SdkInteractionCoordinator {
return approval
}
async handleAskQuestion(question: string, options: string[], _context: unknown): Promise<string> {
async handleAskQuestion(question: string, options: string[], _context: unknown): Promise<AskQuestionAnswer> {
const askData: ClineAskQuestion = {
question,
options: options?.length ? options : undefined,
@@ -172,7 +194,7 @@ export class SdkInteractionCoordinator {
this.options.setTurnPhase?.("awaiting_followup", askMessage.ts)
await this.options.postStateToWebview()
return new Promise<string>((resolve) => {
return new Promise<AskQuestionAnswer>((resolve) => {
this.pendingAskResolve = resolve
})
}
@@ -237,7 +259,7 @@ export class SdkInteractionCoordinator {
return true
}
resolvePendingAskQuestion(prompt: string | undefined): boolean {
resolvePendingAskQuestion(prompt: string | undefined, images?: string[], files?: string[]): boolean {
if (!this.pendingAskResolve) {
return false
}
@@ -245,14 +267,17 @@ export class SdkInteractionCoordinator {
const resolve = this.pendingAskResolve
this.pendingAskResolve = undefined
const responseText = prompt ?? ""
const hasAttachments = !!(images?.length || files?.length)
Logger.log(`[SdkController] Resolving pending ask_question with: "${responseText.substring(0, 80)}"`)
if (responseText) {
if (responseText || hasAttachments) {
const userMessage: ClineMessage = {
ts: this.nextMessageTs(),
type: "say",
say: "user_feedback",
text: responseText,
images,
files,
partial: false,
}
this.options.messages.appendAndEmit([userMessage], {
@@ -263,17 +288,37 @@ export class SdkInteractionCoordinator {
// User answered the follow-up — the agent resumes its turn.
this.options.setTurnPhase?.("streaming")
resolve(responseText)
if (!hasAttachments) {
resolve(responseText)
return true
}
this.loadingAskResolve = resolve
void buildAnswerWithAttachments(responseText, images, files)
.catch((error) => {
Logger.error(`[SdkController] Failed to load ask_question attachments: ${error}`)
return responseText
})
.then((answer) => {
if (this.loadingAskResolve === resolve) {
this.loadingAskResolve = undefined
}
// No-op if clearPending already settled this question.
resolve(answer)
})
return true
}
clearPending(reason: string): void {
const resolveAsk = this.pendingAskResolve
const resolveLoadingAsk = this.loadingAskResolve
this.pendingAskResolve = undefined
this.loadingAskResolve = undefined
// ask_question is awaiting this promise inside the outgoing agent run. Settle it
// before session teardown so the run can unwind instead of remaining suspended;
// use an empty answer so the lifecycle reason is not presented as user input.
resolveAsk?.("")
resolveLoadingAsk?.("")
this.denyPendingToolApproval(reason)
}
@@ -117,6 +117,11 @@ export class SdkModeCoordinator {
}
const activeSession = this.options.sessions.getActiveSession()
// Cloud sessions are Act-only; the webview disables the toggle, this
// guards the keyboard shortcut and any other caller.
if (activeSession?.sdkHost && "isCloud" in activeSession.sdkHost) {
return false
}
if (activeSession) {
// awaiting_followup is also used for non-plan turns, so it is not
// sufficient evidence that the user has a plan to approve. Require the
@@ -41,6 +41,18 @@ describe("SdkSessionLifecycle", () => {
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
})
it("does not start a host when the caller is superseded", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-stale" } })
const lifecycle = makeLifecycle()
await expect(lifecycle.startNewSession({} as StartInput, sdkHost as never, () => false)).rejects.toMatchObject({
name: "SessionStartSupersededError",
})
expect(sdkHost.start).not.toHaveBeenCalled()
expect(lifecycle.getActiveSession()).toBeUndefined()
})
it("stores the provider and model config used to start the active session", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
+76 -3
View File
@@ -163,7 +163,18 @@ export class SdkSessionLifecycle {
async startNewSession(
startInput: Parameters<VscodeSessionHost["start"]>[0],
/**
* Session-scoped host (e.g. a CloudSessionHost) to start on instead of the
* shared local host. Its events are routed to onSessionEvent for as long
* as the session is active; tool policies are left to the host.
*/
hostOverride?: SdkSessionHost,
/** Rechecked at every suspension point so a stale start cannot replace its successor. */
shouldContinue: () => boolean = () => true,
): Promise<{ startResult: StartSessionResult; sdkHost: SdkSessionHost }> {
if (!shouldContinue()) {
throw new SessionStartSupersededError()
}
if (this.activeSession) {
await this.endActiveSession("startNewSession")
}
@@ -174,16 +185,27 @@ export class SdkSessionLifecycle {
if (requestedSessionId) {
await this.waitForPendingStop(requestedSessionId)
}
if (!shouldContinue()) {
throw new SessionStartSupersededError()
}
const autoApprovalSettings = StateManager.get().getGlobalSettingsKey("autoApprovalSettings")
const toolPolicies = autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined
const toolPolicies =
!hostOverride && autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined
const sdkHost = await this.getOrCreateSharedHost()
const sdkHost = hostOverride ?? (await this.getOrCreateSharedHost())
if (!shouldContinue()) {
throw new SessionStartSupersededError()
}
const startResult = await sdkHost.start({
...startInput,
...(toolPolicies ? { toolPolicies } : {}),
})
if (!shouldContinue()) {
await sdkHost.stop(startResult.sessionId)
throw new SessionStartSupersededError()
}
this.activeSession = {
sessionId: startResult.sessionId,
startConfig: startInput.config
@@ -193,7 +215,7 @@ export class SdkSessionLifecycle {
}
: undefined,
sdkHost,
unsubscribe: () => {},
unsubscribe: hostOverride ? this.subscribeSessionHost(hostOverride) : () => {},
startResult,
isRunning: true,
queuedPromptCount: 0,
@@ -202,6 +224,50 @@ export class SdkSessionLifecycle {
return { startResult, sdkHost }
}
/**
* Makes an already-running conversation on a session-scoped host the active
* session without starting a new one (e.g. reopening a cloud session that
* keeps running in its sandbox). Events flow to onSessionEvent until the
* session is ended; the host itself is not stopped when it is.
*/
async attachExistingSession(input: {
sdkHost: SdkSessionHost
sessionId: string
startConfig?: ActiveSession["startConfig"]
isRunning: boolean
shouldContinue?: () => boolean
}): Promise<ActiveSession> {
if (input.shouldContinue?.() === false) {
throw new SessionStartSupersededError()
}
if (this.activeSession) {
await this.endActiveSession("attachExistingSession")
}
if (input.shouldContinue?.() === false) {
throw new SessionStartSupersededError()
}
const session: ActiveSession = {
sessionId: input.sessionId,
startConfig: input.startConfig,
sdkHost: input.sdkHost,
unsubscribe: this.subscribeSessionHost(input.sdkHost),
isRunning: input.isRunning,
queuedPromptCount: 0,
}
this.activeSession = session
return session
}
async endActiveSessionIfHost(host: SdkSessionHost, reason: string): Promise<void> {
if (this.activeSession?.sdkHost === host) {
await this.endActiveSession(reason)
}
}
private subscribeSessionHost(sdkHost: SdkSessionHost): () => void {
return this.createSafeUnsubscribe(sdkHost.subscribe(this.options.onSessionEvent), "session-host")
}
async replaceActiveSession(options: {
expectedSession: ActiveSession
startInput: Parameters<VscodeSessionHost["start"]>[0]
@@ -451,6 +517,13 @@ export class SdkSessionLifecycle {
}
}
export class SessionStartSupersededError extends Error {
constructor() {
super("Session start was superseded")
this.name = "SessionStartSupersededError"
}
}
export function isAbortError(error: unknown): boolean {
if (error instanceof Error) {
return error.name === "AbortError" || error.message.toLowerCase().includes("aborted")

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