Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b45fbbcc29 | |||
| c80f4d859d |
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
|
||||
|
||||
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases ship two 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. 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 are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. 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
|
||||
|
||||
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
|
||||
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
@@ -120,7 +120,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 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, signs the updater artifact with the Tauri updater key, 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.
|
||||
|
||||
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
|
||||
```
|
||||
|
||||
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ body:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
- Desktop App
|
||||
- Cloud Platform
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
@@ -64,15 +62,13 @@ body:
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
- Desktop App: paste the app version from the Settings view.
|
||||
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
|
||||
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
name: Sign Windows CLI binaries
|
||||
description: >
|
||||
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
|
||||
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
|
||||
signatures. If the Azure Trusted Signing secrets are not configured, the
|
||||
action logs a warning and exits successfully so releases keep working while
|
||||
signing infrastructure is being provisioned.
|
||||
|
||||
inputs:
|
||||
azure-client-id:
|
||||
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
|
||||
required: false
|
||||
default: ""
|
||||
azure-tenant-id:
|
||||
description: Entra tenant ID.
|
||||
required: false
|
||||
default: ""
|
||||
azure-subscription-id:
|
||||
description: Azure subscription ID containing the Trusted Signing account.
|
||||
required: false
|
||||
default: ""
|
||||
endpoint:
|
||||
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
|
||||
required: false
|
||||
default: ""
|
||||
account:
|
||||
description: Trusted Signing account name.
|
||||
required: false
|
||||
default: ""
|
||||
certificate-profile:
|
||||
description: Trusted Signing certificate profile name.
|
||||
required: false
|
||||
default: ""
|
||||
files:
|
||||
description: Newline-separated list of PE files to sign.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check signing configuration
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
|
||||
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
run: |
|
||||
missing=()
|
||||
set_count=0
|
||||
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
|
||||
if [ -z "${!var}" ]; then
|
||||
missing+=("$var")
|
||||
else
|
||||
set_count=$((set_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -eq 0 ]; then
|
||||
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$set_count" -eq 0 ]; then
|
||||
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Partial configuration is almost certainly a typo'd or renamed
|
||||
# secret. Fail loudly instead of silently publishing unsigned.
|
||||
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
FILES: ${{ inputs.files }}
|
||||
JSIGN_VERSION: "7.5"
|
||||
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
|
||||
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
|
||||
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
|
||||
|
||||
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
|
||||
echo "::add-mask::${JSIGN_STOREPASS}"
|
||||
export JSIGN_STOREPASS
|
||||
|
||||
# jsign expects the endpoint host, not the URL. Tolerate both the
|
||||
# portal's display form (trailing slash) and the bare form.
|
||||
KEYSTORE="${SIGNING_ENDPOINT#https://}"
|
||||
KEYSTORE="${KEYSTORE%/}"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Signing ${file}"
|
||||
java -jar "$JSIGN_JAR" \
|
||||
--storetype TRUSTEDSIGNING \
|
||||
--keystore "$KEYSTORE" \
|
||||
--storepass env:JSIGN_STOREPASS \
|
||||
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
|
||||
--alg SHA-256 \
|
||||
--tsaurl http://timestamp.acs.microsoft.com \
|
||||
--tsmode RFC3161 \
|
||||
--replace \
|
||||
"$file"
|
||||
done <<< "$FILES"
|
||||
|
||||
- name: Verify signatures
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
FILES: ${{ inputs.files }}
|
||||
# Authenticode chains anchor to the Microsoft Identity Verification
|
||||
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
|
||||
# explicitly (pinned) for osslsigncode chain validation.
|
||||
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
|
||||
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v osslsigncode >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq osslsigncode
|
||||
fi
|
||||
|
||||
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
|
||||
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
|
||||
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
|
||||
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
|
||||
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Verifying signature on ${file}"
|
||||
# Timestamp countersignature chain is checked separately by Windows;
|
||||
# -ignore-timestamp only skips TSA chain validation here, not the
|
||||
# Authenticode chain itself.
|
||||
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
|
||||
done <<< "$FILES"
|
||||
@@ -190,19 +190,6 @@ jobs:
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
@@ -219,8 +206,6 @@ jobs:
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
|
||||
@@ -228,32 +213,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
|
||||
# and link out to the full notes. The GitHub release body stays whole.
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -289,7 +248,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
@@ -460,20 +419,6 @@ jobs:
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=main
|
||||
FEED=desktop-latest
|
||||
PRODUCT="Cline"
|
||||
PRODUCT="Cline Code"
|
||||
;;
|
||||
beta)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=desktop-experimental
|
||||
FEED=desktop-beta
|
||||
PRODUCT="Cline Beta"
|
||||
PRODUCT="Cline Code Beta"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
# "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
@@ -462,282 +462,9 @@ jobs:
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
needs: validate
|
||||
# Same gate rationale as the macOS build job above. This job additionally
|
||||
# needs id-token: write for Azure OIDC: Windows binaries are
|
||||
# Authenticode-signed with Azure Trusted Signing, authenticated through the
|
||||
# PublishDesktop-environment federated credential on the cline-cli-signing
|
||||
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# All-or-nothing: an unsigned Windows desktop build is never acceptable
|
||||
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
|
||||
# unsigned installers), and Tauri would skip updater-artifact signing
|
||||
# silently if the updater key were missing. Unlike the CLI pipeline
|
||||
# there is no unsigned fallback here.
|
||||
- name: Verify signing secrets are present
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing signing secrets for the Windows desktop build:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "The AZURE_* names are repository secrets; the TAURI_* names"
|
||||
echo "live in the PublishDesktop environment. Refusing to build an"
|
||||
echo "unsigned Windows desktop release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All Windows signing secrets are present."
|
||||
|
||||
# Every action in this job is SHA-pinned (unlike elsewhere in this
|
||||
# file): they run with id-token: write and the updater signing key in
|
||||
# scope, so a hijacked upstream tag must not be able to reach the
|
||||
# signing identity or tamper with what gets signed and uploaded.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
|
||||
with:
|
||||
# With a SHA-pinned action the toolchain no longer comes from the
|
||||
# ref name, so it must be set explicitly.
|
||||
toolchain: stable
|
||||
|
||||
# No Rust build cache, mirroring the macOS job: this job holds the
|
||||
# updater signing key and an Azure signing session, and a restored cache
|
||||
# archive is attacker-controlled if the Actions cache is poisoned.
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
|
||||
# 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.
|
||||
- name: Write signing config overlay
|
||||
shell: bash
|
||||
run: |
|
||||
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
|
||||
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
|
||||
cat > "$SIGN_CONF" <<EOF
|
||||
{
|
||||
"\$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"windows": {
|
||||
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat "$SIGN_CONF"
|
||||
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sign desktop bundle
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
# NSIS only: the MSI (WiX) target adds nothing for direct-download
|
||||
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
|
||||
env:
|
||||
CONFIG_ARGS: ${{ 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_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
|
||||
# Azure Trusted Signing; the token comes from the azure/login session)
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
# Updater artifact signing (minisign keypair, same key as macOS)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Same guardrail as the macOS job: assert the compiled binary embeds
|
||||
# this channel's updater feed URL and not the other channel's. Checked
|
||||
# on the unbundled main exe because NSIS compresses the installer
|
||||
# contents, which defeats a string search on the installer itself.
|
||||
- name: Verify updater feed endpoint
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
found=0
|
||||
for bin in src-tauri/target/release/*.exe; do
|
||||
if grep -a "$FORBID" "$bin" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if grep -a "$WANT" "$bin" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "No exe in src-tauri/target/release embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
|
||||
# Same guardrail as the macOS job, run natively on the Windows sidecar.
|
||||
- name: Verify sidecar telemetry config was 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
|
||||
|
||||
- name: Collect artifacts
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
|
||||
if [ -z "$SETUP" ]; then
|
||||
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
|
||||
exit 1
|
||||
fi
|
||||
# The .sig is the updater (minisign) signature; without it the
|
||||
# manifest generator cannot publish a windows-x86_64 entry.
|
||||
if [ ! -f "${SETUP}.sig" ]; then
|
||||
echo "updater signature missing next to $SETUP"
|
||||
exit 1
|
||||
fi
|
||||
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
|
||||
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
# Independent Authenticode gate on the exact artifact users download.
|
||||
# The signing script already verifies each file it signs, but this step
|
||||
# would still catch an installer that skipped signCommand entirely.
|
||||
- name: Verify Authenticode signatures
|
||||
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" }
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file.FullName
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
|
||||
}
|
||||
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-windows-x64
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build, build-windows]
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -779,33 +506,6 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body and updater manifest stay whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
@@ -880,14 +580,14 @@ jobs:
|
||||
if ! gh release view "$FEED" >/dev/null 2>&1; then
|
||||
if [ "$CHANNEL" = "beta" ]; then
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop beta (auto-update feed)" \
|
||||
--title "Cline Code desktop beta (auto-update feed)" \
|
||||
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
else
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop (auto-update feed)" \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
@@ -901,7 +601,7 @@ jobs:
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
echo "Published Cline desktop v${VERSION}"
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
|
||||
|
||||
@@ -912,16 +612,16 @@ jobs:
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: desktop-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dmg-background:
|
||||
name: Test DMG background tooling
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/examples/desktop-app
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# The suite only uses Bun/Node built-ins and committed artwork, so it does
|
||||
# not need a workspace dependency install or macOS runner.
|
||||
- name: Test DMG background tooling
|
||||
run: bun run test:dmg-background
|
||||
@@ -150,12 +150,14 @@ jobs:
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -490,35 +492,6 @@ jobs:
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
{
|
||||
echo "slack_content<<CHANGELOG_EOF"
|
||||
echo "$SLACK_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
@@ -574,7 +547,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -58,12 +58,14 @@ jobs:
|
||||
with:
|
||||
ref: legacy-extension
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -264,33 +266,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -320,7 +295,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -14,12 +14,9 @@ name: ext-vscode-publish-nightly
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
# Manual dispatch only. The nightly cron was removed deliberately: the
|
||||
# PublishNightly environment gained required reviewers, and an unattended
|
||||
# cron run would just sit `waiting` on that approval, hold this workflow's
|
||||
# concurrency group, and silently cancel every later scheduled run behind it
|
||||
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
|
||||
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
@@ -77,9 +74,8 @@ jobs:
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: the || fallback is retained so this stays correct if a
|
||||
# non-dispatch trigger is ever added back (inputs are empty strings
|
||||
# on e.g. `schedule` events, where the declared default does not apply).
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
@@ -234,33 +234,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -330,7 +303,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -80,9 +80,8 @@ jobs:
|
||||
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}-latest
|
||||
timeout-minutes: 20
|
||||
# Nothing in this job uses OIDC, so it does not need an id-token
|
||||
# permission.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
@@ -94,9 +93,6 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Cache keys below are exact-match only (no restore-keys prefix
|
||||
# fallbacks); a miss just means a cold install, which is acceptable.
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
uses: actions/cache@v4
|
||||
@@ -104,6 +100,8 @@ jobs:
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -112,6 +110,8 @@ jobs:
|
||||
with:
|
||||
path: apps/vscode/.vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
@@ -123,6 +123,8 @@ jobs:
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
@@ -169,12 +171,9 @@ jobs:
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
|
||||
# Repo-root relative: the job's `working-directory` default applies to `run`
|
||||
# steps only, so an apps/vscode-relative path here silently matches nothing
|
||||
# and every failing run uploads no recordings at all.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: playwright-recordings-${{ matrix.runner }}
|
||||
path: |
|
||||
apps/vscode/test-results/
|
||||
test-results/playwright/
|
||||
|
||||
@@ -282,33 +282,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -360,7 +333,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -88,7 +88,6 @@ apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
|
||||
@@ -1,99 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.16]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
|
||||
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
|
||||
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
|
||||
- New files are now created with your platform's native line endings.
|
||||
- Fixed the codebase search tool crashing on files containing a single enormous line.
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
|
||||
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
|
||||
- The hub's event log can no longer grow until it fills your disk.
|
||||
|
||||
### Changed
|
||||
|
||||
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
|
||||
|
||||
## [4.1.15]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
|
||||
|
||||
## [4.1.14]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Added
|
||||
|
||||
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
|
||||
|
||||
## [4.1.13]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
|
||||
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
|
||||
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
|
||||
|
||||
## [4.1.12]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
|
||||
|
||||
## [4.1.11]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
|
||||
|
||||
### Added
|
||||
|
||||
- Let models that support it generate images during a task. Generated images render inline in the conversation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix code actions failing with "command not found" on VS Code 1.134.
|
||||
- Fix `@` file mentions breaking on paths that contain spaces.
|
||||
- Show the diff edit view for multi-line edits in files with CRLF line endings.
|
||||
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
|
||||
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
|
||||
- Honor the classic truncation range when migrating legacy tasks.
|
||||
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
|
||||
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
|
||||
- Point provider signup links at each provider's API key page instead of a generic landing page.
|
||||
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
|
||||
- Stop offering image, voice, and other non-chat models in chat model pickers.
|
||||
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
|
||||
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
|
||||
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
|
||||
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
|
||||
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
|
||||
|
||||
### Changed
|
||||
|
||||
- Show the billed cost for Cline gateway usage.
|
||||
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
|
||||
|
||||
### Fixed (legacy bundle)
|
||||
|
||||
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
|
||||
|
||||
## [4.1.10]
|
||||
|
||||
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
|
||||
|
||||
@@ -1,67 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.61
|
||||
|
||||
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
|
||||
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
|
||||
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
|
||||
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
|
||||
- Fixed images being dropped from file reads on models whose capability list is empty
|
||||
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not-ready in every published binary while working in dev
|
||||
- Restoring a checkpoint now refuses to run when you have made commits after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected
|
||||
- `apply_patch` now preserves a file's existing CRLF line endings
|
||||
- Global rules are now also read from `~/Cline/Rules`, which is where the VS Code Rules tab writes them on WSL and headless installs
|
||||
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
|
||||
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
|
||||
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
|
||||
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
|
||||
- Fixed scheduled tasks disappearing after a hub restart
|
||||
- Fixed markdown flashing as it settled at the end of a streamed response
|
||||
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
|
||||
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
|
||||
- Refreshed the model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
|
||||
|
||||
## 3.0.60
|
||||
|
||||
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing on files that contain a single enormous line
|
||||
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
|
||||
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.58
|
||||
|
||||
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
|
||||
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
|
||||
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 3.0.57
|
||||
|
||||
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
|
||||
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
|
||||
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
|
||||
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
|
||||
## 3.0.56
|
||||
|
||||
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
|
||||
- Skill slash commands now load through the skills tool instead of expanding into your message. History and resume show the `/command` you typed instead of the whole skill body, and the instructions reach the model once instead of twice. Workflows still expand, as does zen mode, whose preset has no skills tool
|
||||
- Image, voice, and other non-chat models are no longer offered in the onboarding and model pickers or ACP model listings, and are rejected for `--model`
|
||||
- Fixed TUI dialog colors not following theme changes live
|
||||
- Fixed the account dialog's selection chevron so it matches the other dialogs
|
||||
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown as a tool card
|
||||
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hooks running fire-and-forget with their output and `cancel` control discarded
|
||||
- Fixed `run_commands` failing with ENOENT when a structured command carried a full command line with no `args`
|
||||
- PowerShell commands now fail fast on the first error instead of emitting an error record per enumerated item and still reporting success
|
||||
- Fixed Gemini custom base URLs configured as a host root
|
||||
- Fixed `cline schedule` commands against a remote hub, which now register a workspace client so they are authorized under the new workspace-scoped schedule rules
|
||||
- Usage now displays the billed gateway cost
|
||||
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
|
||||
|
||||
## 3.0.55
|
||||
|
||||
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
|
||||
|
||||
@@ -270,9 +270,6 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
|
||||
### Windows
|
||||
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
|
||||
|
||||
### Windows code signing
|
||||
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
|
||||
|
||||
### File permissions
|
||||
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
|
||||
|
||||
|
||||
@@ -72,29 +72,6 @@ function run(target) {
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
// Windows application control (Smart App Control, WDAC, AppLocker)
|
||||
// blocks the child exe at launch, which Node surfaces only as an
|
||||
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
|
||||
const code = result.error.code;
|
||||
if (
|
||||
os.platform() === "win32" &&
|
||||
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
|
||||
) {
|
||||
console.error(
|
||||
"\nWindows refused to start the Cline binary:\n " +
|
||||
target +
|
||||
"\n\n" +
|
||||
"This usually means an application control policy (Smart App Control,\n" +
|
||||
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
|
||||
"run the path above directly in a terminal and check the error Windows\n" +
|
||||
"reports, or inspect its signature with:\n\n" +
|
||||
' Get-AuthenticodeSignature "' +
|
||||
target +
|
||||
'"\n\n' +
|
||||
"If it was blocked by policy, allow the file or ask your administrator\n" +
|
||||
"to trust it. See https://github.com/cline/cline/issues for known issues.",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof result.status === "number") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.61",
|
||||
"version": "3.0.55",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -3,20 +3,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockLocalHubHasNoActiveSessions,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockRequestHubDrain,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockLocalHubHasNoActiveSessions: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockRequestHubDrain: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -31,10 +27,8 @@ const {
|
||||
vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
requestHubDrain: mockRequestHubDrain,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
@@ -101,147 +95,6 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand() {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
return {
|
||||
cmd,
|
||||
output,
|
||||
errors,
|
||||
exitCode: () => exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
it("sends an un-drain request with drain --off", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain", "--off"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain --off",
|
||||
{ off: true },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: false,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("drains without the off flag by default", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain",
|
||||
{ off: false },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
|
||||
// The drain was never lifted manually: the drained hub was replaced.
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
upgraded: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
|
||||
|
||||
const { cmd, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(errors[0]).toContain("still serving sessions");
|
||||
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
|
||||
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",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const { cmd } = createCommand();
|
||||
cmd.configureOutput({ writeErr: () => {} });
|
||||
for (const sub of cmd.commands) {
|
||||
sub.configureOutput({ writeErr: () => {} });
|
||||
}
|
||||
await expect(
|
||||
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
|
||||
).rejects.toThrow("--wait requires a non-negative number of seconds.");
|
||||
expect(mockRequestHubDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
localHubHasNoActiveSessions,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
requestHubDrain,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -56,16 +54,6 @@ function resolveCliHubOwnerContext() {
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function parseWaitSeconds(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
throw new InvalidArgumentError(
|
||||
"--wait requires a non-negative number of seconds.",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -162,117 +150,5 @@ export function createHubCommand(
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("drain")
|
||||
.description("Refuse new mutating work while accepted runs finish")
|
||||
.option("--reason <text>", "Why the hub is draining")
|
||||
.option("--off", "Lift the drain and accept new mutating work again")
|
||||
.action(
|
||||
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
io.writeErr("No hub is running.");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const draining = cmdOptions.off !== true;
|
||||
const ok = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
cmdOptions.reason ??
|
||||
(draining ? "cline hub drain" : "cline hub drain --off"),
|
||||
{ off: !draining },
|
||||
);
|
||||
if (!ok) {
|
||||
io.writeErr(
|
||||
draining
|
||||
? "Hub drain request failed."
|
||||
: "Hub un-drain request failed.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
io.writeln(JSON.stringify({ draining, url: discovery.url }));
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("upgrade")
|
||||
.description(
|
||||
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
|
||||
)
|
||||
.option(
|
||||
"--wait <seconds>",
|
||||
"How long to wait for the hub to go idle",
|
||||
parseWaitSeconds,
|
||||
120,
|
||||
)
|
||||
.action(
|
||||
action(async (cmdOptions: { wait: number }) => {
|
||||
const opts = hub.opts<{
|
||||
cwd: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
}>();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url) {
|
||||
const drained = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade",
|
||||
).catch(() => false);
|
||||
// An aborted upgrade must hand the hub back: leaving it
|
||||
// draining refuses all new mutating work until a restart.
|
||||
const undrain = async (): Promise<void> => {
|
||||
if (!drained) {
|
||||
return;
|
||||
}
|
||||
await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
).catch(() => false);
|
||||
};
|
||||
try {
|
||||
const deadline = Date.now() + cmdOptions.wait * 1_000;
|
||||
let idle = false;
|
||||
// Check at least once so --wait 0 still observes an idle hub.
|
||||
for (;;) {
|
||||
idle = await localHubHasNoActiveSessions(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
).catch(() => true);
|
||||
if (idle || Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
if (!idle) {
|
||||
await undrain();
|
||||
io.writeErr(
|
||||
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
await stopHubServer(opts.cwd);
|
||||
} catch (error) {
|
||||
await undrain();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { url } = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
io.writeln(JSON.stringify({ upgraded: true, url }));
|
||||
}),
|
||||
);
|
||||
|
||||
return hub;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockHubClientCommand = vi.hoisted(() => vi.fn());
|
||||
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
@@ -17,17 +16,7 @@ vi.mock("@cline/core", async () => {
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
NodeHubClient: class {
|
||||
command = mockHubClientCommand;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
mockNodeHubClientCtor(options);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
close(): void {}
|
||||
},
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
@@ -85,7 +74,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -107,21 +96,18 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["No schedules found."]);
|
||||
// Schedule commands are workspace-scoped: the hub client must register
|
||||
// with a workspace context (and the hub auth token) before commanding.
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
authToken: "test-token",
|
||||
}),
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.list",
|
||||
payload: {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps JSON list output unchanged when --json is provided", async () => {
|
||||
@@ -129,7 +115,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -151,7 +137,7 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["[]"]);
|
||||
expect(mockHubClientCommand).toHaveBeenCalled();
|
||||
expect(mockSendHubCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +157,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -203,19 +189,15 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
cwd: "/tmp/workspace",
|
||||
authToken: "test-token",
|
||||
}),
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -233,7 +215,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -264,11 +246,14 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -307,7 +292,7 @@ describe("runScheduleCommand create", () => {
|
||||
expect(errors).toEqual([
|
||||
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
|
||||
]);
|
||||
expect(mockHubClientCommand).not.toHaveBeenCalled();
|
||||
expect(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -315,7 +300,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_delivery" } },
|
||||
});
|
||||
@@ -354,17 +339,21 @@ describe("runScheduleCommand create", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -381,7 +370,7 @@ describe("runScheduleCommand import", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -422,12 +411,16 @@ describe("runScheduleCommand import", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -451,7 +444,7 @@ describe("runScheduleCommand export", () => {
|
||||
prompt: "review status",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
};
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
@@ -491,9 +484,14 @@ describe("runScheduleCommand export", () => {
|
||||
|
||||
const written = await readFile(targetPath, "utf8");
|
||||
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
|
||||
scheduleId: "sched_abc",
|
||||
});
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.get",
|
||||
payload: { scheduleId: "sched_abc" },
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(targetPath, { force: true });
|
||||
}
|
||||
@@ -509,7 +507,7 @@ describe("runScheduleCommand export", () => {
|
||||
name: "Weekly Sync",
|
||||
cronPattern: "0 9 * * 1",
|
||||
};
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
NodeHubClient,
|
||||
sendHubCommand,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
@@ -11,51 +11,28 @@ import {
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export class HubScheduleClient {
|
||||
private hub: Promise<NodeHubClient> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly workspaceRoot: string,
|
||||
private readonly authToken?: string,
|
||||
private readonly endpoint: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
close(): void {
|
||||
const hub = this.hub;
|
||||
this.hub = undefined;
|
||||
void hub?.then((client) => client.close()).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Schedule commands are authorized against the workspace bound to the
|
||||
// connection's client registration, so all commands must share one
|
||||
// registered connection instead of fire-and-forget envelopes.
|
||||
private connectedHub(): Promise<NodeHubClient> {
|
||||
this.hub ??= (async () => {
|
||||
const client = new NodeHubClient({
|
||||
url: this.url,
|
||||
clientType: "cli-schedule",
|
||||
displayName: "Cline CLI scheduler",
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
authToken: this.authToken,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
client.close();
|
||||
this.hub = undefined;
|
||||
throw error;
|
||||
}
|
||||
return client;
|
||||
})();
|
||||
return this.hub;
|
||||
}
|
||||
close(): void {}
|
||||
|
||||
private async command(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const client = await this.connectedHub();
|
||||
const reply = await client.command(command as never, payload);
|
||||
const reply = await sendHubCommand(this.endpoint, {
|
||||
clientId: "cline-schedule",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -120,7 +97,6 @@ export class LocalScheduleClient {
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
private readonly commands = new HubScheduleCommandService(this.service);
|
||||
constructor(private readonly workspaceRoot: string) {}
|
||||
|
||||
close(): void {
|
||||
void this.service.dispose();
|
||||
@@ -130,21 +106,12 @@ export class LocalScheduleClient {
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await this.commands.handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-schedule-local",
|
||||
workspaceContext: {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
const reply = await this.commands.handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
@@ -218,27 +185,24 @@ export async function ensureSchedulerHub(
|
||||
if (!address?.trim()) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const requestedEndpoint = parseHubEndpointOverride(address);
|
||||
const { url: hubUrl, authToken } = await ensureCliHubServer(
|
||||
const { url: hubUrl } = await ensureCliHubServer(
|
||||
workspaceRoot,
|
||||
requestedEndpoint,
|
||||
);
|
||||
const endpoint = parseHubEndpointOverride(hubUrl);
|
||||
return {
|
||||
ok: true,
|
||||
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
|
||||
client: new HubScheduleClient(endpoint),
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export function MigrationNoticeContent(
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $4.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
|
||||
|
||||
const workspaceDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of workspaceDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
|
||||
describe("buildUserInputMessage", () => {
|
||||
it("extracts image mentions into userImages", async () => {
|
||||
@@ -52,43 +43,3 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
it("includes git remotes and the latest commit for Cline requests", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
execFileSync("git", ["init"], { cwd });
|
||||
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
|
||||
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
|
||||
writeFileSync(join(cwd, "README.md"), "test\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd });
|
||||
execFileSync(
|
||||
"git",
|
||||
["remote", "add", "origin", "https://example.com/cline/repo.git"],
|
||||
{ cwd },
|
||||
);
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
|
||||
expect(prompt).toContain(commit);
|
||||
});
|
||||
|
||||
it("includes parseable metadata outside a project", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
expect(prompt).toContain(JSON.stringify(cwd));
|
||||
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
|
||||
expect(prompt).not.toContain("associatedRemoteUrls");
|
||||
expect(prompt).not.toContain("latestGitCommitHash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ export function getToolCatalog(
|
||||
): ToolCatalogEntry[] {
|
||||
const modelToolSettings = resolveModelToolSettings();
|
||||
return getCoreBuiltinToolCatalog({
|
||||
clientType: "cli",
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
enabledModelToolIds: new Set(
|
||||
Object.entries(modelToolSettings)
|
||||
|
||||
@@ -657,18 +657,11 @@ export function ChatEntryView(props: {
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*
|
||||
* streaming stays true even after the entry settles: flipping the
|
||||
* prop makes MarkdownRenderable rebuild every block from scratch
|
||||
* (updateBlocks(true) skips all reuse paths), so the finished
|
||||
* message flashes back to unhighlighted text while tree-sitter
|
||||
* re-highlights. opencode's TUI keeps streaming={true} for the
|
||||
* same reason. entry.streaming still drives the spinner glyph.
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={true}
|
||||
streaming={entry.streaming}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
|
||||
@@ -14,27 +14,6 @@ export function resolveHubUpdateRequiredKeyAction(
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human phrase for the live work an outdated Hub is serving, used by the
|
||||
* "Hub update required" dialog. Falls back to an unquantified phrase when the
|
||||
* Hub could not answer the activity query.
|
||||
*/
|
||||
export function describeOutdatedHubSessions(counts: {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}): string {
|
||||
const sessions = counts.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return "active sessions from other Cline clients";
|
||||
}
|
||||
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
|
||||
const clients = counts.participantClientCount;
|
||||
if (typeof clients !== "number" || clients <= 0) {
|
||||
return sessionsPhrase;
|
||||
}
|
||||
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo and sandbox sessions force the local backend and never attach to the
|
||||
* shared managed Hub (see the forceLocalBackend condition in the interactive
|
||||
|
||||
@@ -2,21 +2,12 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
resolveHubUpdateRequiredKeyAction,
|
||||
} from "./hub-update-required-helpers";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown only for `unsupported_protocol`: the running Hub speaks a protocol
|
||||
* this CLI cannot, so nothing hub-backed works until the CLI updates. The
|
||||
* softer `build_mismatch` case (newer Hub, compatible protocol) is a toast
|
||||
* in root.tsx, not this modal.
|
||||
*/
|
||||
export function HubUpdateRequiredContent(
|
||||
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
|
||||
) {
|
||||
@@ -38,12 +29,13 @@ export function HubUpdateRequiredContent(
|
||||
<text fg="yellow">Cline Hub was updated</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
Another Cline installation updated the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""} to a version this
|
||||
CLI cannot talk to.
|
||||
Another Cline installation restarted the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
|
||||
matches this CLI.
|
||||
</text>
|
||||
<text selectable>
|
||||
Update and restart Cline to reconnect to the running Hub.
|
||||
Update and restart Cline so this CLI and the Hub run the same version
|
||||
again.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
@@ -57,64 +49,3 @@ export function HubUpdateRequiredContent(
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HubOutdatedDetails {
|
||||
hubCoreVersion?: string;
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when this CLI is the newer build and the shared Hub was left running
|
||||
* an older one because it is still serving other clients' sessions. Enter
|
||||
* replaces the Hub now (interrupting that work); Esc keeps it running.
|
||||
*/
|
||||
export function HubOutdatedContent(
|
||||
props: ChoiceContext<boolean> & HubOutdatedDetails,
|
||||
) {
|
||||
const {
|
||||
activeSessionCount,
|
||||
dialogId,
|
||||
dismiss,
|
||||
participantClientCount,
|
||||
resolve,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "update") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="yellow">Cline Hub update required</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
This CLI needs a newer Cline Hub, but the running one is still serving{" "}
|
||||
{describeOutdatedHubSessions({
|
||||
activeSessionCount,
|
||||
participantClientCount,
|
||||
})}
|
||||
.
|
||||
</text>
|
||||
<text selectable>
|
||||
Updating stops that Hub and interrupts its sessions.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Update Now</text>
|
||||
</box>
|
||||
</box>
|
||||
<text fg={palette.muted}>
|
||||
Press Enter to update now, Esc to keep the Hub running
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -76,5 +77,8 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
if (CLI_PROMO_CODE) {
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RuntimeToolInteraction, TuiProps } from "../types";
|
||||
|
||||
@@ -40,16 +36,16 @@ function toRuntimeToolInteraction(
|
||||
};
|
||||
}
|
||||
|
||||
function deniedToolResult(): ToolApprovalResult {
|
||||
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
|
||||
return {
|
||||
approved: false,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
|
||||
if (pending.kind === "tool_approval") {
|
||||
pending.resolve(deniedToolResult());
|
||||
pending.resolve(deniedToolResult(pending.request));
|
||||
return;
|
||||
}
|
||||
pending.resolve("[User dismissed the question]");
|
||||
@@ -115,7 +111,9 @@ export function useRuntimeDialogBridge(input: {
|
||||
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(approved ? { approved: true } : deniedToolResult());
|
||||
pending.resolve(
|
||||
approved ? { approved: true } : deniedToolResult(pending.request),
|
||||
);
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
getCurrentContextSize,
|
||||
type ManagedHubBuildMismatchEvent,
|
||||
summarizeUsageFromMessages,
|
||||
upgradeManagedHub,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
@@ -41,10 +40,7 @@ import {
|
||||
buildCommandPaletteItems,
|
||||
findCommandPaletteShortcut,
|
||||
} from "./components/dialogs/command-palette-items";
|
||||
import {
|
||||
HubOutdatedContent,
|
||||
HubUpdateRequiredContent,
|
||||
} from "./components/dialogs/hub-update-required";
|
||||
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
|
||||
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
|
||||
import {
|
||||
SKILLS_MARKETPLACE_ACTION,
|
||||
@@ -590,85 +586,17 @@ function App(props: TuiProps) {
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
if (hubBuildMismatch.reason === "outdated_hub") {
|
||||
// This CLI is already the newer build; the Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving. Left alone it
|
||||
// would stay behind for as long as those sessions run, so put the
|
||||
// choice to the user: replace it now (interrupting that work), or
|
||||
// keep it running and update later. This session itself is safe
|
||||
// either way - a CLI that could not attach to the outdated Hub is
|
||||
// running on the local backend.
|
||||
const details = {
|
||||
hubCoreVersion,
|
||||
activeSessionCount: hubBuildMismatch.activeSessionCount,
|
||||
participantClientCount: hubBuildMismatch.participantClientCount,
|
||||
};
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<HubOutdatedContent {...ctx} {...details} />
|
||||
),
|
||||
})
|
||||
.then(async (update) => {
|
||||
if (!update) {
|
||||
// choice() resolves undefined on Esc; it does not reject.
|
||||
showToast(
|
||||
"The running Cline Hub stays on the older version. Run 'cline hub upgrade' once its sessions finish.",
|
||||
"info",
|
||||
);
|
||||
refocusTextareaRef.current();
|
||||
return;
|
||||
}
|
||||
showToast("Updating the Cline Hub…", "info");
|
||||
try {
|
||||
const result = await upgradeManagedHub({
|
||||
force: true,
|
||||
reason: "cline TUI hub update",
|
||||
});
|
||||
if (result.outcome === "still_busy") {
|
||||
showToast(
|
||||
"The Hub picked up new sessions before it could be replaced. Try again in a moment.",
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
result.outcome === "replaced" || result.outcome === "started"
|
||||
? "Cline Hub updated."
|
||||
: "Cline Hub is already up to date.",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Updating the Cline Hub failed. Run 'cline doctor fix' and try again.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
refocusTextareaRef.current();
|
||||
})
|
||||
.catch(() => {
|
||||
refocusTextareaRef.current();
|
||||
});
|
||||
// This CLI is already the newer build. The Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving, and it is
|
||||
// replaced on its own at the next launch. Nothing is wrong, nothing is
|
||||
// asked, and nothing the user can act on differs - so say nothing, the
|
||||
// same conclusion the desktop surface reached.
|
||||
//
|
||||
// The classification still earns its keep here: it is what stops the
|
||||
// update-and-restart prompt below from firing at someone who has
|
||||
// nothing to update.
|
||||
return;
|
||||
}
|
||||
if (hubBuildMismatch.reason === "build_mismatch") {
|
||||
// The Hub is newer but still speaks this CLI's protocol, so the
|
||||
// session keeps working and parity is advisable rather than urgent.
|
||||
// A modal mid-session is too heavy for advice; a toast (once per
|
||||
// observed Hub build, the watcher dedupes) says what changed and
|
||||
// how to catch up without stealing focus.
|
||||
showToast(
|
||||
`The shared Cline Hub was updated${
|
||||
hubCoreVersion ? ` (core ${hubCoreVersion})` : ""
|
||||
}. Run 'cline update' and restart when convenient.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// unsupported_protocol: this CLI cannot speak the running Hub's
|
||||
// protocol at all, so nothing hub-backed can work until it updates.
|
||||
// That is worth a blocking prompt.
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isProviderSettingsUsable, ProviderSettingsManager } from "@cline/core";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { isProviderSettingsUsable } from "../../utils/provider-readiness";
|
||||
import type { TuiProps } from "../types";
|
||||
|
||||
export function isProviderConfigured(config: TuiProps["config"]): boolean {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import { truncate } from "./helpers";
|
||||
import { c, getActiveCliSession, write } from "./output";
|
||||
|
||||
@@ -95,7 +91,7 @@ async function requestTerminalToolApproval(
|
||||
}
|
||||
return {
|
||||
approved: false,
|
||||
reason: USER_REJECTED_TOOL_REASON,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,20 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString();
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ProviderSettings } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderSettings } from "../llms/provider-settings";
|
||||
import { isProviderSettingsUsable } from "./provider-readiness";
|
||||
|
||||
describe("provider readiness", () => {
|
||||
@@ -167,24 +167,4 @@ describe("provider readiness", () => {
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects migrated placeholder entries that only hold a default model", () => {
|
||||
// The legacy VS Code migration can seed entries with a catalog-default
|
||||
// model and no credentials (e.g. qwen-code from a plan/act provider
|
||||
// selection, sapaicore from a persisted orchestration-mode default).
|
||||
// These must not read as configured.
|
||||
expect(
|
||||
isProviderSettingsUsable("qwen-code", {
|
||||
provider: "qwen-code",
|
||||
model: "qwen3-coder-plus",
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProviderSettingsUsable("sapaicore", {
|
||||
provider: "sapaicore",
|
||||
model: "anthropic--claude-4-sonnet",
|
||||
sap: { useOrchestrationMode: true },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import {
|
||||
getProviderConfigFields,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
} from "../../auth/provider-auth-registry";
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ProviderSettings,
|
||||
} from "../llms/provider-settings";
|
||||
import { getProviderConfigFields } from "./provider-config-fields";
|
||||
normalizeProviderId,
|
||||
} from "./provider-auth";
|
||||
|
||||
function hasText(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
@@ -34,7 +34,7 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
|
||||
// existing project-only configs usable while new saves include a region.
|
||||
// existing project-only configs usable while new CLI saves include a region.
|
||||
return hasText(gcp?.projectId);
|
||||
}
|
||||
|
||||
@@ -52,14 +52,6 @@ function hasSapCredentials(settings: ProviderSettings): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether persisted provider settings hold enough real credentials or
|
||||
* endpoint configuration for a turn to plausibly succeed. Unlike the mere
|
||||
* existence of a settings entry (which migrations and empty "connect" saves
|
||||
* can create), this requires provider-appropriate evidence: an API key or
|
||||
* OAuth token, cloud credentials (AWS/GCP/Azure/SAP), a local-auth CLI, or a
|
||||
* resolvable endpoint + model for keyless local providers.
|
||||
*/
|
||||
export function isProviderSettingsUsable(
|
||||
providerId: string,
|
||||
settings: ProviderSettings | undefined,
|
||||
@@ -68,10 +60,8 @@ export function isProviderSettingsUsable(
|
||||
if (!settings) {
|
||||
return false;
|
||||
}
|
||||
const normalizedProviderId = LlmsModels.normalizeProviderId(providerId);
|
||||
if (
|
||||
LlmsModels.normalizeProviderId(settings.provider) !== normalizedProviderId
|
||||
) {
|
||||
const normalizedProviderId = normalizeProviderId(providerId);
|
||||
if (normalizeProviderId(settings.provider) !== normalizedProviderId) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedProviderId === "bedrock") {
|
||||
@@ -253,7 +253,7 @@ export async function handleDesktopCommand(
|
||||
return path;
|
||||
}
|
||||
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
|
||||
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
}
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot, cwd: workspaceRoot };
|
||||
@@ -264,19 +264,6 @@ export async function handleDesktopCommand(
|
||||
) {
|
||||
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
|
||||
}
|
||||
if (command === "search_sessions") {
|
||||
if (!ctx.uiClient) throw new Error("Hub is not connected");
|
||||
const query = String(args?.query ?? "").trim();
|
||||
if (!query) return [];
|
||||
return await ctx.uiClient.searchSessions({
|
||||
query,
|
||||
limit: typeof args?.limit === "number" ? args.limit : 50,
|
||||
workspaceRoot:
|
||||
typeof args?.workspaceRoot === "string"
|
||||
? args.workspaceRoot
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (command === "read_session_hooks") {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -27,20 +27,13 @@ function getCommands(): HubScheduleCommandService {
|
||||
async function clientCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await getCommands().handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-hub-schedules",
|
||||
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
|
||||
},
|
||||
);
|
||||
const reply = await getCommands().handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -77,17 +70,14 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<unknown> {
|
||||
const commandHub = (hubCommand: string, payload?: Record<string, unknown>) =>
|
||||
clientCommand(hubCommand, payload, workspaceRoot);
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
commandHub("schedule.list", {
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
commandHub("schedule.active"),
|
||||
commandHub("schedule.upcoming", { limit: 30 }),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const scheduleRows = Array.isArray(schedules.schedules)
|
||||
? schedules.schedules
|
||||
@@ -98,7 +88,7 @@ export async function handleRoutineScheduleCommand(
|
||||
(schedule as Record<string, unknown>).scheduleId,
|
||||
);
|
||||
if (!scheduleId) return undefined;
|
||||
const reply = await commandHub("schedule.list_executions", {
|
||||
const reply = await clientCommand("schedule.list_executions", {
|
||||
scheduleId,
|
||||
limit: 1,
|
||||
});
|
||||
@@ -125,7 +115,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await commandHub("schedule.create", {
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
@@ -159,7 +149,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await commandHub("schedule.update", {
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
@@ -190,25 +180,25 @@ export async function handleRoutineScheduleCommand(
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await commandHub("schedule.disable", { scheduleId });
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await commandHub("schedule.enable", { scheduleId });
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const existing = await commandHub("schedule.get", { scheduleId });
|
||||
const existing = await clientCommand("schedule.get", { scheduleId });
|
||||
if (!existing.schedule)
|
||||
throw new Error(`schedule not found: ${scheduleId}`);
|
||||
const reply = await commandHub("schedule.trigger", {
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await commandHub("schedule.delete", { scheduleId });
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
|
||||
@@ -88,7 +88,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Cline Desktop", name: "Cline Desktop" };
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
}
|
||||
return {
|
||||
key: client.clientId,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "11.16.1",
|
||||
"mermaid": "11.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
PlugIcon,
|
||||
RotateCcwIcon,
|
||||
RssIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
@@ -64,7 +63,6 @@ import type {
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import { desktopClient } from "./lib/desktop-client";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
import { postToHost } from "./vscode";
|
||||
|
||||
@@ -742,18 +740,7 @@ function SessionsView({
|
||||
onRenameSession: (sessionId: string, title: string) => Promise<void> | void;
|
||||
sessions: WebviewSessionSummary[];
|
||||
}) {
|
||||
type SearchHit = {
|
||||
sessionId: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
workspaceRoot: string;
|
||||
role: string;
|
||||
snippet: string;
|
||||
};
|
||||
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
const [deleteSessionCandidate, setDeleteSessionCandidate] =
|
||||
@@ -785,34 +772,6 @@ function SessionsView({
|
||||
});
|
||||
}, [sessions, sessionFilters, sortDirection]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchHits([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
const timer = setTimeout(() => {
|
||||
void desktopClient
|
||||
.invoke<SearchHit[]>("search_sessions", { query, limit: 50 })
|
||||
.then((hits) => {
|
||||
if (!cancelled) setSearchHits(hits);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSearchHits([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSearching(false);
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const startRenameSession = (session: WebviewSessionSummary) => {
|
||||
setEditingSessionId(session.sessionId);
|
||||
setEditingTitle(session.title || shortId(session.sessionId));
|
||||
@@ -935,52 +894,6 @@ function SessionsView({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="relative mb-3">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search all session history"
|
||||
className="pl-9"
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search messages, commands, errors, and file paths across all sessions…"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim() ? (
|
||||
<section className="mb-4 overflow-hidden rounded-lg border bg-card">
|
||||
{searching ? (
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
Searching…
|
||||
</p>
|
||||
) : searchHits.length === 0 ? (
|
||||
<p className="px-4 py-5 text-sm text-muted-foreground">
|
||||
No matching session history.
|
||||
</p>
|
||||
) : (
|
||||
searchHits.map((hit) => (
|
||||
<button
|
||||
className="block w-full border-b px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
|
||||
key={hit.documentId}
|
||||
onClick={() => onOpenSession(hit.sessionId)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="truncate">{hit.title}</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{hit.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{hit.snippet}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{hit.workspaceRoot}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="w-full min-w-0 overflow-x-auto">
|
||||
<div className="grid w-full min-w-[56rem] grid-cols-[minmax(12rem,1.35fr)_minmax(7rem,0.85fr)_minmax(10rem,1.1fr)_5rem_5rem_4.5rem_5.5rem_2rem] gap-x-4 bg-muted/40 px-4 py-3 text-[15px] font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type GeneratedMedia,
|
||||
USER_REJECTED_TOOL_REASON,
|
||||
} from "@cline/shared/browser";
|
||||
import type { GeneratedMedia } from "@cline/shared/browser";
|
||||
import { GeneratedMediaContent } from "@cline/ui";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -1185,7 +1182,7 @@ export default function Chat({
|
||||
type: "approval_response",
|
||||
approvalId,
|
||||
approved,
|
||||
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
|
||||
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
|
||||
});
|
||||
setStatus(approved ? "Approval sent." : "Rejection sent.");
|
||||
};
|
||||
|
||||
@@ -1,109 +1,4 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.21
|
||||
|
||||
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
|
||||
- Stopping a session now actually stops everything it started. Stop stays available while child agents are running, and an abort propagates to delegated subagents and to teammates instead of leaving orphaned work running in the background; cancelled teammate tasks now persist as cancelled
|
||||
- Fixed the ask-a-question tool's option text overflowing instead of wrapping
|
||||
- You can now drop file attachments anywhere over the chat input, not just on the small attach target
|
||||
- Cline provider models now refresh from the live catalog, so newly released models show up without waiting for an app update
|
||||
- Provider 401/403 responses are now classified as authentication errors rather than generic request failures, so a bad or missing API key is distinguishable from a real provider outage
|
||||
- Fixed Langfuse tracing never initializing in release builds — the minified bundle broke tracer detection, so telemetry worked in dev and silently did nothing in the shipped app. Also updated for AI SDK 7's telemetry API
|
||||
- Refreshed the model catalog. Adds TokenGo and Volcengine Ark, and updates model lists, pricing, and the resolved default model for ~36 providers (including Hugging Face, Mistral, OpenRouter, Together, NanoGPT, Requesty, Baseten, Cloudflare Workers AI, and DigitalOcean) — if you use one of those without pinning a model, you will get a different default
|
||||
|
||||
## 0.0.20
|
||||
|
||||
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
|
||||
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
|
||||
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
|
||||
- Session search now covers your full indexed history. The sidebar search icon opens the command bar (Cmd/Ctrl+P) with server-ranked results, instead of a sidebar-local dialog that first loaded every session into memory
|
||||
- Onboarding has a new GitHub integration step
|
||||
- Fixed scheduled tasks disappearing after the app updated — hub-managed schedules were being wiped by cron reconciliation on restart
|
||||
- Agent-created schedules now live in one user-level home (`~/.cline/schedules`) instead of being scattered across whichever chat folder created them, and they now appear on the Schedules page
|
||||
- A finished scheduled session now surfaces its final answer: the completing step auto-expands, is labeled "Scheduled task completed" (or failed), and its summary renders as markdown
|
||||
- Suggested routine templates now ask for a specific final report, so a scheduled run ends with something readable
|
||||
- Providers no longer show as "Configured" on the strength of a leftover settings entry with no real credentials, and the badge now updates live after connecting or saving credentials instead of waiting for a remount
|
||||
- Fixed OpenAI Codex (ChatGPT subscription) sign-in silently dead-ending when callback port 1455 was already in use — it now fails immediately with an actionable error, and OAuth redirect errors surface instead of a confusing "Missing authorization code"
|
||||
- Codex and OCA sign-ins are no longer dropped when a token refresh hits a transient network failure or server error
|
||||
- Checkpoint restore now refuses to reset your workspace when commits were made after the checkpoint, instead of silently knocking them off the branch
|
||||
- Fixed an enabled-but-offline remote MCP server stalling session startup until the session was torn down
|
||||
- Global rules stored at `~/Cline/Rules` are now discovered (previously only `~/Documents/Cline/Rules`), fixing rules that never reached the model on WSL and headless installs
|
||||
- `apply_patch` now preserves a file's own CRLF line endings
|
||||
- The window title bar stays draggable across every view
|
||||
- Voice input's Live and After recording badges now have tooltips explaining them
|
||||
- Removed the box shadow from the chat message actions row
|
||||
- The hub no longer watches agenda spec directories while the todo tool is disabled, dropping an OS watch handle per known workspace
|
||||
|
||||
## 0.0.19
|
||||
|
||||
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
|
||||
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
|
||||
|
||||
## 0.0.18
|
||||
|
||||
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
|
||||
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
|
||||
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
|
||||
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
|
||||
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
|
||||
- "Run now" now hands you into the session it starts
|
||||
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
|
||||
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
|
||||
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
|
||||
- Fixed quitting the app beach-balling for several seconds
|
||||
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
|
||||
- Fixed hover cards flashing closed and reopening when clicked
|
||||
- The macOS DMG install window now has custom Cline artwork and layout
|
||||
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
|
||||
|
||||
## 0.0.17
|
||||
|
||||
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
|
||||
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
|
||||
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
|
||||
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
|
||||
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
|
||||
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
|
||||
- Added suggested schedule templates to the Schedule page
|
||||
- Add Provider opens a dialog instead of swapping out the page
|
||||
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
|
||||
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
|
||||
- Fixed the provider list being unscrollable while a provider detail panel was open
|
||||
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
|
||||
- Fixed Uninstall buttons collapsing to a broken square next to Install
|
||||
- Fixed unreadable selected text inside input fields
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing the app on files containing a single enormous line
|
||||
- The hub's event log can no longer grow until it fills your disk
|
||||
|
||||
## 0.0.16
|
||||
|
||||
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
- The app now honors server-side feature flags, refreshing them when your account changes
|
||||
|
||||
## 0.0.15
|
||||
|
||||
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
|
||||
- Refreshed app icons and branding
|
||||
- Reskinned the first-run onboarding, with an interactive welcome graphic
|
||||
- Plugins, MCP servers, and Skills are now one Plugins hub with a dedicated Marketplace page
|
||||
- The composer's model selector now leads with Recommended and Free tiers (Subscribed and Free on ClinePass), labeled by display name with descriptions, instead of an alphabetized list of raw model ids. Provider settings show the same badges and descriptions
|
||||
- Agents can now create and manage durable todos and one-time or recurring schedules
|
||||
- Fixed checkpoint restore wedging permanently. Sessions that were never prompted — and persistence-only updates — reported a bogus "running" status, so anything gated on an active turn stayed blocked forever
|
||||
- Fixed "No sessions found" flashing while session history was still loading
|
||||
- Fixed the work summary undercounting elapsed time when thinking before a tool call attached to the answer instead of the run
|
||||
- Fixed the settings gear keeping its hover state while the Account screen is open
|
||||
- Fixed ClinePass not being recognized as OAuth-managed in the chat credential gate, which asked for credentials it already had
|
||||
- Fixed copying a user message bringing along its internal envelope
|
||||
- Fixed multi-line code blocks collapsing onto a single line
|
||||
- Image, voice, and other non-chat models are no longer offered in chat model pickers
|
||||
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hook output and `cancel` control being discarded
|
||||
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown
|
||||
- PowerShell commands now fail fast on the first error instead of flooding output and still reporting success
|
||||
- Usage now displays the billed gateway cost
|
||||
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.14
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Desktop Experimental Branch & Beta Channel
|
||||
|
||||
How experimental desktop features are developed on the `desktop-experimental`
|
||||
branch, shipped to users as **Cline Beta**, and graduated into `main`.
|
||||
branch, shipped to users as **Cline Code Beta**, and graduated into `main`.
|
||||
The release mechanics (workflow internals, secrets) live in
|
||||
[`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml)
|
||||
and the `publish-desktop` skill
|
||||
@@ -12,8 +12,8 @@ this doc is the process.
|
||||
|
||||
The beta is a **separate app**, not a mode of the stable app:
|
||||
|
||||
- Product name `Cline Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline` / `bot.cline.app`) — set by
|
||||
- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline Code` / `bot.cline.app`) — set by
|
||||
[`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which
|
||||
is layered over `tauri.release.conf.json` at build time.
|
||||
- Both apps install and run **side by side**, so people can compare beta
|
||||
|
||||
@@ -6,9 +6,8 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin
|
||||
|
||||
From `apps/examples/desktop-app/`:
|
||||
|
||||
- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential
|
||||
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:web` - Next.js UI only (`http://localhost:3125`)
|
||||
- `bun run dev:sidecar` - sidecar backend only
|
||||
- `bun run dev` - Tauri desktop dev
|
||||
- `bun run build` - build web assets
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
@@ -17,38 +16,6 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Customizing the macOS Install Window
|
||||
|
||||
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
|
||||
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
|
||||
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
|
||||
`background.gen.tiff` Finder actually renders is a gitignored build artifact
|
||||
regenerated from them on every build.
|
||||
|
||||
1. The current source artwork is `640x400`. Export `background.png` at 1x and
|
||||
`background@2x.png` at 2x.
|
||||
2. Currently the app icons are centered at `(140, 200)` and
|
||||
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
|
||||
and `applicationFolderPosition` to reposition the app icons.
|
||||
3. Build with `bun run build:binary`. Before compiling, the build validates
|
||||
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
|
||||
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
|
||||
expected 1x and 2x representations. Run `bun run dmg:background` to do just
|
||||
that step, e.g. to sanity-check new artwork without a full build. The DMG
|
||||
is written beneath `src-tauri/target/release/bundle/dmg/`.
|
||||
|
||||
Run `bun run test:dmg-background` for the cross-platform checks covering the
|
||||
committed PNG dimensions and TIFF validation logic.
|
||||
|
||||
The configured `640x432` Finder window is intentionally 32 points taller than
|
||||
the `640x400` background. That extra height matches the Finder chrome in the
|
||||
currently verified packaged layout; re-check it after material macOS or Finder
|
||||
changes. The project deliberately uses a multi-resolution TIFF even though
|
||||
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
|
||||
both the 1x and 2x representations from a single background file. Re-check the
|
||||
packaged DMG after upgrading Tauri in case its background validation changes.
|
||||
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
@@ -88,7 +55,7 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
|
||||
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
|
||||
shipped apps can't verify new updates).
|
||||
|
||||
There is also a beta channel ("Cline Beta", a separate app that installs
|
||||
There is also a beta channel ("Cline Code Beta", a separate app that installs
|
||||
side by side with stable) cut from the `desktop-experimental` branch and
|
||||
served by the rolling `desktop-beta` release — the same never-delete rule
|
||||
applies to it. The experimental-branch process and beta release flow live in
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"predev:headless": "bun run build:ui",
|
||||
"dev:headless": "bun run scripts/dev-headless.ts",
|
||||
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"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:binary": "tauri build",
|
||||
"dmg:background": "bun run scripts/dmg-background.ts",
|
||||
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
|
||||
"package": "bun run package:desktop",
|
||||
"package:desktop": "bun run scripts/package-desktop.ts",
|
||||
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
|
||||
@@ -35,10 +31,10 @@
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
@@ -82,7 +78,6 @@
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const approvalToken = randomUUID();
|
||||
const children: ReturnType<typeof Bun.spawn>[] = [];
|
||||
|
||||
async function reserveAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to reserve a sidecar port"));
|
||||
return;
|
||||
}
|
||||
server.close((error) => (error ? reject(error) : resolve(address.port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function spawn(command: string[], env: Record<string, string>) {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd: import.meta.dir + "/..",
|
||||
env: { ...process.env, ...env },
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function stopChildren(): void {
|
||||
for (const child of children) {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", stopChildren);
|
||||
process.on("SIGTERM", stopChildren);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sidecarPort = await reserveAvailablePort();
|
||||
const endpoint = `ws://127.0.0.1:${sidecarPort}/transport?approval_token=${approvalToken}`;
|
||||
const sidecar = spawn(["bun", "run", "sidecar/index.ts"], {
|
||||
CLINE_SIDECAR_APPROVAL_TOKEN: approvalToken,
|
||||
CLINE_SIDECAR_PORT: String(sidecarPort),
|
||||
});
|
||||
const web = spawn(
|
||||
["bun", "run", "next", "dev", "webview", "-p", "3125", "--turbo"],
|
||||
{ NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: endpoint },
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([sidecar.exited, web.exited]);
|
||||
stopChildren();
|
||||
await Promise.allSettled(children.map((child) => child.exited));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import path from "node:path";
|
||||
import {
|
||||
parseTiffInfo,
|
||||
readPngDimensions,
|
||||
validateTiffRepresentations,
|
||||
} from "./dmg-background";
|
||||
|
||||
const DMG_ROOT = path.resolve(import.meta.dir, "..", "src-tauri", "dmg");
|
||||
|
||||
const EXPECTED_REPRESENTATIONS = [
|
||||
{ width: 640, height: 400, dpiX: 72, dpiY: 72 },
|
||||
{ width: 1280, height: 800, dpiX: 144, dpiY: 144 },
|
||||
];
|
||||
|
||||
const TIFF_INFO = `Directory at 0x1
|
||||
Image Width: 640 Image Length: 400
|
||||
Resolution: 72, 72
|
||||
Resolution Unit: pixels/inch
|
||||
Directory at 0x2
|
||||
Image Width: 1280 Image Length: 800
|
||||
Resolution: 144, 144
|
||||
Resolution Unit: pixels/inch
|
||||
`;
|
||||
|
||||
describe("parseTiffInfo", () => {
|
||||
test("reads the dimensions and DPI of every TIFF representation", () => {
|
||||
expect(parseTiffInfo(TIFF_INFO)).toEqual(EXPECTED_REPRESENTATIONS);
|
||||
});
|
||||
|
||||
test("rejects representations without pixel-per-inch resolution", () => {
|
||||
expect(() =>
|
||||
parseTiffInfo(TIFF_INFO.replace("pixels/inch", "pixels/cm")),
|
||||
).toThrow(/could not parse TIFF representation/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMG source artwork", () => {
|
||||
test("has the expected 1x and 2x dimensions", async () => {
|
||||
const [dimensions1x, dimensions2x] = await Promise.all([
|
||||
readPngDimensions(path.join(DMG_ROOT, "background.png")),
|
||||
readPngDimensions(path.join(DMG_ROOT, "background@2x.png")),
|
||||
]);
|
||||
|
||||
expect(dimensions1x).toEqual({ width: 640, height: 400 });
|
||||
expect(dimensions2x).toEqual({ width: 1280, height: 800 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTiffRepresentations", () => {
|
||||
test("accepts the expected representations", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations(EXPECTED_REPRESENTATIONS),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects the wrong number of representations", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations(EXPECTED_REPRESENTATIONS.slice(0, 1)),
|
||||
).toThrow(/exactly two image representations/);
|
||||
});
|
||||
|
||||
test("rejects incorrect representation dimensions", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations([
|
||||
EXPECTED_REPRESENTATIONS[0],
|
||||
{ ...EXPECTED_REPRESENTATIONS[1], width: 1279 },
|
||||
]),
|
||||
).toThrow(/must be 1280x800/);
|
||||
});
|
||||
|
||||
test("rejects incorrect representation DPI", () => {
|
||||
expect(() =>
|
||||
validateTiffRepresentations([
|
||||
{ ...EXPECTED_REPRESENTATIONS[0], dpiX: 73 },
|
||||
EXPECTED_REPRESENTATIONS[1],
|
||||
]),
|
||||
).toThrow(/must be 72x72 DPI/);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
type Dimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TiffRepresentation = Dimensions & {
|
||||
dpiX: number;
|
||||
dpiY: number;
|
||||
};
|
||||
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const DMG_ROOT = path.join(APP_ROOT, "src-tauri", "dmg");
|
||||
const BACKGROUND_1X = path.join(DMG_ROOT, "background.png");
|
||||
const BACKGROUND_2X = path.join(DMG_ROOT, "background@2x.png");
|
||||
// Gitignored build artifact; only the PNG sources are committed.
|
||||
const BACKGROUND_TIFF = path.join(DMG_ROOT, "background.gen.tiff");
|
||||
|
||||
const EXPECTED_1X = { width: 640, height: 400 };
|
||||
const EXPECTED_2X = { width: 1280, height: 800 };
|
||||
const EXPECTED_TIFF_REPRESENTATIONS: TiffRepresentation[] = [
|
||||
{ ...EXPECTED_1X, dpiX: 72, dpiY: 72 },
|
||||
{ ...EXPECTED_2X, dpiX: 144, dpiY: 144 },
|
||||
];
|
||||
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
|
||||
// PNG stores its big-endian width and height in the fixed IHDR fields at
|
||||
// byte offsets 16 and 20, so dimensions can be checked without an image library.
|
||||
export const readPngDimensions = async (
|
||||
filePath: string,
|
||||
): Promise<Dimensions> => {
|
||||
const contents = await readFile(filePath);
|
||||
const hasPngSignature = PNG_SIGNATURE.every(
|
||||
(byte, index) => contents[index] === byte,
|
||||
);
|
||||
if (
|
||||
contents.length < 24 ||
|
||||
!hasPngSignature ||
|
||||
contents.toString("ascii", 12, 16) !== "IHDR"
|
||||
) {
|
||||
throw new Error(`${filePath} is not a valid PNG with an IHDR header`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: contents.readUInt32BE(16),
|
||||
height: contents.readUInt32BE(20),
|
||||
};
|
||||
};
|
||||
|
||||
const assertDimensions = (
|
||||
label: string,
|
||||
actual: Dimensions,
|
||||
expected: Dimensions,
|
||||
): void => {
|
||||
if (actual.width !== expected.width || actual.height !== expected.height) {
|
||||
throw new Error(
|
||||
`${label} must be ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// tiffutil prints one "Directory at ..." block for each image representation
|
||||
// embedded in the TIFF.
|
||||
export const parseTiffInfo = (output: string): TiffRepresentation[] =>
|
||||
output
|
||||
.split(/(?=Directory at )/)
|
||||
.filter((block) => block.startsWith("Directory at "))
|
||||
.map((block) => {
|
||||
const dimensions = block.match(
|
||||
/Image Width:\s*(\d+)\s+Image Length:\s*(\d+)/,
|
||||
);
|
||||
const resolution = block.match(/Resolution:\s*([\d.]+),\s*([\d.]+)/);
|
||||
if (
|
||||
!dimensions ||
|
||||
!resolution ||
|
||||
!block.includes("Resolution Unit: pixels/inch")
|
||||
) {
|
||||
throw new Error(`could not parse TIFF representation:\n${block}`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: Number(dimensions[1]),
|
||||
height: Number(dimensions[2]),
|
||||
dpiX: Number(resolution[1]),
|
||||
dpiY: Number(resolution[2]),
|
||||
};
|
||||
});
|
||||
|
||||
export const validateTiffRepresentations = (
|
||||
representations: TiffRepresentation[],
|
||||
label = "TIFF",
|
||||
): void => {
|
||||
const sortedRepresentations = [...representations].sort(
|
||||
(left, right) => left.width - right.width,
|
||||
);
|
||||
|
||||
if (sortedRepresentations.length !== EXPECTED_TIFF_REPRESENTATIONS.length) {
|
||||
throw new Error(
|
||||
`${label} must contain exactly two image representations, got ${sortedRepresentations.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, expected] of EXPECTED_TIFF_REPRESENTATIONS.entries()) {
|
||||
const actual = sortedRepresentations[index];
|
||||
assertDimensions(`${label} representation ${index + 1}`, actual, expected);
|
||||
if (actual.dpiX !== expected.dpiX || actual.dpiY !== expected.dpiY) {
|
||||
throw new Error(
|
||||
`${label} representation ${index + 1} must be ${expected.dpiX}x${expected.dpiY} DPI, got ${actual.dpiX}x${actual.dpiY} DPI`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const assertTiffRepresentations = async (filePath: string): Promise<void> => {
|
||||
const representations = parseTiffInfo(
|
||||
await $`tiffutil -info ${filePath}`.quiet().text(),
|
||||
);
|
||||
validateTiffRepresentations(representations, filePath);
|
||||
};
|
||||
|
||||
const assertSourceDimensions = async (): Promise<void> => {
|
||||
const [dimensions1x, dimensions2x] = await Promise.all([
|
||||
readPngDimensions(BACKGROUND_1X),
|
||||
readPngDimensions(BACKGROUND_2X),
|
||||
]);
|
||||
assertDimensions("background.png", dimensions1x, EXPECTED_1X);
|
||||
assertDimensions("background@2x.png", dimensions2x, EXPECTED_2X);
|
||||
};
|
||||
|
||||
const generateTiff = async (outputPath: string): Promise<void> => {
|
||||
// Finder's .DS_Store references one background file. A multi-representation
|
||||
// TIFF lets AppKit select the 1x or 2x bitmap without relying on it to discover
|
||||
// a separate @2x companion beside that referenced file.
|
||||
await $`tiffutil -cathidpicheck ${BACKGROUND_1X} ${BACKGROUND_2X} -out ${outputPath}`.quiet();
|
||||
await assertTiffRepresentations(outputPath);
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (process.argv.length > 2) {
|
||||
throw new Error("usage: bun run dmg:background");
|
||||
}
|
||||
if (process.platform !== "darwin") {
|
||||
// Runs from beforeBuildCommand on every platform, but only macOS builds
|
||||
// bundle a DMG and only macOS ships tiffutil.
|
||||
console.log("Skipping DMG background generation on non-macOS host.");
|
||||
return;
|
||||
}
|
||||
|
||||
await assertSourceDimensions();
|
||||
// Generate and validate in scratch space so the configured build artifact is
|
||||
// replaced only after tiffutil has produced a complete, verified TIFF.
|
||||
const scratchRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "cline-dmg-background-"),
|
||||
);
|
||||
const generatedTiff = path.join(scratchRoot, "background.tiff");
|
||||
try {
|
||||
await generateTiff(generatedTiff);
|
||||
await copyFile(generatedTiff, BACKGROUND_TIFF);
|
||||
console.log(`Generated ${path.relative(APP_ROOT, BACKGROUND_TIFF)}.`);
|
||||
} finally {
|
||||
await rm(scratchRoot, { force: true, recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -77,66 +77,6 @@ describe("buildUpdateManifest", () => {
|
||||
expect(Object.keys(manifest.platforms)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("maps a Windows NSIS setup artifact to windows-x86_64", () => {
|
||||
const dir = makeUniversalArtifactDir();
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_x64-setup.exe.sig"),
|
||||
"sig-windows-x64\n",
|
||||
);
|
||||
const manifest = buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(manifest.platforms["windows-x86_64"]).toEqual({
|
||||
signature: "sig-windows-x64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x64-setup.exe",
|
||||
});
|
||||
// darwin entries from the universal artifact are unaffected.
|
||||
expect(Object.keys(manifest.platforms).sort()).toEqual([
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"windows-x86_64",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores non-updater exe files without a setup arch suffix", () => {
|
||||
const dir = makeUniversalArtifactDir();
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64.exe"), "exe");
|
||||
const manifest = buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
expect(Object.keys(manifest.platforms).sort()).toEqual([
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws when a Windows setup artifact is missing its signature", () => {
|
||||
const dir = makeUniversalArtifactDir();
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("throws when universal and per-arch artifacts claim the same platform", () => {
|
||||
const dir = makePerArchArtifactDir();
|
||||
writeFileSync(
|
||||
|
||||
@@ -24,25 +24,17 @@ export type UpdateManifest = {
|
||||
platforms: Record<string, UpdaterPlatformEntry>;
|
||||
};
|
||||
|
||||
// Maps the arch token embedded in macOS artifact file names (see the "Collect
|
||||
// Maps the arch token embedded in artifact file names (see the "Collect
|
||||
// artifacts" workflow step) to the platform keys the Tauri updater requests.
|
||||
// A universal (fat) bundle serves both macOS architectures: each slice of the
|
||||
// installed app requests its own compile-time arch key at runtime, and both
|
||||
// keys point at the same artifact and signature.
|
||||
const MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
|
||||
const PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
|
||||
aarch64: ["darwin-aarch64"],
|
||||
x86_64: ["darwin-x86_64"],
|
||||
universal: ["darwin-aarch64", "darwin-x86_64"],
|
||||
};
|
||||
|
||||
// On Windows the updater artifact is the NSIS installer itself
|
||||
// (createUpdaterArtifacts signs the setup exe with the updater key), named
|
||||
// `<Product>_<version>_<arch>-setup.exe` by the Tauri bundler.
|
||||
const WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
|
||||
x64: ["windows-x86_64"],
|
||||
arm64: ["windows-aarch64"],
|
||||
};
|
||||
|
||||
const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
const index = args.indexOf(name);
|
||||
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
|
||||
@@ -53,22 +45,13 @@ const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
return inline?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const platformKeysOfUpdaterArtifact = (
|
||||
fileName: string,
|
||||
): string[] | undefined => {
|
||||
if (fileName.endsWith(".app.tar.gz")) {
|
||||
const arch = Object.keys(MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
|
||||
(candidate) => fileName.includes(`_${candidate}`),
|
||||
);
|
||||
return arch ? MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
|
||||
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
|
||||
if (!fileName.endsWith(".app.tar.gz")) {
|
||||
return undefined;
|
||||
}
|
||||
if (fileName.endsWith("-setup.exe")) {
|
||||
const arch = Object.keys(WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
|
||||
(candidate) => fileName.endsWith(`_${candidate}-setup.exe`),
|
||||
);
|
||||
return arch ? WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
return Object.keys(PLATFORM_KEYS_BY_ARCH_SUFFIX).find((arch) =>
|
||||
fileName.includes(`_${arch}`),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildUpdateManifest = (options: {
|
||||
@@ -82,8 +65,8 @@ export const buildUpdateManifest = (options: {
|
||||
const platforms: Record<string, UpdaterPlatformEntry> = {};
|
||||
|
||||
for (const fileName of readdirSync(options.dir).sort()) {
|
||||
const platformKeys = platformKeysOfUpdaterArtifact(fileName);
|
||||
if (!platformKeys) {
|
||||
const arch = archOfUpdaterArtifact(fileName);
|
||||
if (!arch) {
|
||||
continue;
|
||||
}
|
||||
const signaturePath = path.join(options.dir, `${fileName}.sig`);
|
||||
@@ -91,7 +74,7 @@ export const buildUpdateManifest = (options: {
|
||||
if (!signature) {
|
||||
throw new Error(`empty updater signature at ${signaturePath}`);
|
||||
}
|
||||
for (const platformKey of platformKeys) {
|
||||
for (const platformKey of PLATFORM_KEYS_BY_ARCH_SUFFIX[arch]) {
|
||||
if (platforms[platformKey]) {
|
||||
throw new Error(
|
||||
`multiple updater artifacts claim platform ${platformKey}; found ${fileName} after ${platforms[platformKey].url}`,
|
||||
@@ -106,7 +89,7 @@ export const buildUpdateManifest = (options: {
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
throw new Error(
|
||||
`no updater artifacts (*.app.tar.gz or *-setup.exe with a known arch suffix) found in ${options.dir}`,
|
||||
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +121,7 @@ const main = () => {
|
||||
|
||||
const notes = notesFile
|
||||
? readFileSync(notesFile, "utf8").trim()
|
||||
: `Cline v${version}`;
|
||||
: `Cline Code v${version}`;
|
||||
|
||||
const manifest = buildUpdateManifest({
|
||||
version,
|
||||
|
||||
@@ -15,7 +15,7 @@ const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline";
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Authenticode-signs one PE file with Azure Trusted Signing via jsign.
|
||||
#
|
||||
# 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
|
||||
# NSIS uninstaller, and the NSIS installer itself.
|
||||
#
|
||||
# Requirements (all provided by the desktop-publish Windows job):
|
||||
# - an azure/login OIDC session (jsign's token comes from `az account get-access-token`)
|
||||
# - AZURE_TRUSTED_SIGNING_ENDPOINT / _ACCOUNT_NAME / _CERTIFICATE_PROFILE env vars
|
||||
# - java on PATH (preinstalled on GitHub Windows runners)
|
||||
#
|
||||
# Mirrors the CLI pipeline (.github/actions/sign-windows-cli): same jsign
|
||||
# version and flags, same Microsoft timestamp service. Kept as a standalone
|
||||
# script so the signing behavior is reviewable in the repo rather than inlined
|
||||
# in a generated config string.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$jsignVersion = "7.5"
|
||||
$jsignSha256 = "602A51C3545A6DC4FB99BD2EA7152B26D1345916D0C93DDFBD5936CB735AF91C"
|
||||
|
||||
$endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
|
||||
$account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
|
||||
$certProfile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
|
||||
if (-not $endpoint -or -not $account -or -not $certProfile) {
|
||||
throw "Azure Trusted Signing env vars are not set (AZURE_TRUSTED_SIGNING_ENDPOINT/_ACCOUNT_NAME/_CERTIFICATE_PROFILE)"
|
||||
}
|
||||
|
||||
$resolved = (Resolve-Path $Path).Path
|
||||
|
||||
# jsign expects the endpoint host; tolerate the portal's trailing-slash form.
|
||||
$keystore = $endpoint -replace '^https://', '' -replace '/$', ''
|
||||
|
||||
$jar = Join-Path $env:RUNNER_TEMP "jsign-$jsignVersion.jar"
|
||||
if (-not (Test-Path $jar)) {
|
||||
Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$jsignVersion/jsign-$jsignVersion.jar" -OutFile $jar
|
||||
}
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 $jar).Hash
|
||||
if ($actualHash -ne $jsignSha256) {
|
||||
Remove-Item $jar -Force
|
||||
throw "jsign jar checksum mismatch: expected $jsignSha256, got $actualHash"
|
||||
}
|
||||
|
||||
# Short-lived bearer token from the azure/login OIDC session. Fetched per
|
||||
# invocation (signCommand runs once per file) so a long Rust build beforehand
|
||||
# can never leave us with an expired token. Passed to jsign via env, not argv.
|
||||
$env:JSIGN_STOREPASS = (az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
|
||||
if (-not $env:JSIGN_STOREPASS) {
|
||||
throw "failed to acquire an Azure access token; is azure/login configured on this job?"
|
||||
}
|
||||
|
||||
Write-Host "Signing $resolved"
|
||||
java -jar $jar `
|
||||
--storetype TRUSTEDSIGNING `
|
||||
--keystore $keystore `
|
||||
--storepass env:JSIGN_STOREPASS `
|
||||
--alias "$account/$certProfile" `
|
||||
--alg SHA-256 `
|
||||
--tsaurl http://timestamp.acs.microsoft.com `
|
||||
--tsmode RFC3161 `
|
||||
--replace `
|
||||
$resolved
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "jsign failed for $resolved (exit $LASTEXITCODE)"
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature $resolved
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "signature verification failed for ${resolved}: $($signature.Status) - $($signature.StatusMessage)"
|
||||
}
|
||||
Write-Host "Signed and verified: $resolved ($($signature.SignerCertificate.Subject))"
|
||||
@@ -49,7 +49,7 @@ const sessionManager = await ClineCore.create({
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
@@ -175,8 +175,7 @@ Supported commands:
|
||||
## Dev Workflow
|
||||
|
||||
```bash
|
||||
bun run dev:headless # Start sidecar and Next.js with a fresh shared approval credential
|
||||
bun run dev:sidecar # Start only the sidecar (no browser approval surface)
|
||||
bun run dev:web # Start only Next.js (no authenticated approval connection)
|
||||
bun run dev:sidecar # Start sidecar on port 3126
|
||||
bun run dev:web # Start Next.js on port 3125
|
||||
bun run dev # Both concurrently
|
||||
```
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import { handleCoreSessionEvent } from "./context";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("resolveDesktopSessionMode", () => {
|
||||
@@ -462,89 +461,6 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("forks trimmed messages without restoring when the edited run has no checkpoint", async () => {
|
||||
const sourceSessionId = `source-imported-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "imported prompt" },
|
||||
{ role: "assistant" as const, content: "imported response" },
|
||||
{ role: "user" as const, content: "prompt to edit" },
|
||||
{ role: "assistant" as const, content: "response to replace" },
|
||||
];
|
||||
const expectedMessages = sourceMessages.slice(0, 2);
|
||||
const start = vi.fn(async () => ({ sessionId: "imported-fork" }));
|
||||
const restore = vi.fn(async () => {
|
||||
throw new Error("restore must not run without a checkpoint");
|
||||
});
|
||||
const readMessages = vi.fn(async () => expectedMessages);
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sourceSessionId,
|
||||
{
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
messages: sourceMessages,
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
importedFrom: { tool: "codex", sourceId: "cdx-1" },
|
||||
},
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
forkBeforeRunCount: 2,
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
})) as { sessionId: string };
|
||||
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialMessages: expectedMessages,
|
||||
sessionMetadata: expect.objectContaining({
|
||||
fork: expect.objectContaining({
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
beforeRunCount: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.sessionId).toBe("imported-fork");
|
||||
expect(ctx.liveSessions.has(sourceSessionId)).toBe(false);
|
||||
expect(ctx.liveSessions.get("imported-fork")?.messages).toEqual(
|
||||
expectedMessages,
|
||||
);
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full-history fork on the current workspace without restoring", async () => {
|
||||
const sourceSessionId = `source-full-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
@@ -726,81 +642,6 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("allows a workspace restore after a queued turn completes through the event stream", async () => {
|
||||
const sessionId = `queued-turn-session-${Date.now()}`;
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-queued-restore-"));
|
||||
const originalDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
process.env.CLINE_SESSION_DATA_DIR = dataDir;
|
||||
try {
|
||||
const restore = vi.fn(async () => ({
|
||||
sessionId,
|
||||
messages: [{ role: "user", content: "first prompt" }],
|
||||
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
|
||||
}));
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: { cwd: "/workspace/project" },
|
||||
messages: [{ role: "user", content: "first prompt" }],
|
||||
promptsInQueue: [],
|
||||
// A drained queued turn is running: no send() RPC owns
|
||||
// this turn's busy flag, only the event stream does.
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: { restore },
|
||||
} as unknown as SidecarContext;
|
||||
const restoreRequest = {
|
||||
action: "restore_checkpoint" as const,
|
||||
sessionId,
|
||||
checkpointRunCount: 1,
|
||||
config: {
|
||||
cwd: "/workspace/project",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
},
|
||||
};
|
||||
|
||||
// While the queued turn is still running the workspace stays locked.
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, restoreRequest),
|
||||
).rejects.toThrow("Wait for all turns in this workspace to finish");
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
|
||||
// The queued turn settles through the event stream: the runtime
|
||||
// host reports the session back at idle (there is no send() RPC
|
||||
// response to clear the busy flag for event-settled turns).
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "status",
|
||||
payload: { sessionId, status: "idle" },
|
||||
});
|
||||
expect(ctx.liveSessions.get(sessionId)).toMatchObject({
|
||||
busy: false,
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, restoreRequest),
|
||||
).resolves.toMatchObject({ sessionId });
|
||||
expect(restore).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = originalDataDir;
|
||||
}
|
||||
rmSync(dataDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks sends from sibling sessions while their workspace is restored", async () => {
|
||||
const send = vi.fn();
|
||||
const sessionId = "workspace-sibling-session";
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
createUserInstructionConfigService,
|
||||
findCheckpointForRun,
|
||||
getCoreBuiltinToolCatalog,
|
||||
isSkillsToolAvailable,
|
||||
projectSessionCompactionState,
|
||||
readGlobalSettings,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
@@ -1301,18 +1299,8 @@ async function handleForkUnlocked(
|
||||
sessionMetadata: forkMetadata,
|
||||
toolPolicies: resolveToolPolicies(forkConfig),
|
||||
};
|
||||
// Sessions without a checkpoint at or before the edited run (imported
|
||||
// transcripts, checkpoints disabled) have no workspace state to roll back,
|
||||
// so fork the trimmed messages onto the current workspace instead of
|
||||
// failing the edit.
|
||||
const canRestoreWorkspace =
|
||||
forkBeforeRunCount !== undefined &&
|
||||
findCheckpointForRun(
|
||||
readSessionCheckpointHistory({ metadata: sourceMetadata }),
|
||||
forkBeforeRunCount,
|
||||
) !== undefined;
|
||||
let newSessionId: string;
|
||||
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
|
||||
if (forkBeforeRunCount !== undefined) {
|
||||
const cwd =
|
||||
restoreWorkspacePath ||
|
||||
(typeof forkConfig.cwd === "string" && forkConfig.cwd.trim()) ||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { SidecarContext } from "./types";
|
||||
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -22,7 +21,6 @@ vi.mock("@cline/core", async () => {
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
@@ -52,7 +50,6 @@ beforeEach(() => {
|
||||
clineAccountServiceCtorMock.mockReset();
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -143,162 +140,3 @@ describe("cline_account command auth states", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature-flag identity is otherwise resolved once at sidecar startup, so these
|
||||
* cover the mid-session transitions that would otherwise keep evaluating flags
|
||||
* against a stale account (or the device).
|
||||
*/
|
||||
describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
async function currentFlagsUserId(): Promise<string | undefined> {
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
return getDesktopFeatureFlagsContext().userId ?? undefined;
|
||||
}
|
||||
|
||||
async function runOperation(ctx: SidecarContext, operation: string) {
|
||||
const { handleCommand } = await import("./commands");
|
||||
return handleCommand(ctx, "cline_account", {
|
||||
action: "clineAccount",
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { resetDesktopFeatureFlagsForTesting } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
resetDesktopFeatureFlagsForTesting();
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
});
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue(undefined);
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
auth: { accountId: "stale-acct" },
|
||||
});
|
||||
|
||||
await runOperation(ctx, "switchAccount");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("adopts the identity from the refetch that follows a switch", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue(undefined);
|
||||
await runOperation(ctx, "switchAccount");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-2" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-2");
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
// Signed out: no token resolves.
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears the identity when sign-out blanks the cline auth settings", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
// What the Sign Out button actually sends: a settings write that blanks
|
||||
// the auth block. No account command is involved.
|
||||
getProviderSettingsMock.mockReturnValue({ auth: { accountId: "" } });
|
||||
saveProviderSettingsMock.mockReturnValue({
|
||||
providerId: "cline",
|
||||
enabled: true,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "cline",
|
||||
api_key: "",
|
||||
settings: { auth: { accessToken: "", refreshToken: "", accountId: "" } },
|
||||
});
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores settings writes for other providers", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
saveProviderSettingsMock.mockReturnValue({
|
||||
providerId: "anthropic",
|
||||
enabled: true,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "anthropic",
|
||||
api_key: "sk-test",
|
||||
});
|
||||
|
||||
// Saving an unrelated provider must not disturb the Cline identity.
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("falls back to the device distinct ID after logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
const deviceId = getDesktopFeatureFlagsContext().distinctId;
|
||||
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe("acct-1");
|
||||
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
// Not left on the previous account's ID.
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe(deviceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidecarContext, SidecarWebSocketClient } from "./types";
|
||||
|
||||
const upgradeManagedHubMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
upgradeManagedHub: upgradeManagedHubMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createContext(): SidecarContext {
|
||||
return {
|
||||
workspaceRoot: "/workspace",
|
||||
wsClients: new Set(),
|
||||
hubBuildMismatch: {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
reason: "outdated_hub",
|
||||
expectedBuildId: "current-build",
|
||||
},
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
}
|
||||
|
||||
function connection(canApproveTools: boolean): SidecarWebSocketClient {
|
||||
return { data: { canApproveTools } } as unknown as SidecarWebSocketClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
upgradeManagedHubMock.mockReset();
|
||||
});
|
||||
|
||||
describe("hub_upgrade command", () => {
|
||||
it("rejects connections without the approval token, before touching the hub", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(false) }),
|
||||
).rejects.toThrow(/trusted desktop connection/);
|
||||
await expect(handleCommand(ctx, "hub_upgrade", {}, {})).rejects.toThrow(
|
||||
/trusted desktop connection/,
|
||||
);
|
||||
expect(upgradeManagedHubMock).not.toHaveBeenCalled();
|
||||
// The pending mismatch must survive a refused request.
|
||||
expect(ctx.hubBuildMismatch).not.toBeNull();
|
||||
});
|
||||
|
||||
it("forces the upgrade for the trusted webview connection and clears the mismatch", async () => {
|
||||
upgradeManagedHubMock.mockResolvedValue({
|
||||
outcome: "replaced",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
activeSessionCount: 2,
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createContext();
|
||||
|
||||
const result = await handleCommand(
|
||||
ctx,
|
||||
"hub_upgrade",
|
||||
{},
|
||||
{ connection: connection(true) },
|
||||
);
|
||||
|
||||
expect(upgradeManagedHubMock).toHaveBeenCalledWith({
|
||||
workspaceRoot: "/workspace",
|
||||
force: true,
|
||||
reason: "Cline Desktop hub update",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
outcome: "replaced",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
interruptedSessionCount: 2,
|
||||
});
|
||||
expect(ctx.hubBuildMismatch).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a newer running hub as an error instead of replacing it", async () => {
|
||||
upgradeManagedHubMock.mockResolvedValue({
|
||||
outcome: "hub_not_older",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(true) }),
|
||||
).rejects.toThrow(/newer than this app/);
|
||||
expect(ctx.hubBuildMismatch).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
resolveGitHubInstallUrl,
|
||||
} from "./commands-integrations";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const REQUEST_OPTIONS = {
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
appBaseUrl: "https://app.example.com",
|
||||
authToken: "test-token",
|
||||
} as const;
|
||||
|
||||
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
...REQUEST_OPTIONS,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("listClineIntegrations", () => {
|
||||
it("lists integrations through the envelope with a bearer token", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineIntegrations(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer test-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listClineGitHubRepositories", () => {
|
||||
it("lists GitHub repositories from the repositories endpoint", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ full_name: "cline/cline" }]);
|
||||
expect(String(fetchImpl.mock.calls[0][0])).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/repositories",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the API envelope error message on failures", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: "failed to list integrations" },
|
||||
500,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
listClineIntegrations(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("failed to list integrations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitHubInstallUrl", () => {
|
||||
it("resolves the GitHub install URL from the redirect location", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
});
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/install",
|
||||
);
|
||||
// The post-install browser hop must land on the Cline dashboard.
|
||||
expect(url.searchParams.get("redirect")).toBe(
|
||||
"https://app.example.com/dashboard/integrations",
|
||||
);
|
||||
// The redirect must be read, not followed: the Location URL is the result.
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("resolves a relative redirect location against the request URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "//github.com/apps/cline/installations/new" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
// A bare relative Location would blow up later in the URL opener.
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://evil.example/apps/cline", "evil.example"],
|
||||
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
|
||||
// Subdomains are not part of the install flow, so they are not allowed
|
||||
// either -- the host must be exactly github.com.
|
||||
["https://gist.github.com/apps/cline", "gist.github.com"],
|
||||
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(null, { status: 302, headers: { location } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow(`unexpected host: ${host}`);
|
||||
});
|
||||
|
||||
it("rejects a redirect that does not use https", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://github.com/apps/cline" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("must use https");
|
||||
});
|
||||
|
||||
it("rejects a redirect location that is not a usable URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("not a valid URL");
|
||||
});
|
||||
|
||||
it("throws when the install endpoint does not answer with a redirect", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ error: "authentication required" }, 401),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("authentication required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline_integrations command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the Cline API with the resolved fresh token when signed in", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toContain("/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer fresh-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown operations", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
await expect(
|
||||
handleCommand(ctx, "cline_integrations", {
|
||||
operation: "dropIntegrations",
|
||||
}),
|
||||
).rejects.toThrow("Unsupported Cline integrations operation");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import type {
|
||||
ClineGitHubRepository,
|
||||
ClineIntegration,
|
||||
} from "../webview/lib/cline-integrations-types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const GITHUB_INSTALL_HOST = "github.com";
|
||||
|
||||
function resolveInstallRedirect(location: string, requestUrl: URL): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
|
||||
}
|
||||
if (resolved.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`GitHub install redirect must use https, got: ${resolved.protocol}`,
|
||||
);
|
||||
}
|
||||
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
|
||||
throw new Error(
|
||||
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
|
||||
);
|
||||
}
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
export interface ClineIntegrationsRequestOptions {
|
||||
apiBaseUrl: string;
|
||||
/** Frontend origin the browser install flow returns to when it finishes. */
|
||||
appBaseUrl: string;
|
||||
authToken: string;
|
||||
requestTimeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function listClineIntegrations(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineIntegration[]> {
|
||||
const data = await requestClineApiJson("/api/v1/integrations", options);
|
||||
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
|
||||
}
|
||||
|
||||
export async function listClineGitHubRepositories(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineGitHubRepository[]> {
|
||||
const data = await requestClineApiJson(
|
||||
"/api/v1/integrations/github/repositories",
|
||||
options,
|
||||
);
|
||||
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
|
||||
}
|
||||
|
||||
export async function resolveGitHubInstallUrl(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<{ url: string }> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const installUrl = new URL(
|
||||
"/api/v1/integrations/github/install",
|
||||
options.apiBaseUrl,
|
||||
);
|
||||
installUrl.searchParams.set(
|
||||
"redirect",
|
||||
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(installUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${options.authToken}` },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (response.status >= 300 && response.status < 400 && location?.trim()) {
|
||||
return { url: resolveInstallRedirect(location.trim(), installUrl) };
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = text.trim() ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvelopeError(parsed: unknown): string | undefined {
|
||||
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const error = (parsed as { error?: unknown }).error;
|
||||
return typeof error === "string" && error.trim() ? error : undefined;
|
||||
}
|
||||
|
||||
function formatRequestFailure(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
parsed: unknown,
|
||||
): string {
|
||||
const envelopeError = getEnvelopeError(parsed);
|
||||
if (envelopeError) {
|
||||
return envelopeError;
|
||||
}
|
||||
const body = bodyText.trim();
|
||||
if (body) {
|
||||
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
|
||||
return `Cline integrations request failed with status ${status}: ${preview}`;
|
||||
}
|
||||
return `Cline integrations request failed with status ${status}`;
|
||||
}
|
||||
|
||||
async function requestClineApiJson(
|
||||
endpoint: string,
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<unknown> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
formatRequestFailure(response.status, text, undefined),
|
||||
);
|
||||
}
|
||||
throw new Error("Cline integrations response was not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
}
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
|
||||
const envelope = parsed as {
|
||||
success?: unknown;
|
||||
error?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
if (typeof envelope.success === "boolean") {
|
||||
if (!envelope.success) {
|
||||
throw new Error(
|
||||
getEnvelopeError(parsed) || "Cline integrations request failed",
|
||||
);
|
||||
}
|
||||
return envelope.data ?? null;
|
||||
}
|
||||
}
|
||||
return parsed ?? null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
createUserInstructionConfigService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
fetchClineRecommendedModels,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
listHookConfigFiles,
|
||||
@@ -35,10 +34,6 @@ import {
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SESSION_IMPORT_TOOLS,
|
||||
type SessionImportRequest,
|
||||
SessionImportService,
|
||||
type SessionImportTool,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
saveVoiceInputSettings,
|
||||
@@ -49,13 +44,10 @@ import {
|
||||
transcribeConfiguredVoiceInput,
|
||||
updateLocalProvider,
|
||||
updateMcpSettingsFileSync,
|
||||
upgradeManagedHub,
|
||||
} from "@cline/core";
|
||||
import { resolveAudioTranscriptionRoute } from "@cline/llms";
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
formatSessionSearchPreview,
|
||||
formatSessionSearchTitle,
|
||||
getClineEnvironmentConfig,
|
||||
isCanonicalBase64,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
@@ -66,11 +58,6 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
resolveGitHubInstallUrl,
|
||||
} from "./commands-integrations";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -80,12 +67,7 @@ import {
|
||||
broadcastEvent,
|
||||
ensureSharedHubClient,
|
||||
resolveSidecarAskQuestion,
|
||||
sendEventToClient,
|
||||
} from "./context";
|
||||
import {
|
||||
identifyDesktopFeatureFlagsAccount,
|
||||
refreshDesktopFeatureFlags,
|
||||
} from "./feature-flags";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -125,7 +107,6 @@ import type {
|
||||
ChatSessionCommandRequest,
|
||||
JsonRecord,
|
||||
SidecarContext,
|
||||
SidecarWebSocketClient,
|
||||
} from "./types";
|
||||
import { pickWorkspaceDirectory } from "./workspace-picker";
|
||||
|
||||
@@ -310,33 +291,6 @@ function removePathIfExists(
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function syncFeatureFlagsAccountFromResult(
|
||||
ctx: SidecarContext,
|
||||
operation: string,
|
||||
result: unknown,
|
||||
): void {
|
||||
if (operation === "fetchMe") {
|
||||
const user = result as { id?: string; email?: string } | undefined;
|
||||
if (user?.id) {
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: user.id, email: user.email },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFeatureFlagsAccountFromSettings(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
): void {
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveFreshClineAuthToken(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
@@ -546,67 +500,6 @@ async function listSessionsFromSidecarManager(
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
async function withSearchDeadline<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Session search timed out")),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function metadataSessionSearchHits(
|
||||
value: unknown,
|
||||
query: string,
|
||||
): JsonRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const session = item as JsonRecord;
|
||||
const metadata =
|
||||
session.metadata && typeof session.metadata === "object"
|
||||
? (session.metadata as JsonRecord)
|
||||
: {};
|
||||
const sessionId = String(session.sessionId ?? "").trim();
|
||||
if (!sessionId) return [];
|
||||
const rawTitle = String(
|
||||
metadata.title ?? session.title ?? session.prompt ?? sessionId,
|
||||
).trim();
|
||||
const prompt = String(session.prompt ?? metadata.prompt ?? "");
|
||||
const title = formatSessionSearchTitle(rawTitle) || sessionId;
|
||||
const workspaceRoot = String(session.workspaceRoot ?? session.cwd ?? "");
|
||||
const searchable = [rawTitle, prompt, workspaceRoot, session.model]
|
||||
.join("\n")
|
||||
.toLocaleLowerCase();
|
||||
if (!searchable.includes(normalizedQuery)) return [];
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
documentId: `${sessionId}:metadata`,
|
||||
ordinal: -1,
|
||||
role: "session",
|
||||
startedAt: String(session.startedAt ?? session.createdAt ?? ""),
|
||||
workspaceRoot,
|
||||
title,
|
||||
snippet: formatSessionSearchPreview("session", prompt || title),
|
||||
score: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -689,15 +582,7 @@ async function handleRoutineScheduleCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => {
|
||||
// The desktop app runs chats (and therefore agent-created schedules)
|
||||
// across many workspace folders, while this hub client is registered
|
||||
// against the app's own launch directory. Ask the hub for schedules
|
||||
// across all workspaces so the Schedules page manages every schedule
|
||||
// on this machine, not just the launch-directory scope.
|
||||
const reply = await hubClient.command(hubCommand as never, {
|
||||
...payload,
|
||||
allWorkspaces: true,
|
||||
});
|
||||
const reply = await hubClient.command(hubCommand as never, payload);
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -856,43 +741,6 @@ async function handleRoutineScheduleCommand(
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agenda task queue helpers (in-process via shared hub server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGENDA_TASK_COMMANDS = new Set([
|
||||
"task.create",
|
||||
"task.list",
|
||||
"task.get",
|
||||
"task.update",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.get",
|
||||
"task.automation.set",
|
||||
]);
|
||||
|
||||
const AGENDA_TASK_EXECUTION_COMMANDS = new Set([
|
||||
"task.create",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.set",
|
||||
]);
|
||||
|
||||
async function handleAgendaTaskCommand(
|
||||
ctx: SidecarContext,
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const reply = await hubClient.command(command as never, args);
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
return reply.payload ?? {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User instruction config listing through the core config service.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1301,7 +1149,7 @@ export async function handleCommand(
|
||||
ctx: SidecarContext,
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
options?: { connection?: SidecarWebSocketClient },
|
||||
options?: { connection?: object },
|
||||
): Promise<unknown> {
|
||||
// ── Chat session commands ──────────────────────────────────────────
|
||||
if (command === "chat_session_command") {
|
||||
@@ -1388,54 +1236,11 @@ export async function handleCommand(
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Managed hub upgrade ───────────────────────────────────────────
|
||||
if (command === "hub_upgrade") {
|
||||
// Replacing the shared Hub interrupts other clients' sessions, so it
|
||||
// carries the same per-connection gate as the tool-approval commands:
|
||||
// only the webview connection dialed with the approval token may ask,
|
||||
// never an arbitrary local WebSocket client.
|
||||
if (!options?.connection?.data?.canApproveTools) {
|
||||
throw new Error("hub upgrade requires a trusted desktop connection");
|
||||
}
|
||||
// Only reached after the user accepted the blocking "Hub update
|
||||
// required" dialog, so force: the old Hub is replaced even though it
|
||||
// is still serving other clients' sessions. Drain-first semantics
|
||||
// still give in-flight turns the wait window to finish.
|
||||
const result = await upgradeManagedHub({
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
force: true,
|
||||
reason: "Cline Desktop hub update",
|
||||
});
|
||||
if (result.outcome === "hub_not_older") {
|
||||
throw new Error(
|
||||
"The running Cline Hub is newer than this app, so it was not replaced. Update Cline instead.",
|
||||
);
|
||||
}
|
||||
if (result.outcome === "still_busy") {
|
||||
throw new Error(
|
||||
"The running Cline Hub picked up new sessions before it could be replaced, so it was left running. Try again.",
|
||||
);
|
||||
}
|
||||
// The mismatch is resolved: a null broadcast closes the dialog in
|
||||
// every connected webview and stops the replay-on-connect.
|
||||
ctx.hubBuildMismatch = null;
|
||||
broadcastEvent(ctx, "hub_build_mismatch", null);
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
url: result.url ?? null,
|
||||
interruptedSessionCount: result.activeSessionCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tool approvals (in-memory) ────────────────────────────────────
|
||||
if (command === "poll_tool_approvals") {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
const connection = options?.connection;
|
||||
if (!connection?.data?.canApproveTools) {
|
||||
throw new Error("tool approvals require a trusted desktop connection");
|
||||
}
|
||||
return Array.from(ctx.pendingApprovals.values())
|
||||
.filter((a) => a.owner === connection && a.item.sessionId === sessionId)
|
||||
.filter((a) => a.item.sessionId === sessionId)
|
||||
.map((a) => a.item);
|
||||
}
|
||||
if (command === "respond_tool_approval") {
|
||||
@@ -1444,28 +1249,20 @@ export async function handleCommand(
|
||||
if (!sessionId || !requestId) {
|
||||
throw new Error("sessionId and requestId are required");
|
||||
}
|
||||
const connection = options?.connection;
|
||||
if (!connection?.data?.canApproveTools) {
|
||||
throw new Error("tool approvals require a trusted desktop connection");
|
||||
}
|
||||
const pending = ctx.pendingApprovals.get(requestId);
|
||||
if (!pending || pending.owner !== connection) {
|
||||
throw new Error("tool approval does not belong to this connection");
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
approved: Boolean(args?.approved),
|
||||
...(typeof args?.reason === "string" && args.reason.trim().length > 0
|
||||
? { reason: args.reason.trim() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (pending.item.sessionId !== sessionId) {
|
||||
throw new Error("tool approval does not belong to this session");
|
||||
}
|
||||
pending.resolve({
|
||||
approved: Boolean(args?.approved),
|
||||
...(typeof args?.reason === "string" && args.reason.trim().length > 0
|
||||
? { reason: args.reason.trim() }
|
||||
: {}),
|
||||
});
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
const remaining = Array.from(ctx.pendingApprovals.values())
|
||||
.filter((a) => a.owner === connection && a.item.sessionId === sessionId)
|
||||
.filter((a) => a.item.sessionId === sessionId)
|
||||
.map((a) => a.item);
|
||||
sendEventToClient(ctx, connection, "tool_approval_state", {
|
||||
broadcastEvent(ctx, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: remaining,
|
||||
});
|
||||
@@ -1510,105 +1307,11 @@ export async function handleCommand(
|
||||
typeof args?.limit === "number" ? args.limit : 300,
|
||||
);
|
||||
}
|
||||
if (command === "search_sessions") {
|
||||
const query = String(args?.query ?? "").trim();
|
||||
if (!query) return [];
|
||||
const limit =
|
||||
typeof args?.limit === "number" && Number.isFinite(args.limit)
|
||||
? Math.max(1, Math.min(200, Math.trunc(args.limit)))
|
||||
: 50;
|
||||
const workspaceRoot =
|
||||
typeof args?.workspaceRoot === "string"
|
||||
? args.workspaceRoot.trim() || undefined
|
||||
: undefined;
|
||||
if (ctx.hubClient) {
|
||||
try {
|
||||
const reply = await withSearchDeadline(
|
||||
ctx.hubClient.command("session.search", {
|
||||
query,
|
||||
limit,
|
||||
workspaceRoot,
|
||||
}),
|
||||
750,
|
||||
);
|
||||
if (
|
||||
reply.ok &&
|
||||
Array.isArray(reply.payload?.hits) &&
|
||||
reply.payload.hits.length > 0
|
||||
) {
|
||||
return reply.payload.hits.slice(0, limit).map((hit) => ({
|
||||
...hit,
|
||||
title: formatSessionSearchTitle(hit.title),
|
||||
snippet: formatSessionSearchPreview(hit.role, hit.snippet),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Fall back to metadata-only search when the index is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = await withSearchDeadline(
|
||||
listSessionsFromSidecarManager(ctx, 500),
|
||||
1_000,
|
||||
).catch(() => []);
|
||||
return metadataSessionSearchHits(sessions, query).slice(0, limit);
|
||||
}
|
||||
if (command === "get_discovered_session") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
return (await getSessionFromSidecarManager(ctx, sessionId)) ?? null;
|
||||
}
|
||||
|
||||
// ── Session import from other coding tools ────────────────────────
|
||||
if (command === "list_importable_sessions") {
|
||||
const backend = await resolveSessionBackend({ backendMode: "local" });
|
||||
const importer = new SessionImportService(backend);
|
||||
return {
|
||||
installedTools: importer.installedTools(),
|
||||
sessions: await importer.discover(),
|
||||
};
|
||||
}
|
||||
if (command === "import_sessions") {
|
||||
const rawSelections = Array.isArray(args?.selections)
|
||||
? args.selections
|
||||
: [];
|
||||
const requests: SessionImportRequest[] = [];
|
||||
for (const selection of rawSelections) {
|
||||
if (!selection || typeof selection !== "object") continue;
|
||||
const tool = String((selection as JsonRecord).tool ?? "").trim();
|
||||
const sourceId = String((selection as JsonRecord).sourceId ?? "").trim();
|
||||
if (!sourceId) continue;
|
||||
if (!(SESSION_IMPORT_TOOLS as readonly string[]).includes(tool)) {
|
||||
continue;
|
||||
}
|
||||
requests.push({ tool: tool as SessionImportTool, sourceId });
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
throw new Error("at least one { tool, sourceId } selection is required");
|
||||
}
|
||||
const backend = await resolveSessionBackend({ backendMode: "local" });
|
||||
const importer = new SessionImportService(backend);
|
||||
// Opening a history session resumes on the row's provider/model, so the
|
||||
// UI passes what a new chat would run on; the source tool's own
|
||||
// provider/model stay in metadata.importedFrom.
|
||||
// Never let the source tool's provider become the resume target: when
|
||||
// the caller sends no selection, use the app default like other
|
||||
// server-started sessions do.
|
||||
const provider = asTrimmedString(args?.provider) ?? "cline";
|
||||
const model = asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID;
|
||||
const results = await importer.importMany(
|
||||
requests,
|
||||
(result, index) => {
|
||||
broadcastEvent(ctx, "session_import_progress", {
|
||||
index,
|
||||
total: requests.length,
|
||||
result,
|
||||
});
|
||||
},
|
||||
{ provider, model },
|
||||
);
|
||||
return { results };
|
||||
}
|
||||
if (command === "update_chat_session_title") {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
@@ -1648,7 +1351,7 @@ export async function handleCommand(
|
||||
const result = await backend.updateSession({ sessionId, metadata: merged });
|
||||
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
|
||||
// Annotating a session is not session activity. updateSession stamps
|
||||
// updated_at, which clients sort and label rows by, so a pin would
|
||||
// updated_at, which clients sort and label rows by, so a favorite would
|
||||
// otherwise make an old session look like it just ran.
|
||||
if (existing?.updatedAt) {
|
||||
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
|
||||
@@ -1793,14 +1496,6 @@ export async function handleCommand(
|
||||
// would be captured as error telemetry and shown raw to the user.
|
||||
const authToken = await resolveFreshClineAuthToken(ctx, manager);
|
||||
if (!authToken) {
|
||||
// Backstop for credentials that go away without a settings write —
|
||||
// an expired or server-revoked token. Explicit sign-out is handled
|
||||
// at its source in `save_provider_settings`; this catches the rest
|
||||
// so a stale account never keeps serving its rollout cohort.
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
@@ -1809,43 +1504,10 @@ export async function handleCommand(
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
const result = await executeClineAccountAction(
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
syncFeatureFlagsAccountFromResult(ctx, operation, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Cline integrations (GitHub App) ────────────────────────────────
|
||||
if (command === "cline_integrations") {
|
||||
const operation = String(args?.operation ?? "").trim();
|
||||
if (!operation) throw new Error("operation is required");
|
||||
const manager = new ProviderSettingsManager();
|
||||
|
||||
const authToken = await resolveFreshClineAuthToken(ctx, manager);
|
||||
if (!authToken) {
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
const environment = getClineEnvironmentConfig();
|
||||
const requestOptions = {
|
||||
apiBaseUrl: settings?.baseUrl?.trim() || environment.apiBaseUrl,
|
||||
appBaseUrl: environment.appBaseUrl,
|
||||
authToken,
|
||||
};
|
||||
switch (operation) {
|
||||
case "list":
|
||||
return await listClineIntegrations(requestOptions);
|
||||
case "listGitHubRepositories":
|
||||
return await listClineGitHubRepositories(requestOptions);
|
||||
case "githubInstallUrl":
|
||||
return await resolveGitHubInstallUrl(requestOptions);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported Cline integrations operation: ${operation}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider management ────────────────────────────────────────────
|
||||
@@ -1861,11 +1523,6 @@ export async function handleCommand(
|
||||
manager.getProviderConfig(String(args?.provider ?? "").trim()),
|
||||
);
|
||||
}
|
||||
if (command === "list_cline_recommended_models") {
|
||||
// Tiered picker data (recommended / free / clinePass) with
|
||||
// display-ready names; falls back to a bundled list offline.
|
||||
return await fetchClineRecommendedModels();
|
||||
}
|
||||
if (command === "create_streaming_transcription_session") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const selection = manager.getVoiceInputSettings();
|
||||
@@ -2004,21 +1661,13 @@ export async function handleCommand(
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const saved = saveLocalProviderSettings(manager, {
|
||||
return saveLocalProviderSettings(manager, {
|
||||
...readProviderSettingsUpdate(args),
|
||||
providerId: String(args?.provider ?? ""),
|
||||
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
|
||||
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
|
||||
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
|
||||
});
|
||||
// Sign-out is a `save_provider_settings` that blanks the cline auth block
|
||||
// (see signOut in webview settings/account-view.tsx), so this is the
|
||||
// authoritative signal — it fires the moment credentials are cleared
|
||||
// rather than waiting for the next account fetch.
|
||||
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
|
||||
syncFeatureFlagsAccountFromSettings(ctx, manager);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
if (command === "add_provider") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
@@ -2121,18 +1770,6 @@ export async function handleCommand(
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Feature flags ──────────────────────────────────────────────────
|
||||
// Flags are evaluated here, not in the webview: the sidecar already has
|
||||
// the PostHog key inlined at build time and evaluates against the same
|
||||
// distinct ID it reports telemetry with. The client just reads the
|
||||
// resolved values.
|
||||
if (command === "get_feature_flags") {
|
||||
return await refreshDesktopFeatureFlags({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
@@ -2388,17 +2025,6 @@ export async function handleCommand(
|
||||
return await handleRoutineScheduleCommand(ctx, command, args);
|
||||
}
|
||||
|
||||
// ── Agenda task queue ─────────────────────────────────────────────
|
||||
if (AGENDA_TASK_COMMANDS.has(command)) {
|
||||
if (
|
||||
AGENDA_TASK_EXECUTION_COMMANDS.has(command) &&
|
||||
!options?.connection?.data?.canApproveTools
|
||||
) {
|
||||
throw new Error("task execution requires a trusted desktop connection");
|
||||
}
|
||||
return await handleAgendaTaskCommand(ctx, command, args);
|
||||
}
|
||||
|
||||
// ── User instruction configs ──────────────────────────────────────
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx);
|
||||
|
||||
@@ -15,11 +15,6 @@ const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
const updateCapabilitiesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@ai-sdk/provider-utils", () => ({
|
||||
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
@@ -40,7 +35,6 @@ vi.mock("@cline/core", async () => {
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
updateCapabilities = updateCapabilitiesMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
};
|
||||
@@ -70,7 +64,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
updateCapabilitiesMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
@@ -80,7 +73,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
updateCapabilitiesMock.mockResolvedValue(undefined);
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
@@ -88,7 +80,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the desktop capability factory with core", async () => {
|
||||
it("registers Code App capability factory with core", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
@@ -110,7 +102,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -121,7 +113,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
displayName: "Code App observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -210,157 +202,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns indexed search results without listing every session", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
|
||||
const hits = [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:0",
|
||||
ordinal: 0,
|
||||
role: "user",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
title: oversizedPrompt,
|
||||
snippet: oversizedPrompt,
|
||||
score: -1,
|
||||
},
|
||||
];
|
||||
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
|
||||
const list = vi.fn(async () => []);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
})) as Array<{ title: string; snippet: string }>;
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:0",
|
||||
}),
|
||||
]);
|
||||
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
|
||||
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
|
||||
expect(results[0]?.title).not.toContain("user_input");
|
||||
expect(results[0]?.snippet).not.toContain("user_input");
|
||||
expect(command).toHaveBeenCalledWith("session.search", {
|
||||
query: "generate",
|
||||
limit: 50,
|
||||
workspaceRoot: undefined,
|
||||
});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to session metadata while the index has no hits", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const command = vi.fn(async () => ({ ok: true, payload: { hits: [] } }));
|
||||
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
prompt: oversizedPrompt,
|
||||
metadata: { title: oversizedPrompt },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
})) as Array<{ title: string; snippet: string }>;
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:metadata",
|
||||
}),
|
||||
]);
|
||||
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
|
||||
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
|
||||
expect(results[0]?.title).not.toContain("user_input");
|
||||
expect(results[0]?.snippet).not.toContain("user_input");
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to session metadata when the hub search call rejects", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const command = vi.fn(async () => {
|
||||
throw new Error("hub connection lost");
|
||||
});
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
prompt: "generate an image of a puppy",
|
||||
metadata: { title: "generate an image of a puppy" },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
})) as Array<{ sessionId: string; documentId: string }>;
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:metadata",
|
||||
}),
|
||||
]);
|
||||
expect(command).toHaveBeenCalledOnce();
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to session metadata when the hub search call exceeds the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
// Never resolves: exercises the withSearchDeadline race timing out
|
||||
// rather than the hub call rejecting.
|
||||
const command = vi.fn(() => new Promise(() => {}));
|
||||
const list = vi.fn(async () => [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
startedAt: "2026-08-27T12:00:00.000Z",
|
||||
workspaceRoot: "/workspace/project",
|
||||
prompt: "generate an image of a puppy",
|
||||
metadata: { title: "generate an image of a puppy" },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
|
||||
const pending = handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
}) as Promise<Array<{ sessionId: string; documentId: string }>>;
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
const results = await pending;
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
documentId: "session-1:metadata",
|
||||
}),
|
||||
]);
|
||||
expect(command).toHaveBeenCalledOnce();
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards raw hub tool updates to attached desktop sessions", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
@@ -618,11 +459,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
@@ -728,11 +565,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
@@ -745,7 +578,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hub: expect.objectContaining({
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -764,12 +597,9 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
|
||||
expect(approval).toBeInstanceOf(Promise);
|
||||
const pending = await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
const pending = await handleCommand(ctx, "poll_tool_approvals", {
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
expect(pending).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
@@ -795,270 +625,18 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
|
||||
const [{ requestId }] = pending as Array<{ requestId: string }>;
|
||||
const untrustedClient = { send: vi.fn() };
|
||||
ctx.wsClients.add(untrustedClient);
|
||||
await expect(
|
||||
handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: untrustedClient },
|
||||
),
|
||||
).rejects.toThrow("trusted desktop connection");
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
await handleCommand(ctx, "respond_tool_approval", {
|
||||
sessionId: "sess-1",
|
||||
requestId,
|
||||
approved: true,
|
||||
});
|
||||
|
||||
await expect(approval).resolves.toEqual({ approved: true });
|
||||
expect(
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects and removes an approval when initial delivery fails", async () => {
|
||||
const { createSidecarContext, createSidecarRuntimeCapabilities } =
|
||||
await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const failedClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(failedClient);
|
||||
|
||||
const approval = createSidecarRuntimeCapabilities(
|
||||
ctx,
|
||||
).requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
|
||||
await expect(approval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(failedClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an owned approval when a later broadcast fails", async () => {
|
||||
const {
|
||||
broadcastEvent,
|
||||
createSidecarContext,
|
||||
createSidecarRuntimeCapabilities,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
|
||||
const approval = createSidecarRuntimeCapabilities(
|
||||
ctx,
|
||||
).requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
|
||||
broadcastEvent(ctx, "task.updated", { taskId: "task-1" });
|
||||
|
||||
await expect(approval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(approvalClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects sibling approvals when a targeted state update fails", async () => {
|
||||
const { createSidecarContext, createSidecarRuntimeCapabilities } =
|
||||
await import("./context");
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
const capabilities = createSidecarRuntimeCapabilities(ctx);
|
||||
const request = (toolCallId: string) =>
|
||||
capabilities.requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId,
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
const firstApproval = request("tool-call-1");
|
||||
const siblingApproval = request("tool-call-2");
|
||||
const [{ requestId }] = (await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
)) as Array<{ requestId: string }>;
|
||||
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await expect(firstApproval).resolves.toEqual({ approved: true });
|
||||
await expect(siblingApproval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(approvalClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes approval readiness updates and publishes the latest state", async () => {
|
||||
const {
|
||||
createSidecarContext,
|
||||
initializeSessionManager,
|
||||
syncSidecarApprovalReadiness,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
updateCapabilitiesMock.mockReset();
|
||||
|
||||
let finishDisconnectedUpdate: (() => void) | undefined;
|
||||
updateCapabilitiesMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDisconnectedUpdate = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const disconnected = syncSidecarApprovalReadiness(ctx);
|
||||
await vi.waitFor(() =>
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledWith([]),
|
||||
);
|
||||
ctx.wsClients.add({
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
});
|
||||
const connected = syncSidecarApprovalReadiness(ctx);
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishDisconnectedUpdate?.();
|
||||
await Promise.all([disconnected, connected]);
|
||||
expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ name: "approval.respond" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards Hub-owned task session approvals to the live desktop", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
let onHubEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
subscribeMock.mockImplementation((handler) => {
|
||||
onHubEvent = handler;
|
||||
return () => {};
|
||||
});
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: "approval.respond" }),
|
||||
]);
|
||||
onHubEvent?.({
|
||||
event: "approval.requested",
|
||||
sessionId: "task-session-1",
|
||||
payload: {
|
||||
approvalId: "hub-approval-1",
|
||||
agendaTaskId: "task-1",
|
||||
agentId: "task-agent-1",
|
||||
conversationId: "task-conversation-1",
|
||||
iteration: 2,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "write_to_file",
|
||||
inputJson: JSON.stringify({ path: "src/a.ts" }),
|
||||
policy: { autoApprove: false },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1));
|
||||
const pendingItems = (await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "task-session-1" },
|
||||
{ connection: approvalClient },
|
||||
)) as Array<{ requestId: string }>;
|
||||
const pending = pendingItems[0];
|
||||
if (!pending) throw new Error("expected a pending task approval");
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{
|
||||
sessionId: "task-session-1",
|
||||
requestId: pending.requestId,
|
||||
approved: true,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hubCommandMock).toHaveBeenCalledWith(
|
||||
"approval.respond",
|
||||
{
|
||||
approvalId: "hub-approval-1",
|
||||
approved: true,
|
||||
reason: undefined,
|
||||
},
|
||||
"task-session-1",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -1080,119 +658,9 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
allWorkspaces: true,
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proxies Agenda task commands through the connected shared Hub", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const task = {
|
||||
taskId: "task-1",
|
||||
title: "Review the PR",
|
||||
status: "pending_approval",
|
||||
};
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { tasks: [task] },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "task.list", {
|
||||
workspaceRoot: "/workspace/project",
|
||||
statuses: ["pending_approval"],
|
||||
}),
|
||||
).resolves.toEqual({ tasks: [task] });
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("task.list", {
|
||||
workspaceRoot: "/workspace/project",
|
||||
statuses: ["pending_approval"],
|
||||
});
|
||||
|
||||
hubCommandMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { task: { ...task, status: "in_progress", revision: 4 } },
|
||||
});
|
||||
await expect(
|
||||
handleCommand(
|
||||
ctx,
|
||||
"task.run",
|
||||
{
|
||||
taskId: "task-1",
|
||||
expectedRevision: 4,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
task: { ...task, status: "in_progress", revision: 4 },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("task.run", {
|
||||
taskId: "task-1",
|
||||
expectedRevision: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"task.create",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.set",
|
||||
])("rejects untrusted %s commands before they reach the shared Hub", async (command) => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
const untrustedClient = {
|
||||
data: { canApproveTools: false },
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, command, {}, { connection: untrustedClient }),
|
||||
).rejects.toThrow("task execution requires a trusted desktop connection");
|
||||
expect(hubCommandMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards Hub task events that do not have a session", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() } as never);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "task.created",
|
||||
payload: {
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readEvents(ctx)).toEqual([
|
||||
{
|
||||
type: "event",
|
||||
event: {
|
||||
name: "task.created",
|
||||
payload: {
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
|
||||
@@ -15,21 +15,13 @@ import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type AgentEvent,
|
||||
HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
isGeneratedMedia,
|
||||
} from "@cline/shared";
|
||||
import { type AgentEvent, isGeneratedMedia } from "@cline/shared";
|
||||
import {
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
reconcileQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import {
|
||||
disposeDesktopFeatureFlagsService,
|
||||
getDesktopFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
import { sessionLogPath } from "./paths";
|
||||
import type {
|
||||
LiveSession,
|
||||
@@ -37,7 +29,6 @@ import type {
|
||||
PendingToolApproval,
|
||||
PromptInQueue,
|
||||
SidecarContext,
|
||||
SidecarWebSocketClient,
|
||||
} from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
@@ -45,7 +36,6 @@ const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
const approvalReadinessUpdates = new WeakMap<SidecarContext, Promise<void>>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -69,78 +59,10 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
client.send(encoded);
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, client);
|
||||
void syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sendEventToClient(
|
||||
ctx: SidecarContext,
|
||||
client: SidecarWebSocketClient,
|
||||
name: string,
|
||||
payload: unknown,
|
||||
): boolean {
|
||||
try {
|
||||
client.send(encodeSidecarEvent(name, payload));
|
||||
return true;
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, client);
|
||||
void syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelSidecarToolApprovalsForOwner(
|
||||
ctx: SidecarContext,
|
||||
owner: SidecarWebSocketClient,
|
||||
): void {
|
||||
for (const [requestId, pending] of ctx.pendingApprovals) {
|
||||
if (pending.owner !== owner) continue;
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
pending.resolve({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncSidecarApprovalReadiness(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
|
||||
const update = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const hubClient = ctx.hubClient;
|
||||
if (!hubClient) return;
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
approvalReadinessUpdates.set(ctx, update);
|
||||
return update.finally(() => {
|
||||
if (approvalReadinessUpdates.get(ctx) === update) {
|
||||
approvalReadinessUpdates.delete(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Session log appends are chained per session so writes stay ordered, but
|
||||
// they run asynchronously: a synchronous write per streamed token would stall
|
||||
// the sidecar event loop (and therefore every pending UI command) under load.
|
||||
@@ -440,7 +362,7 @@ function emitQueuedPromptStart(
|
||||
);
|
||||
}
|
||||
|
||||
export function handleCoreSessionEvent(
|
||||
function handleCoreSessionEvent(
|
||||
ctx: SidecarContext,
|
||||
event: CoreSessionEvent,
|
||||
): void {
|
||||
@@ -633,10 +555,6 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
// Shuts down the PostHog client the feature flags service owns, flushing
|
||||
// any pending $feature_flag_called events.
|
||||
cleanup.push(disposeDesktopFeatureFlagsService());
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -740,15 +658,6 @@ function requestSidecarToolApproval(
|
||||
ctx: SidecarContext,
|
||||
request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> {
|
||||
const owner = [...ctx.wsClients].find(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
);
|
||||
if (!owner) {
|
||||
return Promise.resolve({
|
||||
approved: false,
|
||||
reason: "No trusted desktop approval surface is connected",
|
||||
});
|
||||
}
|
||||
return new Promise<ToolApprovalResult>((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const pending: PendingToolApproval = {
|
||||
@@ -763,25 +672,16 @@ function requestSidecarToolApproval(
|
||||
agentId: request.agentId,
|
||||
conversationId: request.conversationId,
|
||||
},
|
||||
owner,
|
||||
resolve,
|
||||
};
|
||||
ctx.pendingApprovals.set(requestId, pending);
|
||||
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
|
||||
.filter(
|
||||
(approval) =>
|
||||
approval.owner === owner &&
|
||||
approval.item.sessionId === request.sessionId,
|
||||
)
|
||||
.filter((approval) => approval.item.sessionId === request.sessionId)
|
||||
.map((approval) => approval.item);
|
||||
if (
|
||||
!sendEventToClient(ctx, owner, "tool_approval_state", {
|
||||
sessionId: request.sessionId,
|
||||
items: sessionApprovals,
|
||||
})
|
||||
) {
|
||||
cancelSidecarToolApprovalsForOwner(ctx, owner);
|
||||
}
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId: request.sessionId,
|
||||
items: sessionApprovals,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -793,25 +693,6 @@ export function handleHubLiveEvent(
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): void {
|
||||
if (event.event === "approval.requested") {
|
||||
if (typeof event.payload?.agendaTaskId !== "string") return;
|
||||
void handleHubApprovalRequest(ctx, event).catch((error) => {
|
||||
ctx.logger?.error?.("Hub task approval forwarding failed", { error });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Task lifecycle events are Hub-wide invalidations and usually do not have a
|
||||
// session yet (pending and approved tasks explicitly predate their session).
|
||||
// Forward them before the session-only live-chat projection below so Agenda
|
||||
// surfaces stay current without polling.
|
||||
if (event.event.startsWith("task.")) {
|
||||
sendEvent(ctx, event.event, {
|
||||
...(event.payload ?? {}),
|
||||
...(event.sessionId ? { sessionId: event.sessionId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
return;
|
||||
@@ -955,72 +836,6 @@ export function handleHubLiveEvent(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHubApprovalRequest(
|
||||
ctx: SidecarContext,
|
||||
event: {
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const sessionId = event.sessionId?.trim() || "";
|
||||
const approvalId =
|
||||
typeof event.payload?.approvalId === "string"
|
||||
? event.payload.approvalId.trim()
|
||||
: "";
|
||||
const toolCallId =
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId.trim()
|
||||
: "";
|
||||
const toolName =
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName.trim()
|
||||
: "";
|
||||
if (!sessionId || !approvalId || !toolCallId || !toolName) return;
|
||||
let input: unknown;
|
||||
try {
|
||||
input =
|
||||
typeof event.payload?.inputJson === "string"
|
||||
? JSON.parse(event.payload.inputJson)
|
||||
: undefined;
|
||||
} catch {
|
||||
input = undefined;
|
||||
}
|
||||
const result = await requestSidecarToolApproval(ctx, {
|
||||
sessionId,
|
||||
agentId:
|
||||
typeof event.payload?.agentId === "string" ? event.payload.agentId : "",
|
||||
conversationId:
|
||||
typeof event.payload?.conversationId === "string"
|
||||
? event.payload.conversationId
|
||||
: sessionId,
|
||||
iteration:
|
||||
typeof event.payload?.iteration === "number"
|
||||
? event.payload.iteration
|
||||
: 0,
|
||||
toolCallId,
|
||||
toolName,
|
||||
input,
|
||||
policy:
|
||||
event.payload?.policy &&
|
||||
typeof event.payload.policy === "object" &&
|
||||
!Array.isArray(event.payload.policy)
|
||||
? (event.payload.policy as ToolApprovalRequest["policy"])
|
||||
: { autoApprove: false },
|
||||
});
|
||||
const client = ctx.hubClient;
|
||||
if (!client)
|
||||
throw new Error("Hub client disconnected before approval response");
|
||||
await client.command(
|
||||
"approval.respond",
|
||||
{
|
||||
approvalId,
|
||||
approved: result.approved,
|
||||
reason: result.reason,
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
@@ -1031,16 +846,12 @@ export async function initializeSessionManager(
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
featureFlags: getDesktopFeatureFlagsService({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
}),
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1088,7 +899,7 @@ export async function ensureSharedHubClient(
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
@@ -1098,7 +909,6 @@ export async function ensureSharedHubClient(
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
await syncSidecarApprovalReadiness(ctx);
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildClinePostHogClient: vi.fn(() => ({ kind: "posthog-client" })),
|
||||
PostHogFeatureFlagsProvider: vi.fn(function PostHogFeatureFlagsProvider(
|
||||
this: Record<string, unknown>,
|
||||
options: unknown,
|
||||
) {
|
||||
this.kind = "posthog";
|
||||
this.options = options;
|
||||
}),
|
||||
NoOpFeatureFlagsProvider: vi.fn(function NoOpFeatureFlagsProvider(
|
||||
this: Record<string, unknown>,
|
||||
) {
|
||||
this.kind = "noop";
|
||||
}),
|
||||
resolveCoreDistinctId: vi.fn(() => "machine-distinct-id"),
|
||||
poll: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
setContext: vi.fn(),
|
||||
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
// Two known flags keep the snapshot assertions meaningful even as the
|
||||
// real registry changes.
|
||||
FEATURE_FLAGS: ["ext-cline-pass", "ext-demo-flag"],
|
||||
NoOpFeatureFlagsProvider: mocks.NoOpFeatureFlagsProvider,
|
||||
resolveCoreDistinctId: mocks.resolveCoreDistinctId,
|
||||
FeatureFlagsService: class {
|
||||
options: Record<string, unknown>;
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
}
|
||||
poll = mocks.poll;
|
||||
dispose = mocks.dispose;
|
||||
setContext = mocks.setContext;
|
||||
getFlagPayload = mocks.getFlagPayload;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@cline/core/services/feature-flags/posthog", () => ({
|
||||
buildClinePostHogClient: mocks.buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider: mocks.PostHogFeatureFlagsProvider,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildFeatureFlagsSnapshot,
|
||||
disposeDesktopFeatureFlagsService,
|
||||
getDesktopFeatureFlagsContext,
|
||||
getDesktopFeatureFlagsService,
|
||||
refreshDesktopFeatureFlags,
|
||||
resetDesktopFeatureFlagsForTesting,
|
||||
setDesktopFeatureFlagsAccountContext,
|
||||
} from "./feature-flags";
|
||||
|
||||
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const originalIsTest = process.env.IS_TEST;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetDesktopFeatureFlagsForTesting();
|
||||
delete process.env.IS_TEST;
|
||||
delete process.env.E2E_TEST;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
} else {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = originalApiKey;
|
||||
}
|
||||
if (originalIsTest === undefined) {
|
||||
delete process.env.IS_TEST;
|
||||
} else {
|
||||
process.env.IS_TEST = originalIsTest;
|
||||
}
|
||||
});
|
||||
|
||||
describe("getDesktopFeatureFlagsService", () => {
|
||||
it("uses PostHog when the build-time key is inlined", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.buildClinePostHogClient).toHaveBeenCalledWith("phc_key");
|
||||
expect(mocks.NoOpFeatureFlagsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the no-op provider when no key was inlined", () => {
|
||||
delete process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never calls PostHog under IS_TEST even with a key present", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
process.env.IS_TEST = "true";
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns one shared instance so the core and the webview agree", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
expect(getDesktopFeatureFlagsService()).toBe(
|
||||
getDesktopFeatureFlagsService(),
|
||||
);
|
||||
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature flags context", () => {
|
||||
it("defaults to the machine distinct ID under the cline-code client name", () => {
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.clientName).toBe("cline-code");
|
||||
expect(context.distinctId).toBe("machine-distinct-id");
|
||||
});
|
||||
|
||||
it("switches to the account ID once signed in, and pushes it to the service", () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
getDesktopFeatureFlagsService();
|
||||
setDesktopFeatureFlagsAccountContext({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
});
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.distinctId).toBe("acct-1");
|
||||
expect(context.userId).toBe("acct-1");
|
||||
expect(mocks.setContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the device identity when the account ID is blank", () => {
|
||||
setDesktopFeatureFlagsAccountContext({ id: " " });
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe(
|
||||
"machine-distinct-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the account identity on sign-out and falls back to the device", () => {
|
||||
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
|
||||
expect(getDesktopFeatureFlagsContext().userId).toBe("acct-1");
|
||||
|
||||
expect(setDesktopFeatureFlagsAccountContext({})).toBe(true);
|
||||
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.userId).toBeUndefined();
|
||||
// Must not be left on the signed-out account's ID.
|
||||
expect(context.distinctId).toBe("machine-distinct-id");
|
||||
});
|
||||
|
||||
it("reports no change when the same account is re-confirmed", () => {
|
||||
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(true);
|
||||
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no change when signed out twice", () => {
|
||||
expect(setDesktopFeatureFlagsAccountContext({})).toBe(false);
|
||||
});
|
||||
|
||||
it("re-points at the new account when switching accounts", () => {
|
||||
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
|
||||
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-2" })).toBe(true);
|
||||
|
||||
const context = getDesktopFeatureFlagsContext();
|
||||
expect(context.userId).toBe("acct-2");
|
||||
expect(context.distinctId).toBe("acct-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureFlagsSnapshot", () => {
|
||||
it("resolves every known flag so the client needs no defaults", () => {
|
||||
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
|
||||
flag === "ext-cline-pass" ? true : undefined,
|
||||
);
|
||||
const snapshot = buildFeatureFlagsSnapshot(
|
||||
getDesktopFeatureFlagsService() as never,
|
||||
);
|
||||
expect(snapshot.flags).toEqual({
|
||||
"ext-cline-pass": true,
|
||||
// Unreturned flags resolve to false rather than being absent.
|
||||
"ext-demo-flag": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes non-boolean payloads through untouched", () => {
|
||||
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
|
||||
flag === "ext-cline-pass" ? { variant: "b", limit: 3 } : false,
|
||||
);
|
||||
const snapshot = buildFeatureFlagsSnapshot(
|
||||
getDesktopFeatureFlagsService() as never,
|
||||
);
|
||||
expect(snapshot.flags["ext-cline-pass"]).toEqual({
|
||||
variant: "b",
|
||||
limit: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshDesktopFeatureFlags", () => {
|
||||
it("polls before returning the snapshot", async () => {
|
||||
mocks.getFlagPayload.mockReturnValue(true);
|
||||
const snapshot = await refreshDesktopFeatureFlags();
|
||||
expect(mocks.poll).toHaveBeenCalledTimes(1);
|
||||
expect(snapshot.flags["ext-cline-pass"]).toBe(true);
|
||||
});
|
||||
|
||||
it("still returns cached values when the poll fails", async () => {
|
||||
mocks.poll.mockRejectedValueOnce(new Error("offline"));
|
||||
mocks.getFlagPayload.mockReturnValue(false);
|
||||
const logger = { error: vi.fn(), log: vi.fn(), debug: vi.fn() };
|
||||
|
||||
const snapshot = await refreshDesktopFeatureFlags({ logger });
|
||||
|
||||
expect(snapshot.flags["ext-cline-pass"]).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeDesktopFeatureFlagsService", () => {
|
||||
it("disposes the live service and clears it", async () => {
|
||||
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
|
||||
getDesktopFeatureFlagsService();
|
||||
await disposeDesktopFeatureFlagsService();
|
||||
expect(mocks.dispose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A later call builds a fresh service rather than reusing a disposed one.
|
||||
getDesktopFeatureFlagsService();
|
||||
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing was created", async () => {
|
||||
await expect(disposeDesktopFeatureFlagsService()).resolves.toBeUndefined();
|
||||
expect(mocks.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
FEATURE_FLAGS,
|
||||
type FeatureFlagPayload,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let desktopFeatureFlagsContext: FeatureFlagsContext = {
|
||||
clientName: "cline-code",
|
||||
};
|
||||
let desktopFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
function resolveDesktopFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.cline-code.json");
|
||||
}
|
||||
|
||||
function ensureDesktopDistinctId(): string {
|
||||
const distinctId = desktopFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
desktopFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getDesktopFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureDesktopDistinctId();
|
||||
return { ...desktopFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getDesktopFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!desktopFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
desktopFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getDesktopFeatureFlagsContext(),
|
||||
cacheFilePath: resolveDesktopFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return desktopFeatureFlagsService;
|
||||
}
|
||||
|
||||
export async function disposeDesktopFeatureFlagsService(): Promise<void> {
|
||||
if (!desktopFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = desktopFeatureFlagsService;
|
||||
desktopFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export function setDesktopFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): boolean {
|
||||
const accountId = account.id?.trim();
|
||||
const previousUserId = desktopFeatureFlagsContext.userId ?? undefined;
|
||||
if (previousUserId === (accountId || undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
desktopFeatureFlagsContext = {
|
||||
...desktopFeatureFlagsContext,
|
||||
distinctId: accountId,
|
||||
userId: accountId,
|
||||
};
|
||||
} else {
|
||||
// Drop both identifiers; ensureDesktopDistinctId re-resolves the device
|
||||
// ID on the next read rather than leaving the old account's ID behind.
|
||||
const {
|
||||
distinctId: _distinctId,
|
||||
userId: _userId,
|
||||
...rest
|
||||
} = desktopFeatureFlagsContext;
|
||||
desktopFeatureFlagsContext = rest;
|
||||
}
|
||||
|
||||
desktopFeatureFlagsService?.setContext(getDesktopFeatureFlagsContext());
|
||||
return true;
|
||||
}
|
||||
|
||||
export type FeatureFlagsSnapshot = {
|
||||
flags: Record<string, FeatureFlagPayload>;
|
||||
};
|
||||
|
||||
export function buildFeatureFlagsSnapshot(
|
||||
service: FeatureFlagsService,
|
||||
): FeatureFlagsSnapshot {
|
||||
const flags: Record<string, FeatureFlagPayload> = {};
|
||||
for (const flag of FEATURE_FLAGS) {
|
||||
flags[flag] = service.getFlagPayload(flag) ?? false;
|
||||
}
|
||||
return { flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh flags from PostHog, then hand back the resolved snapshot.
|
||||
*
|
||||
* Polling is cheap to call repeatedly.
|
||||
*/
|
||||
export async function refreshDesktopFeatureFlags(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): Promise<FeatureFlagsSnapshot> {
|
||||
const service = getDesktopFeatureFlagsService(options);
|
||||
try {
|
||||
await service.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error refreshing desktop feature flags", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
return buildFeatureFlagsSnapshot(service);
|
||||
}
|
||||
|
||||
export async function identifyDesktopFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
options?: { logger?: BasicLogger; telemetry?: ITelemetryService },
|
||||
): Promise<void> {
|
||||
if (
|
||||
!setDesktopFeatureFlagsAccountContext(account) ||
|
||||
!desktopFeatureFlagsService
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await desktopFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error polling desktop feature flags", { error });
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDesktopFeatureFlagsForTesting(): void {
|
||||
desktopFeatureFlagsService = undefined;
|
||||
desktopFeatureFlagsContext = { clientName: "cline-code" };
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
checkManagedHubBuildMismatch,
|
||||
createClineTelemetryServiceConfig,
|
||||
readGlobalSettings,
|
||||
setHomeDirIfUnset,
|
||||
setModelToolEnabledGlobally,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
@@ -68,20 +65,6 @@ async function main() {
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
// Web search is opt-in elsewhere in Cline, but the desktop app defaults
|
||||
// it to on. Seed the shared setting only when the user has never set it,
|
||||
// so an explicit off (from any Cline app) stays off. Best-effort: an
|
||||
// unwritable settings file must not block startup over a default.
|
||||
try {
|
||||
if (readGlobalSettings().tools?.web_search === undefined) {
|
||||
setModelToolEnabledGlobally("web_search", true);
|
||||
}
|
||||
} catch (error) {
|
||||
observability.logger.error?.("Failed to seed web search default", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
@@ -145,7 +128,7 @@ async function main() {
|
||||
void shutdown("code_sidecar_before_exit");
|
||||
});
|
||||
|
||||
const { port, approvalToken } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
observability.logger.log("Desktop sidecar ready", {
|
||||
port,
|
||||
mode: SIDECAR_MODE,
|
||||
@@ -165,45 +148,16 @@ async function main() {
|
||||
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
|
||||
},
|
||||
});
|
||||
// The watcher's first check only runs after its interval, but a mismatch
|
||||
// that already exists at startup - an older Hub this app attached to
|
||||
// because it is still serving other clients' sessions - must prompt
|
||||
// before the user starts working, not half a minute in. Session-manager
|
||||
// init has already settled the hub state, so check once right away. The
|
||||
// broadcast reaches webviews that are already connected; the replay in
|
||||
// createWebSocketHandler covers ones that connect later. Skipped when
|
||||
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
|
||||
// hosts keep protocol-only compatibility and must not show update prompts.
|
||||
if (!process.env.CLINE_HUB_PORT?.trim()) {
|
||||
void checkManagedHubBuildMismatch()
|
||||
.then((mismatch) => {
|
||||
if (!mismatch || ctx.hubBuildMismatch) {
|
||||
return;
|
||||
}
|
||||
ctx.hubBuildMismatch = mismatch;
|
||||
observability.logger.log(
|
||||
"Managed hub build mismatch detected at startup",
|
||||
{
|
||||
hubBuildId: mismatch.hubBuildId,
|
||||
hubCoreVersion: mismatch.hubCoreVersion,
|
||||
reason: mismatch.reason,
|
||||
},
|
||||
);
|
||||
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
|
||||
wsEndpoint.searchParams.set("approval_token", approvalToken);
|
||||
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
endpoint,
|
||||
wsEndpoint: wsEndpoint.toString(),
|
||||
wsEndpoint,
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
})}\n`,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installPlugin } from "@cline/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOfficialPluginInstallPath,
|
||||
@@ -10,15 +9,6 @@ import {
|
||||
} from "./marketplace";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
// Marketplace plugin installs run in-process through @cline/core (spawning a
|
||||
// `cline` binary fails with 'Executable not found in $PATH: "cline"' in the
|
||||
// packaged app). Stub only installPlugin; everything else stays real.
|
||||
vi.mock(import("@cline/core"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
installPlugin: vi.fn(),
|
||||
}));
|
||||
const installPluginMock = vi.mocked(installPlugin);
|
||||
|
||||
const GOAL_ENTRY = {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
@@ -33,13 +23,6 @@ beforeEach(async () => {
|
||||
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
|
||||
previousClineDir = process.env.CLINE_DIR;
|
||||
process.env.CLINE_DIR = tempClineDir;
|
||||
installPluginMock.mockReset().mockImplementation(async (options) => ({
|
||||
source: options.source,
|
||||
installPath: goalInstallDir(),
|
||||
entryPaths: [],
|
||||
mcpSyncFailures: [],
|
||||
mcpOAuthCandidates: [],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -60,49 +43,57 @@ function goalInstallDir(): string {
|
||||
}
|
||||
|
||||
describe("official plugin install detection", () => {
|
||||
it("installs plugins in-process through @cline/core", async () => {
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(installPluginMock).toHaveBeenCalledWith({
|
||||
source: "goal",
|
||||
force: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the installer, so the UI flipped
|
||||
// the entry to Uninstall with no error while nothing actually worked.
|
||||
// "already installed" without running the CLI, so the UI flipped the
|
||||
// entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
installPluginMock.mockRejectedValueOnce(new Error("install exploded"));
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "install exploded",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }),
|
||||
).rejects.toThrow(/install exploded/);
|
||||
expect(installPluginMock).toHaveBeenCalledTimes(1);
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
|
||||
).rejects.toThrow(/Plugin install failed/);
|
||||
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without force the installer refuses to replace the existing path
|
||||
it("passes --force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without --force the CLI refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(installPluginMock).toHaveBeenCalledWith({
|
||||
source: "goal",
|
||||
force: true,
|
||||
});
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
|
||||
});
|
||||
|
||||
it("does not pass --force for a clean first install", async () => {
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
|
||||
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
|
||||
});
|
||||
|
||||
it("still short-circuits when the directory contains a plugin module", async () => {
|
||||
@@ -120,51 +111,22 @@ describe("official plugin install detection", () => {
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Goal is already installed.",
|
||||
});
|
||||
expect(installPluginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers MCP servers in-process, honoring the -- args separator", async () => {
|
||||
const settingsPath = join(tempClineDir, "cline_mcp_settings.json");
|
||||
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
try {
|
||||
const result = await installMarketplaceEntry({
|
||||
entry: {
|
||||
id: "aikido",
|
||||
type: "mcp",
|
||||
name: "Aikido",
|
||||
install: {
|
||||
args: ["aikido", "--", "npx", "-y", "@aikidosec/mcp@1.0.9"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Aikido.",
|
||||
});
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers: Record<string, { transport?: unknown }>;
|
||||
};
|
||||
expect(settings.mcpServers.aikido?.transport).toEqual({
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@aikidosec/mcp@1.0.9"],
|
||||
});
|
||||
} finally {
|
||||
if (previousSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
|
||||
}
|
||||
}
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes partial install directories from the installed entries list", async () => {
|
||||
|
||||
@@ -18,11 +18,8 @@ import {
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
installPlugin as installCorePlugin,
|
||||
installMcpServer,
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
parseMcpInstallArgs,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
@@ -460,6 +457,18 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
@@ -814,6 +823,7 @@ async function installSkill(
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
@@ -830,36 +840,41 @@ async function installPlugin(
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
// Install in-process instead of shelling out to a `cline` binary: the
|
||||
// packaged desktop app cannot assume a CLI install exists on the user's
|
||||
// PATH (GUI apps inherit launchd's minimal PATH on macOS), which surfaced
|
||||
// as 'Executable not found in $PATH: "cline"' in the marketplace UI.
|
||||
//
|
||||
// force reclaims a leftover directory from a failed or interrupted
|
||||
// install: without it the installer refuses to replace the existing path
|
||||
// and every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
const result = await installCorePlugin({
|
||||
source: installArgs[0] ?? "",
|
||||
force: installState === "partial",
|
||||
});
|
||||
const warnings = result.mcpSyncFailures.map(
|
||||
(failure) =>
|
||||
`Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
// Reclaim a leftover directory from a failed or interrupted install:
|
||||
// without --force the CLI refuses to replace the existing path and
|
||||
// every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
...(installState === "partial" ? ["--force"] : []),
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details: {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
} as JsonRecord,
|
||||
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -870,26 +885,45 @@ export async function installMarketplaceEntry(
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Register the server in-process; this only writes MCP settings, so
|
||||
// there is no reason to depend on a `cline` binary being on PATH.
|
||||
const result = installMcpServer(
|
||||
parseMcpInstallArgs(entry.install.args ?? []),
|
||||
);
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details: result as unknown as JsonRecord,
|
||||
output:
|
||||
result.warnings.length > 0 ? result.warnings.join("\n") : undefined,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry);
|
||||
return installPlugin(entry, spawnCommand);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("desktop observability", () => {
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform: "Cline Code",
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import { version } from "../package.json";
|
||||
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
type DesktopLoggerAdapter,
|
||||
@@ -31,7 +30,7 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform: "Cline Code",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
@@ -46,7 +45,6 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
id: auth.accountId,
|
||||
provider: "cline",
|
||||
});
|
||||
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
import { createFetchHandler, createWebSocketHandler } from "./server";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const TEST_APPROVAL_TOKEN = "test-approval-token";
|
||||
|
||||
function createTestServer() {
|
||||
return {
|
||||
port: 3126,
|
||||
@@ -16,11 +14,7 @@ function createTestServer() {
|
||||
}
|
||||
|
||||
function createHandler(onShutdown = vi.fn()) {
|
||||
return createFetchHandler(
|
||||
{} as SidecarContext,
|
||||
onShutdown,
|
||||
TEST_APPROVAL_TOKEN,
|
||||
);
|
||||
return createFetchHandler({} as SidecarContext, onShutdown);
|
||||
}
|
||||
|
||||
function createTelemetryHandler(capture = vi.fn()) {
|
||||
@@ -91,37 +85,6 @@ describe("sidecar HTTP origin checks", () => {
|
||||
expect(server.upgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not grant approval authority to originless local clients", async () => {
|
||||
const server = createTestServer();
|
||||
await createHandler()(
|
||||
new Request(
|
||||
`http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`,
|
||||
),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), {
|
||||
data: { canApproveTools: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("grants approval authority to the trusted desktop webview", async () => {
|
||||
const server = createTestServer();
|
||||
await createHandler()(
|
||||
new Request(
|
||||
`http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`,
|
||||
{
|
||||
headers: { origin: "tauri://localhost" },
|
||||
},
|
||||
),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), {
|
||||
data: { canApproveTools: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows desktop webview origins in preflight responses", async () => {
|
||||
const server = createTestServer();
|
||||
const response = await createHandler()(
|
||||
@@ -140,20 +103,6 @@ describe("sidecar HTTP origin checks", () => {
|
||||
"tauri://localhost",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not grant approval authority to a spoofed trusted origin", async () => {
|
||||
const server = createTestServer();
|
||||
await createHandler()(
|
||||
new Request("http://127.0.0.1:3126/transport", {
|
||||
headers: { origin: "tauri://localhost" },
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), {
|
||||
data: { canApproveTools: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop error telemetry", () => {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { captureSdkError } from "@cline/shared";
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import { handleCommand } from "./commands";
|
||||
import {
|
||||
cancelSidecarToolApprovalsForOwner,
|
||||
encodeSidecarEvent,
|
||||
sendEvent,
|
||||
syncSidecarApprovalReadiness,
|
||||
} from "./context";
|
||||
import { encodeSidecarEvent, sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
@@ -23,10 +17,7 @@ import {
|
||||
|
||||
type SidecarServer = {
|
||||
port: number;
|
||||
upgrade(
|
||||
req: Request,
|
||||
options?: { data?: { canApproveTools?: boolean } },
|
||||
): boolean;
|
||||
upgrade(req: Request): boolean;
|
||||
};
|
||||
|
||||
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
|
||||
@@ -49,19 +40,6 @@ const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const APPROVAL_TOKEN_QUERY_PARAM = "approval_token";
|
||||
|
||||
function hasValidApprovalToken(url: URL, expectedToken: string): boolean {
|
||||
const candidate = url.searchParams.get(APPROVAL_TOKEN_QUERY_PARAM);
|
||||
if (!candidate) return false;
|
||||
const candidateBytes = Buffer.from(candidate);
|
||||
const expectedBytes = Buffer.from(expectedToken);
|
||||
return (
|
||||
candidateBytes.length === expectedBytes.length &&
|
||||
timingSafeEqual(candidateBytes, expectedBytes)
|
||||
);
|
||||
}
|
||||
|
||||
function readOrigin(req: Request): string | undefined {
|
||||
const origin = req.headers.get("origin")?.trim();
|
||||
return origin ? origin : undefined;
|
||||
@@ -171,9 +149,7 @@ export function startServer(
|
||||
ctx: SidecarContext,
|
||||
preferredPort: number = SIDECAR_PORT,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
approvalToken = process.env.CLINE_SIDECAR_APPROVAL_TOKEN?.trim() ||
|
||||
randomUUID(),
|
||||
): { port: number; approvalToken: string } {
|
||||
): { port: number } {
|
||||
if (!BunRuntime) {
|
||||
throw new Error("sidecar must be run with Bun");
|
||||
}
|
||||
@@ -188,7 +164,7 @@ export function startServer(
|
||||
server = BunRuntime.serve({
|
||||
hostname: SIDECAR_HOST,
|
||||
port: candidate,
|
||||
fetch: createFetchHandler(ctx, onShutdown, approvalToken),
|
||||
fetch: createFetchHandler(ctx, onShutdown),
|
||||
websocket: createWebSocketHandler(ctx),
|
||||
}) as SidecarServer;
|
||||
break;
|
||||
@@ -201,13 +177,12 @@ export function startServer(
|
||||
throw lastError ?? new Error("Failed to start sidecar server");
|
||||
}
|
||||
|
||||
return { port: server.port, approvalToken };
|
||||
return { port: server.port };
|
||||
}
|
||||
|
||||
export function createFetchHandler(
|
||||
ctx: SidecarContext,
|
||||
onShutdown?: (reason?: string) => Promise<void>,
|
||||
approvalToken = "",
|
||||
) {
|
||||
return async (req: Request, server: SidecarServer) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -233,15 +208,7 @@ export function createFetchHandler(
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
server.upgrade(req, {
|
||||
data: {
|
||||
// Originless clients remain supported for local integrations, but only
|
||||
// the browser-hosted desktop UI may receive or resolve approvals.
|
||||
canApproveTools:
|
||||
Boolean(readOrigin(req)) &&
|
||||
hasValidApprovalToken(url, approvalToken),
|
||||
},
|
||||
})
|
||||
server.upgrade(req)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -358,7 +325,6 @@ export function createWebSocketHandler(ctx: SidecarContext) {
|
||||
maxPayloadLength: MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES,
|
||||
open(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.add(ws);
|
||||
void syncSidecarApprovalReadiness(ctx).catch(() => {});
|
||||
sendEvent(ctx, "host_ready", {
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
@@ -399,8 +365,6 @@ export function createWebSocketHandler(ctx: SidecarContext) {
|
||||
},
|
||||
close(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.delete(ws);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, ws);
|
||||
void syncSidecarApprovalReadiness(ctx).catch(() => {});
|
||||
// Browser OAuth flows are interactive: if the connection that started
|
||||
// one goes away (webview reload, transport drop), cancel its callback
|
||||
// wait so the sidecar cannot retain an abandoned authorization attempt.
|
||||
|
||||
@@ -88,166 +88,6 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects pre-tool thinking before the tool row it preceded", async () => {
|
||||
// A thinking model can issue a tool call without narration text:
|
||||
// content = [thinking, tool_use]. The thinking happened before the
|
||||
// tool executed, so it must project before the tool row — matching the
|
||||
// live-stream order and keeping the reasoning from attaching to the
|
||||
// next turn-ending answer (which would corrupt the work summary's
|
||||
// duration anchor in the webview).
|
||||
const sessionId = `thinking-tool-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_000;
|
||||
const assistantTimestamp = userTimestamp + 5_000;
|
||||
const resultTimestamp = userTimestamp + 13_000;
|
||||
const answerTimestamp = userTimestamp + 13_500;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-message",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Run the command" }],
|
||||
ts: userTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-tool",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Planning the command" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-use",
|
||||
name: "run_commands",
|
||||
input: { commands: ["sleep 8"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
{
|
||||
id: "tool-result-message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-use",
|
||||
content: "done",
|
||||
},
|
||||
],
|
||||
ts: resultTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-answer",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "The command finished." }],
|
||||
ts: answerTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "user-message_text_0",
|
||||
role: "user",
|
||||
createdAt: userTimestamp,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tool_reasoning_0",
|
||||
role: "assistant",
|
||||
reasoning: "Planning the command",
|
||||
createdAt: assistantTimestamp,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tool_tool_use_1",
|
||||
role: "tool",
|
||||
createdAt: assistantTimestamp + 1,
|
||||
meta: expect.objectContaining({
|
||||
toolCallId: "tool-use",
|
||||
hookEventName: "history_tool_result",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-answer_text_0",
|
||||
role: "assistant",
|
||||
content: "The command finished.",
|
||||
createdAt: answerTimestamp,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps interleaved thinking between the tool calls it separates", async () => {
|
||||
// Interleaved thinking can produce [thinking, tool_use, thinking,
|
||||
// tool_use] in a single assistant message. Each thinking segment must
|
||||
// project at its own position — merging the second segment into the
|
||||
// first row would display it before a tool call it actually followed.
|
||||
const sessionId = `interleaved-thinking-projection-${Date.now()}`;
|
||||
const assistantTimestamp = 1_781_041_621_000;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-tools",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "First I need the date" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-a",
|
||||
name: "run_commands",
|
||||
input: { commands: ["date"] },
|
||||
},
|
||||
{ type: "thinking", thinking: "Now check the files" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-b",
|
||||
name: "read_files",
|
||||
input: { paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_reasoning_0",
|
||||
role: "assistant",
|
||||
reasoning: "First I need the date",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_tool_use_1",
|
||||
role: "tool",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_reasoning_1",
|
||||
role: "assistant",
|
||||
reasoning: "Now check the files",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-tools_tool_use_3",
|
||||
role: "tool",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects image content blocks without replacing them with placeholder text", async () => {
|
||||
const sessionId = `image-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
|
||||
@@ -465,11 +465,6 @@ export async function readSessionMessages(
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
let textSegmentIndex = 0;
|
||||
let reasoningSegmentIndex = 0;
|
||||
// The text row pushed since the last reasoning flush. Reasoning that
|
||||
// streamed alongside it (the classic [thinking, text] shape) attaches
|
||||
// there instead of becoming a separate row.
|
||||
let reasoningTextTarget: JsonRecord | undefined;
|
||||
const outStartIndex = out.length;
|
||||
const flushTextParts = () => {
|
||||
if (textParts.length === 0) {
|
||||
@@ -480,7 +475,7 @@ export async function readSessionMessages(
|
||||
if (!joined.trim()) {
|
||||
return;
|
||||
}
|
||||
const textRow: JsonRecord = {
|
||||
out.push({
|
||||
id: `${messageIdBase}_text_${textSegmentIndex}`,
|
||||
sessionId,
|
||||
role,
|
||||
@@ -491,48 +486,8 @@ export async function readSessionMessages(
|
||||
// the run; later segments must not acquire a fallback ordinal in
|
||||
// the webview.
|
||||
meta: textMeta ?? (role === "user" ? { userRunSpan: 0 } : undefined),
|
||||
};
|
||||
out.push(textRow);
|
||||
reasoningTextTarget = textRow;
|
||||
textSegmentIndex += 1;
|
||||
textMeta = undefined;
|
||||
};
|
||||
const flushReasoningParts = () => {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const redacted = reasoningRedacted;
|
||||
reasoningParts.length = 0;
|
||||
reasoningRedacted = false;
|
||||
// Consumed per flush: reasoning must only attach to a text row from
|
||||
// its own segment, never to one emitted before an earlier tool call.
|
||||
const target = reasoningTextTarget;
|
||||
reasoningTextTarget = undefined;
|
||||
if (!reasoning && !redacted) {
|
||||
return;
|
||||
}
|
||||
if (target) {
|
||||
if (reasoning) {
|
||||
const existing =
|
||||
typeof target.reasoning === "string" && target.reasoning
|
||||
? `${target.reasoning}\n`
|
||||
: "";
|
||||
target.reasoning = `${existing}${reasoning}`;
|
||||
}
|
||||
if (redacted) {
|
||||
target.reasoningRedacted = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.push({
|
||||
id: `${messageIdBase}_reasoning_${reasoningSegmentIndex}`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: redacted || undefined,
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: textMeta,
|
||||
});
|
||||
reasoningSegmentIndex += 1;
|
||||
textSegmentIndex += 1;
|
||||
textMeta = undefined;
|
||||
};
|
||||
|
||||
@@ -549,13 +504,6 @@ export async function readSessionMessages(
|
||||
const blockType = typeof record.type === "string" ? record.type : "";
|
||||
if (blockType === "tool_use") {
|
||||
flushTextParts();
|
||||
// Everything the model emitted in this message — thinking
|
||||
// included — happened before the tool executed. Flushing the
|
||||
// reasoning here keeps the thinking row ahead of the tool row
|
||||
// (matching the live-stream order) so the webview never attaches
|
||||
// pre-tool reasoning to a later answer, which would drag the
|
||||
// work summary's duration anchor back before the tool ran.
|
||||
flushReasoningParts();
|
||||
const toolName =
|
||||
typeof record.name === "string" ? record.name : "tool_call";
|
||||
const toolUseId = typeof record.id === "string" ? record.id : "";
|
||||
@@ -683,7 +631,32 @@ export async function readSessionMessages(
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
flushReasoningParts();
|
||||
if (reasoningParts.length > 0 || reasoningRedacted) {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role);
|
||||
if (target) {
|
||||
if (reasoning) {
|
||||
target.reasoning = reasoning;
|
||||
}
|
||||
if (reasoningRedacted) {
|
||||
target.reasoningRedacted = true;
|
||||
}
|
||||
} else {
|
||||
out.push({
|
||||
id: `${messageIdBase}_reasoning`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (textMeta && out[outStartIndex]) {
|
||||
out[outStartIndex].meta = {
|
||||
...(typeof out[outStartIndex].meta === "object"
|
||||
|
||||
@@ -82,7 +82,6 @@ export type ToolApprovalRequestItem = {
|
||||
|
||||
export type PendingToolApproval = {
|
||||
item: ToolApprovalRequestItem;
|
||||
owner: SidecarWebSocketClient;
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
};
|
||||
|
||||
@@ -106,7 +105,6 @@ export type PendingAskQuestion = {
|
||||
};
|
||||
|
||||
export type SidecarWebSocketClient = {
|
||||
data?: { canApproveTools?: boolean };
|
||||
send: (message: string) => void;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Cline uses the microphone to transcribe speech into chat input.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Cline uses speech recognition to turn your voice into chat input.</string>
|
||||
<string>Cline Code uses the microphone to transcribe speech into chat input.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 110 KiB |
@@ -9,8 +9,5 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Voice input requires audio capture access when Hardened Runtime is enabled. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 839 B After Width: | Height: | Size: 439 B |
@@ -6,7 +6,7 @@ use std::sync::OnceLock;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const DEV_APP_DIRECTORY: &str = "notification-identity";
|
||||
const DEV_BUNDLE_NAME: &str = "Cline.app";
|
||||
const DEV_BUNDLE_NAME: &str = "Cline Code.app";
|
||||
const LAUNCH_SERVICES_REGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
||||
|
||||
static CONFIGURATION: OnceLock<Result<(), String>> = OnceLock::new();
|
||||
@@ -200,13 +200,17 @@ mod tests {
|
||||
fs::write(&executable, b"test executable").unwrap();
|
||||
fs::write(&icon, b"test icon").unwrap();
|
||||
|
||||
let bundle =
|
||||
create_dev_application_bundle(&executable, &icon, "bot.cline.app.dev", "Cline Dev")
|
||||
.unwrap();
|
||||
let bundle = create_dev_application_bundle(
|
||||
&executable,
|
||||
&icon,
|
||||
"bot.cline.app.dev",
|
||||
"Cline Code Dev",
|
||||
)
|
||||
.unwrap();
|
||||
let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
|
||||
|
||||
assert!(plist.contains("<string>bot.cline.app.dev</string>"));
|
||||
assert!(plist.contains("<string>Cline Dev</string>"));
|
||||
assert!(plist.contains("<string>Cline Code Dev</string>"));
|
||||
assert_eq!(
|
||||
fs::read_link(bundle.join("Contents/MacOS/cline-app")).unwrap(),
|
||||
executable
|
||||
|
||||
@@ -112,12 +112,6 @@ struct UpdateState {
|
||||
// concurrently and the later one can overwrite a freshly staged "ready"
|
||||
// with "idle"/"error" decided from its stale pre-await snapshot.
|
||||
cycle: tokio::sync::Mutex<()>,
|
||||
// Windows only: the downloaded-but-not-installed update. On Windows,
|
||||
// Update::install launches the NSIS installer and std::process::exit(0)s
|
||||
// immediately, so installation must wait for the user-initiated restart
|
||||
// instead of running inside the background cycle like it does on macOS.
|
||||
#[cfg(windows)]
|
||||
pending_install: Mutex<Option<(tauri_plugin_updater::Update, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
@@ -168,7 +162,7 @@ fn running_sessions_text(running_sessions: u32) -> String {
|
||||
}
|
||||
|
||||
// app_name is package_info().name (the configured productName), so beta
|
||||
// builds ("Cline Beta") identify themselves in the tooltip too.
|
||||
// builds ("Cline Code Beta") identify themselves in the tooltip too.
|
||||
fn tray_tooltip_text(app_name: &str, running_sessions: u32) -> String {
|
||||
if running_sessions == 0 {
|
||||
app_name.to_string()
|
||||
@@ -230,30 +224,12 @@ async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
|
||||
return;
|
||||
}
|
||||
set_update_status(app, state, "downloading", Some(version.clone()), None);
|
||||
// macOS: install right away — it only swaps the .app on disk and
|
||||
// the running app keeps going until the user restarts. Windows:
|
||||
// download only, because install() launches the NSIS installer
|
||||
// and exits the process on the spot; the staged bytes are
|
||||
// installed by restart_to_apply_update instead.
|
||||
#[cfg(not(windows))]
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => set_update_status(app, state, "ready", Some(version), None),
|
||||
Err(error) => {
|
||||
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
match update.download(|_, _| {}, || {}).await {
|
||||
Ok(bytes) => {
|
||||
if let Ok(mut pending) = state.pending_install.lock() {
|
||||
*pending = Some((update, bytes));
|
||||
}
|
||||
set_update_status(app, state, "ready", Some(version), None);
|
||||
}
|
||||
Err(error) => {
|
||||
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if ready_version.is_none() {
|
||||
@@ -299,24 +275,35 @@ impl DesktopBackendState {
|
||||
*guard = true;
|
||||
}
|
||||
|
||||
if let Ok(endpoint_guard) = self.ws_endpoint.lock() {
|
||||
if let Some(endpoint) = endpoint_guard.as_ref() {
|
||||
request_desktop_backend_shutdown(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
// Quit runs this on the main thread (on macOS inside
|
||||
// applicationWillTerminate:, where blocking beach-balls the
|
||||
// app), so signal the sidecar and return without waiting.
|
||||
// SIGTERM triggers its own bounded graceful shutdown
|
||||
// (SHUTDOWN_TIMEOUT_MS in sidecar/index.ts), after which it
|
||||
// exits itself, finishing session persistence as an orphan.
|
||||
#[cfg(unix)]
|
||||
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
|
||||
// released before the NSIS installer replaces it.
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
// The sidecar bounds its own graceful shutdown with
|
||||
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
|
||||
// itself; wait past that window before escalating to kill so
|
||||
// an active session can finish persisting.
|
||||
for _ in 0..70 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => thread::sleep(Duration::from_millis(100)),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
*process_guard = None;
|
||||
@@ -345,29 +332,13 @@ struct DesktopBackendReadyLine {
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
/// The release binary is a GUI-subsystem app (no console), so on Windows
|
||||
/// every console-subsystem child (git, cmd, the sidecar) would otherwise
|
||||
/// allocate its own visible console window. Piped stdio does not prevent
|
||||
/// that; only CREATE_NO_WINDOW does.
|
||||
#[cfg(windows)]
|
||||
fn hide_console_window(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn hide_console_window(_command: &mut Command) {}
|
||||
|
||||
fn resolve_workspace_root(launch_cwd: &str) -> String {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(launch_cwd)
|
||||
.arg("rev-parse")
|
||||
.arg("--show-toplevel");
|
||||
hide_console_window(&mut command);
|
||||
let output = command.output();
|
||||
.arg("--show-toplevel")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(result) if result.status.success() => {
|
||||
@@ -382,6 +353,51 @@ fn resolve_workspace_root(launch_cwd: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_desktop_backend_shutdown(endpoint: &str) {
|
||||
let trimmed = endpoint.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let base = trimmed.strip_suffix('/').unwrap_or(trimmed);
|
||||
let url = format!("{base}/shutdown");
|
||||
let timeout_seconds = "2";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"try {{ Invoke-WebRequest -UseBasicParsing -Method Post -Uri '{}' -TimeoutSec {} | Out-Null }} catch {{ }}",
|
||||
url.replace('\'', "''"),
|
||||
timeout_seconds
|
||||
),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = Command::new("curl")
|
||||
.args([
|
||||
"-fsS",
|
||||
"--connect-timeout",
|
||||
timeout_seconds,
|
||||
"--max-time",
|
||||
timeout_seconds,
|
||||
"-X",
|
||||
"POST",
|
||||
&url,
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf> {
|
||||
let launch_cwd = PathBuf::from(&context.launch_cwd);
|
||||
let candidates = [
|
||||
@@ -485,9 +501,7 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
hide_console_window(&mut command);
|
||||
command
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
|
||||
}
|
||||
@@ -610,11 +624,7 @@ fn resolve_mcp_settings_path() -> Result<PathBuf, String> {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
// USERPROFILE is the Windows equivalent of HOME (and what the sidecar's
|
||||
// homedir() resolves there); HOME is usually unset on Windows.
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map_err(|_| "neither HOME nor USERPROFILE is set".to_string())?;
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join(".cline")
|
||||
.join("data")
|
||||
@@ -638,10 +648,8 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_arg = path.to_string_lossy().to_string();
|
||||
let mut command = Command::new("cmd");
|
||||
command.args(["/C", "start", "", &path_arg]);
|
||||
hide_console_window(&mut command);
|
||||
let status = command
|
||||
let status = Command::new("cmd")
|
||||
.args(["/C", "start", "", &path_arg])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to open path: {e}"))?;
|
||||
if status.success() {
|
||||
@@ -728,52 +736,13 @@ fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus
|
||||
fn restart_to_apply_update(
|
||||
app: tauri::AppHandle,
|
||||
backend_state: State<'_, Arc<DesktopBackendState>>,
|
||||
update_state: 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,
|
||||
// which the NSIS installer needs in order to replace it.
|
||||
backend_state.stop();
|
||||
// Windows: install the bytes staged by the background cycle. install()
|
||||
// launches the NSIS installer (which relaunches the app when done) and
|
||||
// exits this process, so it only returns on failure — fall through to a
|
||||
// plain restart of the current version in that case.
|
||||
#[cfg(windows)]
|
||||
if let Some((update, bytes)) = update_state
|
||||
.pending_install
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut pending| pending.take())
|
||||
{
|
||||
if let Err(error) = update.install(bytes) {
|
||||
eprintln!("[updater] failed to launch the update installer: {error}");
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = update_state;
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Relaunch the current version of the app. Used after the sidecar replaces
|
||||
/// the shared Cline Hub under the running app (the "Cline Hub update
|
||||
/// required" flow): a fresh launch attaches everything to the new Hub instead
|
||||
/// of trying to migrate live connections. restart() never returns, so the
|
||||
/// run-loop Exit handler cannot stop the sidecar; do it explicitly first.
|
||||
#[tauri::command]
|
||||
fn relaunch_app(app: tauri::AppHandle, backend_state: State<'_, Arc<DesktopBackendState>>) {
|
||||
// restart() never returns, so the run-loop Exit handler does not get a
|
||||
// chance to stop the sidecar; shut it down explicitly first.
|
||||
backend_state.stop();
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Quit the app. Used by the "Cline Hub update required" flow when the user
|
||||
/// chooses to keep the older running Hub (and its live sessions) and update
|
||||
/// later. The run-loop Exit handler stops the sidecar.
|
||||
#[tauri::command]
|
||||
fn quit_app(app: tauri::AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Run one updater check/download/stage cycle immediately instead of waiting
|
||||
/// for the next background interval, and report the resulting status. Used by
|
||||
/// flows that need an update staged right now (e.g. the "Cline Hub was
|
||||
@@ -791,7 +760,7 @@ async fn check_for_update_now(
|
||||
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
|
||||
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
|
||||
/// resource at icons/dock/<id>.png.
|
||||
const APP_DOCK_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
|
||||
const APP_DOCK_ICONS: [&str; 4] = ["classic", "sunrise", "steel", "midnight"];
|
||||
|
||||
#[tauri::command]
|
||||
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
@@ -1067,7 +1036,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
|
||||
.text(
|
||||
TRAY_OPEN_MENU_ID,
|
||||
// package_info().name is the configured productName, so beta
|
||||
// builds ("Cline Beta") identify themselves in the tray too.
|
||||
// builds ("Cline Code Beta") identify themselves in the tray too.
|
||||
format!(
|
||||
"{} v{}",
|
||||
app.package_info().name,
|
||||
@@ -1231,9 +1200,7 @@ fn main() {
|
||||
set_app_icon,
|
||||
show_session_notification,
|
||||
drain_desktop_actions,
|
||||
set_tray_status,
|
||||
relaunch_app,
|
||||
quit_app
|
||||
set_tray_status
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri app")
|
||||
@@ -1258,16 +1225,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn macos_bundle_declares_voice_input_permissions() {
|
||||
let info_plist = include_str!("../Info.plist");
|
||||
assert!(info_plist.contains("<key>NSMicrophoneUsageDescription</key>"));
|
||||
assert!(info_plist.contains("<key>NSSpeechRecognitionUsageDescription</key>"));
|
||||
|
||||
let entitlements = include_str!("../entitlements.plist");
|
||||
assert!(entitlements.contains("<key>com.apple.security.device.audio-input</key>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_actions_are_buffered_in_order_until_drained() {
|
||||
let state = DesktopActionState::default();
|
||||
@@ -1369,11 +1326,14 @@ mod tests {
|
||||
assert_eq!(running_sessions_text(0), "0 sessions running");
|
||||
assert_eq!(running_sessions_text(1), "1 session running");
|
||||
assert_eq!(running_sessions_text(3), "3 sessions running");
|
||||
assert_eq!(tray_tooltip_text("Cline", 0), "Cline");
|
||||
assert_eq!(tray_tooltip_text("Cline", 3), "Cline — 3 sessions running");
|
||||
assert_eq!(tray_tooltip_text("Cline Code", 0), "Cline Code");
|
||||
assert_eq!(
|
||||
tray_tooltip_text("Cline Beta", 2),
|
||||
"Cline Beta — 2 sessions running"
|
||||
tray_tooltip_text("Cline Code", 3),
|
||||
"Cline Code — 3 sessions running"
|
||||
);
|
||||
assert_eq!(
|
||||
tray_tooltip_text("Cline Code Beta", 2),
|
||||
"Cline Code Beta — 2 sessions running"
|
||||
);
|
||||
assert_eq!(tray_badge_text(0), None);
|
||||
assert_eq!(tray_badge_text(3), Some("3".to_string()));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Beta",
|
||||
"productName": "Cline Code Beta",
|
||||
"identifier": "bot.cline.app.beta",
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.21",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.14",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
"devUrl": "http://localhost:3125",
|
||||
"beforeBuildCommand": "bun run dmg:background && bun run build",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../webview/out"
|
||||
},
|
||||
"plugins": {
|
||||
@@ -21,7 +21,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Cline",
|
||||
"title": "Cline Code",
|
||||
"width": 1500,
|
||||
"height": 980,
|
||||
"resizable": true,
|
||||
@@ -48,22 +48,7 @@
|
||||
],
|
||||
"macOS": {
|
||||
"entitlements": "entitlements.plist",
|
||||
"hardenedRuntime": true,
|
||||
"dmg": {
|
||||
"background": "dmg/background.gen.tiff",
|
||||
"windowSize": {
|
||||
"width": 640,
|
||||
"height": 432
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 140,
|
||||
"y": 200
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 500,
|
||||
"y": 200
|
||||
}
|
||||
}
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Dev",
|
||||
"productName": "Cline Code Dev",
|
||||
"identifier": "bot.cline.app.dev"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AttachmentDropZone } from "@cline/ui";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ImagePlus, Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import { AgentHeader } from "@/components/agent-header";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
|
||||
import { SessionCommandBar } from "@/components/session-command-bar";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -38,11 +36,6 @@ import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
|
||||
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
|
||||
import type { SettingsSection } from "@/components/views/settings/sections";
|
||||
import {
|
||||
WindowTitleBar,
|
||||
WindowTitleBarContent,
|
||||
WindowTitleBarProvider,
|
||||
} from "@/components/window-title-bar";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { useAppUpdate } from "@/hooks/use-app-update";
|
||||
@@ -72,7 +65,6 @@ import {
|
||||
markOnboardingCompleted,
|
||||
ONBOARDING_RESET_EVENT,
|
||||
} from "@/lib/onboarding";
|
||||
import { requestPromptInputFocus } from "@/lib/prompt-input-focus";
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
@@ -172,9 +164,6 @@ export default function Home() {
|
||||
// Starts false on both server and first client render (hydration-safe);
|
||||
// the effect below reads the persisted state right after mount.
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [commandBarOpen, setCommandBarOpen] = useState(false);
|
||||
// Shared by the sidebar search icon and the Cmd/Ctrl+P shortcut.
|
||||
const handleOpenCommandBar = useCallback(() => setCommandBarOpen(true), []);
|
||||
// "welcome" for the full first-run flow; "connect" when re-entered from
|
||||
// the in-app "connect a model" notice, which should land directly on the
|
||||
// provider setup step.
|
||||
@@ -231,7 +220,6 @@ export default function Home() {
|
||||
|
||||
const handleNewThread = useCallback(() => {
|
||||
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
|
||||
requestPromptInputFocus();
|
||||
}, []);
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
@@ -301,7 +289,6 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
navigateWith({ view: "chat" });
|
||||
requestPromptInputFocus();
|
||||
}, [activeThread, handleNewThread, navigateWith]);
|
||||
const handleViewChange = useCallback(
|
||||
(nextView: DesktopAppView) => {
|
||||
@@ -309,21 +296,14 @@ export default function Home() {
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
// The sidebar's New row reads as selected while the fresh, not-yet-started
|
||||
// task page is showing; once the task starts the session row takes over.
|
||||
const newTaskActive =
|
||||
view === "chat" &&
|
||||
activeThread !== undefined &&
|
||||
!activeThread.hasStarted &&
|
||||
!activeThread.historySession;
|
||||
const handleSettingsSectionChange = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
navigateWith({ settingsSection: section, view: "settings" });
|
||||
},
|
||||
[navigateWith],
|
||||
);
|
||||
// Standard app shortcuts: Cmd/Ctrl+P for session search, Cmd/Ctrl+N for a
|
||||
// new session, and Cmd/Ctrl+, for settings.
|
||||
// Standard app shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for
|
||||
// settings — matching the tray menu actions.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (showOnboarding) {
|
||||
@@ -335,9 +315,6 @@ export default function Home() {
|
||||
if (event.key === "n" || event.key === "N") {
|
||||
event.preventDefault();
|
||||
handleNewThread();
|
||||
} else if (event.key === "p" || event.key === "P") {
|
||||
event.preventDefault();
|
||||
setCommandBarOpen((current) => !current);
|
||||
} else if (event.key === ",") {
|
||||
event.preventDefault();
|
||||
handleViewChange("settings");
|
||||
@@ -433,117 +410,98 @@ export default function Home() {
|
||||
return (
|
||||
<AccountProvider>
|
||||
<SidebarProvider>
|
||||
<WindowTitleBarProvider
|
||||
contentEnabled={!showOnboarding && view === "chat"}
|
||||
<div
|
||||
aria-hidden={showOnboarding ? true : undefined}
|
||||
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
|
||||
// The onboarding overlay is opaque and sits on top of the whole
|
||||
// shell; hiding the shell keeps its aurora + animations from
|
||||
// being composited every frame underneath while it still mounts
|
||||
// and loads (providers, history, transport) in the background.
|
||||
// `inert` additionally keeps the covered controls out of the
|
||||
// keyboard tab order and assistive tech while it is hidden.
|
||||
inert={showOnboarding ? true : undefined}
|
||||
style={showOnboarding ? { visibility: "hidden" } : undefined}
|
||||
>
|
||||
<div
|
||||
aria-hidden={showOnboarding ? true : undefined}
|
||||
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
|
||||
// The onboarding overlay is opaque and sits on top of the whole
|
||||
// shell; hiding the shell keeps its aurora + animations from
|
||||
// being composited every frame underneath while it still mounts
|
||||
// and loads (providers, history, transport) in the background.
|
||||
// `inert` additionally keeps the covered controls out of the
|
||||
// keyboard tab order and assistive tech while it is hidden.
|
||||
inert={showOnboarding ? true : undefined}
|
||||
style={showOnboarding ? { visibility: "hidden" } : undefined}
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<Sidebar
|
||||
className="border-r border-sidebar-border"
|
||||
collapsible="icon"
|
||||
>
|
||||
<AgentSidebar
|
||||
<AgentSidebar
|
||||
activeSessionId={activeHistorySessionId}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onNewThread={handleNewThread}
|
||||
onSettingsSectionChange={handleSettingsSectionChange}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
canNavigateBack={navigation.back.length > 0}
|
||||
canNavigateForward={navigation.forward.length > 0}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
newTaskActive={newTaskActive}
|
||||
onHome={handleHome}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onNavigateForward={handleNavigateForward}
|
||||
onOpenSearch={handleOpenCommandBar}
|
||||
onSettingsSectionChange={handleSettingsSectionChange}
|
||||
sessionHistory={sessionHistory}
|
||||
setView={handleViewChange}
|
||||
settingsSection={settingsSection}
|
||||
view={view}
|
||||
canNavigateBack={navigation.back.length > 0}
|
||||
canNavigateForward={navigation.forward.length > 0}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
|
||||
<WindowTitleBar />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
history={sessionHistory}
|
||||
/>
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
initialPromptDraft={activeThread.initialPromptDraft}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onInitialPromptDraftConsumed={
|
||||
handleInitialPromptDraftConsumed
|
||||
}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onOpenSetup={handleOpenSetup}
|
||||
onOpenModelSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
}
|
||||
parentSession={activeParentSession}
|
||||
onOpenVoiceInputSettings={() =>
|
||||
handleSettingsSectionChange("Voice")
|
||||
}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={handleSettingsSectionChange}
|
||||
onOpenSession={handleOpenSessionById}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
{showOnboarding ? (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<WindowTitleBar
|
||||
className="absolute inset-x-0 top-0 z-10"
|
||||
hostContent={false}
|
||||
/>
|
||||
<div className="h-full">
|
||||
<OnboardingView
|
||||
initialStep={onboardingInitialStep}
|
||||
onComplete={completeOnboarding}
|
||||
) : activeThread ? (
|
||||
<div
|
||||
aria-hidden={view === "settings" ? true : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
inert={view === "settings" ? true : undefined}
|
||||
>
|
||||
<ChatThreadPane
|
||||
key={activeThread.id}
|
||||
historySession={activeThread.historySession}
|
||||
initialPromptDraft={activeThread.initialPromptDraft}
|
||||
knownWorkspacePaths={historyWorkspacePaths}
|
||||
onInitialPromptDraftConsumed={
|
||||
handleInitialPromptDraftConsumed
|
||||
}
|
||||
onUpdateSessionMetadata={handleUpdateSessionMetadata}
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
onOpenSessionById={handleOpenSessionById}
|
||||
onOpenSetup={handleOpenSetup}
|
||||
onOpenModelSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
}
|
||||
parentSession={activeParentSession}
|
||||
onOpenVoiceInputSettings={() =>
|
||||
handleSettingsSectionChange("Models")
|
||||
}
|
||||
onThreadStarted={handleThreadStarted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</WindowTitleBarProvider>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<div className="absolute inset-0 z-30 bg-background text-foreground">
|
||||
<SettingsView
|
||||
onNavigateSection={handleSettingsSectionChange}
|
||||
onOpenSession={handleOpenSessionById}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
{showOnboarding ? (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<OnboardingView
|
||||
initialStep={onboardingInitialStep}
|
||||
onComplete={completeOnboarding}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<HubUpdateRequiredDialog />
|
||||
<SessionCommandBar
|
||||
onOpenChange={setCommandBarOpen}
|
||||
onOpenSession={handleOpenSessionById}
|
||||
open={commandBarOpen && !showOnboarding}
|
||||
/>
|
||||
</AccountProvider>
|
||||
);
|
||||
}
|
||||
@@ -638,6 +596,8 @@ function ChatThreadPane({
|
||||
promptInputRef.current = value;
|
||||
}, []);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<File[]>([]);
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const [showDiffView, setShowDiffView] = useState(false);
|
||||
const [deletingSession, setDeletingSession] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
@@ -1287,6 +1247,52 @@ function ChatThreadPane({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Drag-and-drop file attachments. Requires `dragDropEnabled: false` on the
|
||||
// Tauri window — otherwise the native shell swallows OS file drags and these
|
||||
// HTML5 events never fire.
|
||||
const handleDragEnter = useCallback((event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFiles(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsDraggingFiles(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFiles(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleAttachFiles(files);
|
||||
}
|
||||
},
|
||||
[handleAttachFiles],
|
||||
);
|
||||
|
||||
const attachmentList = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
@@ -1510,7 +1516,6 @@ function ChatThreadPane({
|
||||
const composer = (
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
hasRunningAgents={agentActivity.running > 0}
|
||||
onAbort={handleAbort}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onListGitBranches={listGitBranches}
|
||||
@@ -1543,41 +1548,55 @@ function ChatThreadPane({
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={workspaceContextValue}>
|
||||
{/* Requires `dragDropEnabled: false` on the Tauri window so the native shell does not swallow OS file drags. */}
|
||||
<AttachmentDropZone
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Drag-and-drop target only; the paperclip button is the accessible attach path. */}
|
||||
<div
|
||||
className={
|
||||
isWelcomeState
|
||||
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden"
|
||||
? "relative grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
|
||||
: "relative grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
|
||||
}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{!isWelcomeState ? (
|
||||
<WindowTitleBarContent>
|
||||
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
agentActivity={agentActivity}
|
||||
agents={agents}
|
||||
agentsError={agentsError}
|
||||
agentsLoading={agentsLoading}
|
||||
onAgentsOpenChange={setAgentPanelOpen}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
onOpenParentSession={onOpenSessionById}
|
||||
parentSession={hideDeletedSessionUi ? undefined : parentSession}
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={headerDiff}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={handleOpenDiff}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
{isDraggingFiles ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-card px-10 py-8 shadow-lg">
|
||||
<ImagePlus className="h-8 w-8 text-primary" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Drop to attach
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Screenshots and files will be added to your next message
|
||||
</p>
|
||||
</div>
|
||||
</WindowTitleBarContent>
|
||||
</div>
|
||||
) : null}
|
||||
{!isWelcomeState ? (
|
||||
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
agentActivity={agentActivity}
|
||||
agents={agents}
|
||||
agentsError={agentsError}
|
||||
agentsLoading={agentsLoading}
|
||||
onAgentsOpenChange={setAgentPanelOpen}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
onOpenParentSession={onOpenSessionById}
|
||||
parentSession={hideDeletedSessionUi ? undefined : parentSession}
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={headerDiff}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={handleOpenDiff}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
title={threadTitle}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<WelcomeScreen
|
||||
active={isWelcomeState}
|
||||
@@ -1623,10 +1642,9 @@ function ChatThreadPane({
|
||||
) : undefined
|
||||
}
|
||||
onListGitBranches={listGitBranches}
|
||||
onOpenSession={onOpenSessionById}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
/>
|
||||
</AttachmentDropZone>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AgendaTaskRecord } from "@cline/shared";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function AgendaTaskReviewDialog({
|
||||
task,
|
||||
open,
|
||||
pending,
|
||||
confirmLabel = "Approve",
|
||||
rejectLabel = "Reject",
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: {
|
||||
task: AgendaTaskRecord | null;
|
||||
open: boolean;
|
||||
pending: boolean;
|
||||
confirmLabel?: string;
|
||||
rejectLabel?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (task: AgendaTaskRecord) => void | Promise<void>;
|
||||
onReject?: (task: AgendaTaskRecord) => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="h-[min(720px,calc(100dvh-2rem))] w-[min(640px,calc(100vw-2rem))] max-w-none grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||||
{task ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the exact revision before it can start a new agent
|
||||
session.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs">
|
||||
<ReviewField label="Revision" value={String(task.revision)} />
|
||||
<ReviewField label="Priority" value={`P${task.priority}`} />
|
||||
<ReviewField label="Type" value={task.type} />
|
||||
<ReviewField label="Mode" value={task.mode ?? "act"} />
|
||||
<ReviewField
|
||||
label="Scope"
|
||||
value={
|
||||
task.scope === "workspace"
|
||||
? (task.workspaceRoot ?? "workspace")
|
||||
: "General / chat workspace"
|
||||
}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Expires"
|
||||
value={new Date(task.expiresAt).toLocaleString()}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Available"
|
||||
value={new Date(task.availableAt).toLocaleString()}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Assignee"
|
||||
value={task.assignee ?? "Default agent"}
|
||||
/>
|
||||
<ReviewField
|
||||
label="Model"
|
||||
value={
|
||||
task.modelSelection
|
||||
? `${task.modelSelection.providerId}/${task.modelSelection.modelId ?? "default"}`
|
||||
: "Cline default"
|
||||
}
|
||||
/>
|
||||
{task.cwd ? (
|
||||
<ReviewField label="Working directory" value={task.cwd} />
|
||||
) : null}
|
||||
<ReviewField
|
||||
label="Run limits"
|
||||
value={
|
||||
[
|
||||
task.maxIterations
|
||||
? `${task.maxIterations} iterations`
|
||||
: undefined,
|
||||
task.timeoutSeconds
|
||||
? `${task.timeoutSeconds}s timeout`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Hub defaults"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{task.description ? (
|
||||
<ReviewText label="Description" value={task.description} />
|
||||
) : null}
|
||||
<ReviewText label="Instructions" value={task.instructions} />
|
||||
{task.systemPrompt ? (
|
||||
<ReviewText
|
||||
label="System prompt override"
|
||||
value={task.systemPrompt}
|
||||
/>
|
||||
) : null}
|
||||
{task.resourcePaths.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium">Files</h4>
|
||||
<ul className="space-y-1 rounded-md border bg-muted/20 p-3 font-mono text-[11px]">
|
||||
{task.resourcePaths.map((path) => (
|
||||
<li className="break-all" key={path}>
|
||||
{path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (onReject) void onReject(task);
|
||||
else onOpenChange(false);
|
||||
}}
|
||||
type="button"
|
||||
variant={onReject ? "destructive" : "outline"}
|
||||
>
|
||||
{onReject ? rejectLabel : "Not now"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => void onConfirm(task)}
|
||||
type="button"
|
||||
>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className="truncate font-medium capitalize" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewText({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium">{label}</h4>
|
||||
<div className="whitespace-pre-wrap rounded-md border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||