Compare commits

..
Author SHA1 Message Date
Saoud Rizwan ac113474b2 fix(vscode): move webview dev server off the cline hub port
The webview-ui vite dev server and WebviewProvider's DEFAULT_PORT both
defaulted to 25463, which is CLINE_HUB_PORT (25463 spells CLINE) — the
port the installed cline CLI's hub daemon listens on. With the daemon
running, vite silently bumps to another port on every F5. The .vite-port
file keeps the extension in sync so this mostly works, but it breaks
setups that need a predictable port to forward (e.g. VS Code attached to
a dev container, where the webview renders on the host and the dev
server runs in the container).

Move the default to 25465, clear of both CLINE_HUB_PORT (25463) and
CLINE_HUB_DEV_PORT (25466).
2026-07-10 00:48:43 -07:00
660 changed files with 37297 additions and 145744 deletions
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-127
View File
@@ -1,127 +0,0 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (signed + notarized DMG for 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.
## Release contract
- 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.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
2. Collect release commits.
```sh
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Repo secrets (one-time setup)
The workflow needs these repository secrets. The Apple ones come from the same
Apple Developer account used for manual signing (see the app README's "macOS
signing & notarization" section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
OTEL settings) are shared with the CLI publish workflow and already configured.
-158
View File
@@ -1,158 +0,0 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -1,4 +0,0 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+2 -3
View File
@@ -8,9 +8,8 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
-1
View File
@@ -16,7 +16,6 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
-310
View File
@@ -1,310 +0,0 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
needs: validate
runs-on: macos-latest
timeout-minutes: 90
strategy:
fail-fast: true
matrix:
include:
- target: aarch64-apple-darwin
arch: aarch64
- target: x86_64-apple-darwin
arch: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
- 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: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
env:
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: |
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--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)"
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
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/desktop-latest/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
-205
View File
@@ -1,205 +0,0 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
package:
name: Build combined (legacy + next) VSIX
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
path: next-src
lfs: true
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
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 }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
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 }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
+47 -211
View File
@@ -1,40 +1,17 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -43,7 +20,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline'
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
permissions:
contents: read
pull-requests: read
@@ -53,79 +30,60 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout next (SDK) source
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# 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
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
- name: Show build source
working-directory: ${{ github.workspace }}
run: |
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: next-src
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -135,24 +93,20 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Build next bundle
working-directory: next-src/apps/vscode
- name: Publish Nightly Extension
env:
CLINE_ENVIRONMENT: production
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -160,129 +114,12 @@ jobs:
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 }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
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 }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
working-directory: ${{ github.workspace }}
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -290,11 +127,10 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.101.0
VSCODE_TEST_VERSION: 1.103.0
strategy:
fail-fast: false
matrix:
-58
View File
@@ -260,41 +260,6 @@ jobs:
git push origin "refs/tags/${TAG}"
done
- name: Get Previous SDK Tag
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: prev_tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# The checkout is shallow and tagless, so fetch the release tags explicitly.
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: changelog
run: |
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
DELIMITER=$(openssl rand -hex 8)
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$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
with:
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
name: "SDK v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
@@ -315,26 +280,3 @@ jobs:
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
- name: Post release to Slack
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline SDK v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline SDK v${{ steps.version.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
-145
View File
@@ -1,145 +0,0 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
-2
View File
@@ -42,8 +42,6 @@ event names. It exports:
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
**All events should be named using snake_case and so should their properties**
## The Activation Funnel
The canonical funnel that downstream analytics depends on:
-46
View File
@@ -1,51 +1,5 @@
# Cline CLI Changelog
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
- Fixed provider config not reloading when switching models
- Fixed auto-update failing to detect Bun global installs after symlink resolution
- Fixed unexpected logouts caused by transient network or server errors during token refresh
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
- Session id is now preserved when continuing within the same session
- Hardened context compaction budget handling
## 3.0.39
- You can now select Cline free models on the ClinePass provider in the model picker
+1 -16
View File
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
@@ -346,24 +346,9 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
-281
View File
@@ -1,281 +0,0 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
-42
View File
@@ -23,48 +23,6 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.46",
"version": "3.0.39",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -78,19 +78,19 @@
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.4.3",
"@opentui/react": "0.4.3",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.7",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.33.0",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
-367
View File
@@ -1,367 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+2 -3
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,7 +15,6 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -210,7 +209,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
-35
View File
@@ -9,7 +9,6 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -175,40 +174,6 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
-7
View File
@@ -14,7 +14,6 @@ import {
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
@@ -50,8 +49,6 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -340,8 +337,6 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -424,8 +419,6 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
-4
View File
@@ -34,7 +34,6 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -64,7 +63,6 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -90,8 +88,6 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
-3
View File
@@ -9,7 +9,6 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -135,8 +134,6 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
+2 -4
View File
@@ -148,10 +148,8 @@ export function isJsonPath(path: string): boolean {
return path.toLowerCase().endsWith(".json");
}
export function parseMode(
raw: string | undefined,
): "act" | "plan" | "yolo" | undefined {
if (raw === "act" || raw === "plan" || raw === "yolo") {
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
if (raw === "act" || raw === "plan") {
return raw;
}
return undefined;
+3 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -10,7 +9,6 @@ import {
mergeScheduleMetadata,
parseJsonObjectFlag,
parseList,
parseMode,
resolveAddress,
toPositiveInt,
} from "./common";
@@ -65,8 +63,8 @@ export function registerScheduleCommands(
.option("--disabled", "Create in disabled state")
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--mode <act|plan>", "Execution mode")
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
.option("--provider <id>", "Provider ID", "cline")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
@@ -98,7 +96,7 @@ export function registerScheduleCommands(
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
mode: parseMode(opts.mode) ?? "yolo",
mode: opts.mode === "plan" ? "plan" : "act",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
systemPrompt: opts.systemPrompt,
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -40,7 +39,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
"openai/gpt-5.3-codex",
).trim();
return { provider, model };
}
@@ -166,10 +165,7 @@ export function registerScheduleImportCommand(
prompt: String(parsed.prompt ?? "").trim(),
provider,
model,
mode:
parseMode(
typeof parsed.mode === "string" ? parsed.mode : undefined,
) ?? "yolo",
mode: parsed.mode === "plan" ? "plan" : "act",
workspaceRoot,
cwd: String(parsed.cwd ?? "").trim() || undefined,
systemPrompt:
@@ -233,7 +229,7 @@ export function registerScheduleUpdateCommand(
.option("--enabled", "Enable the schedule")
.option("--max-parallel <n>", "New max parallel executions")
.option("--metadata-json <json>", "New metadata as JSON object")
.option("--mode <act|plan|yolo>", "New execution mode")
.option("--mode <act|plan>", "New execution mode")
.option("--model <model>", "New model")
.option("--name <name>", "New name")
.option("--pause", "Pause the schedule")
-16
View File
@@ -101,22 +101,6 @@ describe("getInstallationInfo", () => {
});
});
it("detects bun global installs from the resolved install path", () => {
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
// and realpathSync resolves through the symlink before detection runs.
const wrapperPath = createTempFile(
".bun/install/global/node_modules/cline/bin/cline",
);
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.BUN,
packageName: "cline",
updateCommand: "bun add -g cline@latest",
});
});
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
delete process.env.CLINE_WRAPPER_PATH;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
+1 -6
View File
@@ -118,12 +118,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
};
}
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
// them to ~/.bun/install/global/node_modules/..., so match both.
if (
scriptPath.includes("/.bun/bin") ||
scriptPath.includes("/.bun/install/global/")
) {
if (scriptPath.includes("/.bun/bin")) {
return {
packageManager: PackageManager.BUN,
packageName: DEFAULT_PACKAGE_NAME,
@@ -67,62 +67,6 @@ describe("createConnectorRuntimeTurnStream", () => {
});
});
it("keeps streaming when tool status delivery fails", async () => {
let handlers: StreamHandlers | undefined;
const log = vi.fn();
const statusError = new Error("message_not_found");
const client = {
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
handlers = callbacks;
return () => {};
},
sendRuntimeSession: async () => {
handlers?.onEvent({
eventType: "runtime.chat.tool_call_start",
payload: { toolName: "run_commands" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
handlers?.onEvent({
eventType: "runtime.chat.text_delta",
payload: { text: "Final response" },
});
return {
result: {
text: "Final response",
finishReason: "stop",
iterations: 1,
},
};
},
};
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "hi" },
clientId: "client-1",
logger: { core: { log } } as unknown as CliLoggerAdapter,
transport: "slack",
conversationId: "thread-1",
onToolStatus: async () => {
throw statusError;
},
})) {
chunks.push(chunk);
}
expect(chunks.join("")).toBe("Final response");
expect(log).toHaveBeenCalledWith(
"Connector tool status delivery failed",
expect.objectContaining({
severity: "warn",
transport: "slack",
error: statusError,
}),
);
});
it("treats queued runtime turns as non-error completion", async () => {
const log = vi.fn();
const client = {
+1 -11
View File
@@ -169,17 +169,7 @@ export function createConnectorRuntimeTurnStream(input: {
return;
}
lastStatusMessage = message;
try {
await input.onToolStatus?.(message);
} catch (error) {
input.logger.core.log("Connector tool status delivery failed", {
severity: "warn",
transport: input.transport,
conversationId: input.conversationId,
sessionId: input.sessionId,
error,
});
}
await input.onToolStatus?.(message);
};
const stopStreaming = input.client.streamEvents(
+5 -4
View File
@@ -158,9 +158,8 @@ vi.mock("./runtime/run-interactive", () => {
});
vi.mock("./utils/session", () => sessionMocks);
vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", async () => {
vi.mock("@cline/core", () => {
return {
...(await vi.importActual("@cline/core")),
resolveProviderConfig: llmMocks.resolveProviderConfig,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
@@ -333,7 +332,7 @@ describe("runCli lightweight command dispatch", () => {
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
}, 30_000);
});
it("does not load runtime modules for root update", async () => {
mockState.runAgentImports = 0;
@@ -1302,6 +1301,7 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
thinking: true,
reasoningEffort: "medium",
@@ -1388,7 +1388,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("uses Core's agentic compaction default for prompt runs", async () => {
it("enables truncation compaction by default for prompt runs", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1403,6 +1403,7 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
}),
expect.anything(),
+3 -20
View File
@@ -928,17 +928,6 @@ export async function runCli(): Promise<void> {
runAgent,
} = await loadCliRuntimeModules();
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
// These components operate before/outside ClineCore sessions, so the
// session-scoped logger can't reach them.
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
component: "main",
});
coreServer.setSdkLogger(loggerAdapter.core);
const userInstructionService = createUserInstructionConfigService({
skills: {
workspacePath: workspaceRoot,
@@ -982,15 +971,9 @@ export async function runCli(): Promise<void> {
// and cannot be retroactively updated; this is by design for
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
if (provider === "cline") {
const savedAuth = selectedProviderSettings?.auth;
if (savedAuth?.accountId) {
identifyTelemetryAccount({
id: savedAuth.accountId,
provider: "cline",
organizationId: savedAuth.organizationId,
organizationName: savedAuth.organizationName,
memberId: savedAuth.memberId,
});
const savedAccountId = selectedProviderSettings?.auth?.accountId;
if (savedAccountId) {
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
}
}
+8 -5
View File
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
let savedRejectionListeners: Function[] | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
savedRejectionListeners = process.rawListeners(
"unhandledRejection",
) as Function[];
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
@@ -68,7 +68,10 @@ export function clearAbortInProgress(): void {
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
for (const listener of savedRejectionListeners) {
process.on("unhandledRejection", listener);
process.on(
"unhandledRejection",
listener as (...args: unknown[]) => void,
);
}
savedRejectionListeners = undefined;
}
@@ -12,25 +12,6 @@ import {
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
@@ -65,7 +46,6 @@ function createProviderSettingsManager(): ProviderSettingsManager {
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -126,7 +106,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(400_000);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -150,7 +130,7 @@ describe("compactInteractiveMessages", () => {
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
it("falls back to legacy contextWindow for manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -158,7 +138,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(360_000);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -183,15 +163,6 @@ describe("compactInteractiveMessages", () => {
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -218,17 +189,6 @@ describe("compactInteractiveMessages", () => {
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
+10 -10
View File
@@ -61,15 +61,11 @@ export async function compactInteractiveMessages(input: {
compactionState?: SessionCompactionState;
}> {
const modelInfo = input.config.knownModels?.[input.config.modelId];
const compactionModelInfo = modelInfo
? {
...modelInfo,
id: modelInfo.id ?? input.config.modelId,
}
: {
id: input.config.modelId,
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
};
const maxInputTokens =
input.config.compaction?.maxInputTokens ??
modelInfo?.maxInputTokens ??
modelInfo?.contextWindow ??
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
const compact = createContextCompactionPrepareTurn(
{
providerConfig: resolveCompactionProviderConfig(
@@ -110,7 +106,11 @@ export async function compactInteractiveMessages(input: {
model: {
id: input.config.modelId,
provider: input.config.providerId,
info: compactionModelInfo,
info: {
...(modelInfo ?? {}),
id: modelInfo?.id ?? input.config.modelId,
maxInputTokens: maxInputTokens,
},
},
});
if (!result?.messages) {
+32 -7
View File
@@ -107,13 +107,38 @@ export async function sendTurnWithActModeContinuation<
};
}
// The tracker moved to @cline/shared so the VSCode extension can share the
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
// import surface stable.
export {
createModeSwitchNoticeTracker,
type ModeSwitchNotice,
} from "@cline/shared";
export type ModeSwitchNotice = {
from: InteractiveUiMode;
to: InteractiveUiMode;
};
/**
* Tracks a user-initiated mode switch so the next user message can carry a
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
* switch_to_act_mode path already announces itself via the continuation
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
* out, since the mode the model last saw never effectively changed.
*/
export function createModeSwitchNoticeTracker() {
let pending: ModeSwitchNotice | null = null;
return {
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
if (from === to) {
return;
}
if (pending) {
pending = pending.from === to ? null : { from: pending.from, to };
return;
}
pending = { from, to };
},
consume(): ModeSwitchNotice | null {
const notice = pending;
pending = null;
return notice;
},
};
}
export async function applyInteractiveModeConfig(input: {
config: Config;
@@ -157,7 +157,6 @@ function makeManager() {
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(async () => {}),
pendingPrompts: {
update: vi.fn(),
},
@@ -815,83 +814,6 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
const manager = makeManager();
const config = {
...createConfig(),
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
};
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "hello" }] },
];
manager.readMessages.mockResolvedValue(messages);
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
}),
}),
);
config.providerId = "openai-compatible";
config.modelId = "custom-model";
config.apiKey = "new-key";
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "session-1",
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
}),
initialMessages: messages,
}),
);
});
it("updates the active session connection in place without restarting", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.updateCurrentSessionConnection({
providerId: "openai",
modelId: "codex-test",
});
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
providerId: "openai",
modelId: "codex-test",
});
expect(manager.start).toHaveBeenCalledTimes(1);
expect(runtime.getActiveSessionId()).toBe("session-1");
});
it("does not reuse the session id when restarting empty", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartEmpty();
expect(manager.start).toHaveBeenCalledTimes(2);
const secondStart = manager.start.mock.calls[1]?.[0] as {
config?: { sessionId?: string };
};
expect(secondStart?.config?.sessionId).toBeUndefined();
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
@@ -49,9 +49,6 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
export type SessionConnectionUpdate = Parameters<
CliCore["updateSessionConnection"]
>[1];
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
@@ -213,18 +210,12 @@ export function createInteractiveSessionRuntime(input: {
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
// Restarting an old session associate with this ID,
// For continuing the same conversation, e.g. after a config change.
sessionId?: string,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const started = await manager.start({
source: SessionSource.CLI,
config: {
...buildSessionConfig(),
...(sessionId ? { sessionId } : {}),
},
config: buildSessionConfig(),
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
@@ -424,14 +415,7 @@ export function createInteractiveSessionRuntime(input: {
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
options?: { preserveSessionId?: boolean },
): Promise<void> => {
// Config-only restarts (model/mode/account changes) continue the same
// conversation, so they must keep the session id — otherwise each
// restart mints a new session history entry for the same conversation.
const reuseSessionId = options?.preserveSessionId
? activeSessionId || undefined
: undefined;
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
@@ -447,7 +431,6 @@ export function createInteractiveSessionRuntime(input: {
messages,
sessionMetadata,
initialCompactionState,
reuseSessionId,
);
})().catch((error) => {
startupError = error;
@@ -490,24 +473,9 @@ export function createInteractiveSessionRuntime(input: {
systemPrompt: compactionState?.system_prompt,
})
: undefined,
{ preserveSessionId: true },
);
};
const updateCurrentSessionConnection = async (
update: SessionConnectionUpdate,
): Promise<void> => {
await ensureReady();
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
// No live session to update; the next startup builds its config from
// the already-mutated CLI config, so nothing else is needed.
return;
}
await manager.updateSessionConnection(sessionId, update);
};
const restartEmpty = async (): Promise<void> => {
await restartWithMessages([]);
};
@@ -641,22 +609,7 @@ export function createInteractiveSessionRuntime(input: {
})
: undefined,
);
// Report carried context from what the new session actually accepted:
// the host can reject the inherited state (e.g. stale anchor), and the
// UI must not claim a carry-over that did not happen.
const acceptedState = projectedMessages
? await readCompactionState(activeSessionId)
: undefined;
return {
forkedFromSessionId,
newSessionId: activeSessionId,
carriedWorkingContext: acceptedState
? {
workingContextMessages: acceptedState.messages.length,
canonicalMessages: messages.length,
}
: undefined,
};
return { forkedFromSessionId, newSessionId: activeSessionId };
};
const resumeSession = async (sessionId: string): Promise<Message[]> => {
@@ -887,7 +840,6 @@ export function createInteractiveSessionRuntime(input: {
resetForNewSession,
restartWithMessages,
restartWithCurrentMessages,
updateCurrentSessionConnection,
resumeSession,
forkCurrentSession,
compactCurrentSession,
+26 -4
View File
@@ -9,6 +9,23 @@ import {
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
- Read files, search the codebase, and gather context to understand the problem
- Ask clarifying questions when requirements are ambiguous
- Present your plan as a structured outline with clear steps
- Explain tradeoffs between different approaches when they exist
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
export async function resolveSystemPrompt(input: {
cwd: string;
explicitSystemPrompt?: string;
@@ -17,10 +34,15 @@ export async function resolveSystemPrompt(input: {
mode?: AgentMode;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
// Mode-tag and plan-mode instructions are appended by the shared prompt
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
// @cline/shared), so only the caller-specific rules are merged here.
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
// Both modes get the mode-tag explanation: after a switch, the transcript
// still contains messages tagged with the other mode.
rules = rules
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
: MODE_TAG_INSTRUCTIONS;
if (input.mode === "plan") {
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
}
return buildClineSystemPrompt({
ide: "Terminal Shell",
workspaceRoot: input.cwd,
-153
View File
@@ -43,15 +43,6 @@ const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
"ClinePass limit reached",
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
].join("\n");
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
@@ -74,30 +65,6 @@ vi.mock("@cline/core", () => ({
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
extractClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
const prefix = "you have reached your";
const suffix = "please try again later.";
const start = normalized.indexOf(prefix);
if (start === -1) return undefined;
const suffixStart = normalized.indexOf(suffix, start);
if (suffixStart === -1) return undefined;
const end = suffixStart + suffix.length;
if (!normalized.slice(start, end).includes("clinepass limit")) {
return undefined;
}
return text.slice(start, end);
},
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -802,126 +769,6 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLI_CLINE_PASS_LIMIT_MESSAGE,
);
});
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockImplementation(async () => {
sessionEventsMocks.listener?.({
type: "error",
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
recoverable: false,
});
return {
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
};
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+2 -72
View File
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
import {
applyInteractiveModelChange,
resolveReasoningForModelChange,
} from "./run-interactive";
import { describe, expect, it } from "vitest";
import { resolveReasoningForModelChange } from "./run-interactive";
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
@@ -42,69 +38,3 @@ describe("resolveReasoningForModelChange", () => {
).toEqual({ enabled: true, effort: "medium" });
});
});
describe("applyInteractiveModelChange", () => {
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
const config = {
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
thinking: undefined,
reasoningEffort: undefined,
} as Config;
const getProviderSettings = vi.fn(() => ({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible" as const,
protocol: "openai-chat" as const,
model: "old-model",
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
providers: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
const updateCurrentSessionConnection = vi.fn(async () => {});
await applyInteractiveModelChange({
config,
providerSettingsManager: {
getProviderSettings,
saveProviderSettings,
},
sessionRuntime: {
ensureReady,
restartWithCurrentMessages,
updateCurrentSessionConnection,
},
});
expect(saveProviderSettings).toHaveBeenCalledWith({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible",
protocol: "openai-chat",
model: "custom-model",
});
expect(ensureReady).toHaveBeenCalledOnce();
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
providerId: "openai-compatible",
modelId: "custom-model",
});
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
);
});
});
+18 -50
View File
@@ -82,51 +82,6 @@ export function resolveReasoningForModelChange(
return existing.reasoning;
}
export async function applyInteractiveModelChange(input: {
config: Config;
providerSettingsManager: Pick<
ProviderSettingsManager,
"getProviderSettings" | "saveProviderSettings"
>;
sessionRuntime: Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
| "ensureReady"
| "restartWithCurrentMessages"
| "updateCurrentSessionConnection"
>;
}): Promise<void> {
const { config, providerSettingsManager, sessionRuntime } = input;
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
// Provider changes affect more than the model connection: startup resolves
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
// the runtime with the existing transcript so all of that state changes
// together. restartWithCurrentMessages preserves the session ID.
await sessionRuntime.restartWithCurrentMessages();
// A same-ID restart reuses the existing manifest. Sync its connection label
// after the fully configured runtime is live so session history reflects the
// provider/model that will handle subsequent turns.
await sessionRuntime.updateCurrentSessionConnection({
providerId: config.providerId,
modelId: config.modelId,
});
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -732,12 +687,25 @@ export async function runInteractive(
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: () =>
applyInteractiveModelChange({
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerSettingsManager,
sessionRuntime,
}),
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
await sessionRuntime.restartWithCurrentMessages();
},
onSessionRestart: async () => {
await sessionRuntime.ensureReady();
await sessionRuntime.restartEmpty();
-59
View File
@@ -309,62 +309,3 @@ describe("loadIndividualSubscriptionPlans", () => {
expect(result).toEqual(plans);
});
});
describe("isClineAccountCreditsErrorMessage", () => {
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the plain human-readable Cline API message", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage("Not enough credits available"),
).toBe(true);
});
it("matches the legacy insufficient balance phrasing", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
"Insufficient balance. Your Cline credits balance is $0.00.",
),
).toBe(true);
});
it("does not match unrelated errors", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
expect(
isClineAccountCreditsErrorMessage(
"Your credit balance is too low to access the Anthropic API.",
),
).toBe(false);
expect(
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
).toBe(false);
});
});
+2 -42
View File
@@ -51,16 +51,9 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
// The Cline API's 402 response carries `code: "insufficient_credits"` and
// the message "Not enough credits available". Depending on how much of the
// payload survives error extraction, the CLI may see the raw JSON blob or
// just the human-readable message, so match both. The
// "insufficient balance" pair is an older backend phrasing kept for safety.
return (
normalized.includes("insufficient_credits") ||
normalized.includes("not enough credits") ||
(normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance"))
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
);
}
@@ -158,38 +151,6 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -222,7 +183,6 @@ export async function loadClineAccountSnapshot(input: {
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
return {
user,
+1 -96
View File
@@ -5,11 +5,9 @@ import { useEffect, useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -25,7 +23,6 @@ import {
type TerminalTheme,
} from "../palette";
import type { ChatEntry } from "../types";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
@@ -134,7 +131,7 @@ function formatToolParams(
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={`${f.path}:${sl}:${el}`}>
<span key={f.path}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -422,86 +419,6 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
);
}
function CompactionDividerRow(props: {
entry: Extract<ChatEntry, { kind: "compaction" }>;
}) {
const { entry } = props;
const { width: terminalWidth } = useTerminalDimensions();
const inProgress = entry.status === "started";
const labelColor = inProgress
? "cyan"
: entry.status === "failed"
? "red"
: entry.status === "cancelled" || entry.status === "skipped"
? "gray"
: "cyan";
const label = `${formatCompactionDividerLabel(entry)}`;
// Fill the remaining line with a plain rule instead of a flexGrow bordered
// box: a single fixed-content text row keeps the renderer's diffing stable.
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
return (
<box flexDirection="row">
{inProgress ? (
<box width={2}>
<spinner name="dots" color={labelColor} />
</box>
) : (
<text fg="gray" content="── " />
)}
<text fg={labelColor} selectable content={label} />
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
</box>
);
}
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
terminalTheme: TerminalTheme;
}) {
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">ClinePass limit reached</text>
<text fg={props.defaultFg} selectable content={detail} />
<text
fg={props.defaultFg}
selectable
content="Switch to Cline usage-based billing and retry with the Cline provider."
/>
<box flexDirection="row">
<text fg="gray">Interactive CLI: </text>
<text
fg={props.defaultFg}
selectable
content="type /model, press tab to change provider, choose Cline, then retry."
/>
</box>
<box flexDirection="row">
<text fg="gray">Headless CLI: </text>
<text fg={props.defaultFg} selectable content="rerun with " />
<code
content="--provider cline"
filetype="bash"
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
selectable
/>
<text fg={props.defaultFg} selectable content="." />
</box>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -617,15 +534,6 @@ export function ChatEntryView(props: {
/>
);
}
if (isClinePassLimitErrorMessage(entry.text)) {
return (
<ClinePassLimitErrorView
message={entry.text}
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
);
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -649,9 +557,6 @@ export function ChatEntryView(props: {
</box>
);
case "compaction":
return <CompactionDividerRow entry={entry} />;
case "done": {
const parts: string[] = [];
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
@@ -1,50 +1,8 @@
import {
getProviderAuthStorageId,
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
/**
* Persist a manually entered API key for an OAuth-capable provider the
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
* stale token would otherwise keep winning over the manual key.
*
* The key is written both to the provider's auth storage entry (cline-pass
* stores credentials under "cline") and to the provider's own entry: settings
* resolution lets a direct entry shadow the storage entry, and provider
* switching copies merged settings (including auth) into direct entries, so
* both must be updated for the manual key to reliably take effect.
*/
export function saveManualProviderApiKey(
manager: ProviderSettingsManager,
providerId: string,
apiKey: string,
): void {
// Empty strings delete these keys from the stored auth object.
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
saveLocalProviderSettings(manager, {
providerId: storageProviderId,
apiKey,
auth: clearedAuth,
});
if (
providerId !== storageProviderId &&
manager.read().providers[providerId]
) {
saveLocalProviderSettings(manager, {
providerId,
apiKey,
auth: clearedAuth,
});
}
}
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
@@ -1,16 +1,5 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProviderSettingsManager } from "@cline/core";
import { afterEach, describe, expect, it } from "vitest";
import {
getPersistedProviderApiKey,
isProviderConfigured,
} from "../../../utils/provider-auth";
import {
buildClinePassSubscriptionPageUrl,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
import { describe, expect, it } from "vitest";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
@@ -27,99 +16,3 @@ describe("buildClinePassSubscriptionPageUrl", () => {
);
});
});
describe("saveManualProviderApiKey", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
function createManager(): ProviderSettingsManager {
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
tempDirs.push(dir);
return new ProviderSettingsManager({
filePath: join(dir, "providers.json"),
});
}
it("clears stored OAuth tokens so the manual key takes effect", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
accountId: "acct_123",
},
});
saveManualProviderApiKey(manager, "cline", "manual-api-key");
const settings = manager.getProviderSettings("cline");
expect(settings?.apiKey).toBe("manual-api-key");
expect(settings?.auth?.accessToken).toBeUndefined();
expect(settings?.auth?.refreshToken).toBeUndefined();
expect(settings?.auth?.accountId).toBe("acct_123");
expect(getPersistedProviderApiKey("cline", settings)).toBe(
"manual-api-key",
);
expect(isProviderConfigured("cline", settings)).toBe(true);
});
it("saves cline-pass keys to the shared cline auth storage entry", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
// cline-pass inherits auth storage from the "cline" entry, so the key
// must land there and the stale tokens must be gone for both providers.
const clineSettings = manager.getProviderSettings("cline");
expect(clineSettings?.apiKey).toBe("manual-api-key");
expect(clineSettings?.auth?.accessToken).toBeUndefined();
const clinePassSettings = manager.getProviderSettings("cline-pass");
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
"manual-api-key",
);
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
});
it("clears stale credentials copied into a direct cline-pass entry", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
// Provider switching copies the merged settings (including auth) into
// a direct cline-pass entry, which shadows the shared "cline" entry.
manager.saveProviderSettings({
provider: "cline-pass",
apiKey: "stale-copied-key",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
const clinePassSettings = manager.getProviderSettings("cline-pass");
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
"manual-api-key",
);
});
});
@@ -37,10 +37,7 @@ import {
getSearchableListRowsWindow,
type SearchableItem,
} from "../searchable-list";
import {
buildClinePassSubscriptionPageUrl,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
interface ProviderItem {
id: string;
@@ -727,27 +724,13 @@ export function CodexCliStatusContent(
);
}
/**
* Resolves `true` on successful login, `"use_api_key"` when the user opts
* into manual API key entry (only offered with `allowApiKeyFallback`).
*/
export type OAuthLoginResult = boolean | "use_api_key";
export function OAuthLoginContent(
props: ChoiceContext<OAuthLoginResult> & {
props: ChoiceContext<boolean> & {
providerId: string;
providerName: string;
allowApiKeyFallback?: boolean;
},
) {
const {
resolve,
dismiss,
dialogId,
providerId,
providerName,
allowApiKeyFallback,
} = props;
const { resolve, dismiss, dialogId, providerId, providerName } = props;
const [mode, setMode] = useState<"browser" | "device">(
providerId === "cline" ? "device" : "browser",
);
@@ -880,19 +863,9 @@ export function OAuthLoginContent(
if (key.name === "escape") {
cancelAuthAttempt();
dismiss();
return;
}
if (key.name === "k" && allowApiKeyFallback) {
cancelAuthAttempt();
resolve("use_api_key");
}
}, dialogId);
const escapeHint = allowApiKeyFallback
? "K to enter an API key instead, Esc to cancel"
: "Esc to cancel";
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
if (mode === "device") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
@@ -919,8 +892,8 @@ export function OAuthLoginContent(
{deviceError && <text fg="red">{deviceError}</text>}
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
<text fg="gray">
<em>Esc to cancel</em>
</text>
</box>
);
@@ -942,83 +915,8 @@ export function OAuthLoginContent(
{error && <text fg="red">{error}</text>}
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
</text>
</box>
);
}
/**
* Manual API key entry for OAuth-capable providers the escape hatch for
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
* the manual key takes effect (see saveManualProviderApiKey).
*/
export function OAuthApiKeyInputContent(
props: ChoiceContext<boolean> & {
providerId: string;
providerName: string;
providerSettingsManager: ProviderSettingsManager;
},
) {
const {
resolve,
dismiss,
dialogId,
providerId,
providerName,
providerSettingsManager,
} = props;
const [value, setValue] = useState("");
const submit = () => {
const apiKey = value.trim();
if (!apiKey) return;
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
resolve(true);
};
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return") {
submit();
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
<text fg="gray">
Use an API key from your Cline dashboard instead of OAuth login. This
replaces any saved login tokens.
</text>
<box flexDirection="column">
<text fg="gray">API key</text>
<box
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<input
value={value}
onInput={setValue}
placeholder="Paste your API key"
flexGrow={1}
focused
/>
</box>
</box>
<text fg="gray">
<em>Enter to save, Esc to go back</em>
<em>Esc to cancel</em>
</text>
</box>
);
@@ -3,7 +3,6 @@ import type { OpenConfigOptions } from "./use-config-panel";
export interface LocalSlashCommandActionInput {
name: string;
isRunning: boolean;
openAccount: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
@@ -47,12 +46,7 @@ export function runLocalSlashCommandAction(
return true;
}
if (normalized === "compact") {
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
// shared running state.
if (!input.isRunning) {
input.runCompact();
}
input.runCompact();
return true;
}
if (normalized === "fork") {
@@ -7,10 +7,7 @@ import {
type AccountDialogAction,
AccountDialogContent,
} from "../components/dialogs/account-dialog";
import {
OAuthLoginContent,
type OAuthLoginResult,
} from "../components/dialogs/provider-picker";
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
import type { OpenModelSelectorOptions } from "./use-model-selector";
export function useAccountDialog(opts: {
@@ -63,14 +60,14 @@ export function useAccountDialog(opts: {
return;
}
if (action === "login") {
const saved = await dialog.choice<OAuthLoginResult>({
const saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
content: (ctx: ChoiceContext<boolean>) => (
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
),
});
if (saved === true) {
if (saved) {
await onAccountChange?.();
await openAccountDialog();
return;
+3 -87
View File
@@ -6,14 +6,13 @@ import type {
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveNonCompactionStatusLabel } from "../../utils/events";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
formatToolOutput,
truncate,
} from "../../utils/helpers";
import type { ChatEntry, InlineStream, TuiProps } from "../types";
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
interface AgentEventDeps {
appendEntry: (entry: ChatEntry) => void;
@@ -33,7 +32,6 @@ interface AgentEventDeps {
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
const openCompactionEntryRef = useRef(false);
const {
appendEntry,
updateLastEntry,
@@ -47,47 +45,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
verbose,
} = deps;
// Compaction dividers that arrived while an assistant message was still
// streaming. Appending them immediately would split the message in two, so
// they are held until the active content block closes (or the turn ends).
const pendingCompactionEntriesRef = useRef<
Extract<ChatEntry, { kind: "compaction" }>[]
>([]);
const flushPendingCompactionEntries = useCallback(() => {
const pending = pendingCompactionEntriesRef.current;
if (pending.length === 0) return;
pendingCompactionEntriesRef.current = [];
for (const entry of pending) {
if (entry.status !== "started" && openCompactionEntryRef.current) {
updateEntry((current) =>
current.kind === "compaction" && current.status === "started"
? { ...current, ...entry }
: current,
);
openCompactionEntryRef.current = false;
} else {
appendEntry(entry);
if (entry.status === "started") {
openCompactionEntryRef.current = true;
}
}
}
}, [appendEntry, updateEntry]);
const finalizeDanglingCompactionEntry = useCallback(
(status: "failed" | "cancelled") => {
if (!openCompactionEntryRef.current) return;
openCompactionEntryRef.current = false;
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status }
: entry,
);
},
[updateEntry],
);
const closeToolEntry = useCallback(
(event: AgentEvent & { type: "content_end" }) => {
const error = event.error ?? undefined;
@@ -127,11 +84,9 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(true);
setIsStreaming(true);
closeInlineStream();
flushPendingCompactionEntries();
break;
case "iteration_end":
closeInlineStream();
flushPendingCompactionEntries();
break;
case "content_start": {
setIsStreaming(false);
@@ -210,15 +165,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("cancelled");
break;
case "error":
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("failed");
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
@@ -230,40 +181,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
break;
case "notice":
if (event.displayRole === "status") {
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (!compaction) {
closeInlineStream();
}
if (compaction) {
if (activeInlineStreamRef.current) {
// An assistant message is still streaming; appending now
// would split it around the divider. Hold the divider (final
// state until the content block closes, then reconcile it
// with the same open divider atomically.
pendingCompactionEntriesRef.current.push({
kind: "compaction",
...compaction,
});
break;
}
if (compaction.status === "started") {
appendEntry({ kind: "compaction", ...compaction });
openCompactionEntryRef.current = true;
} else if (openCompactionEntryRef.current) {
// Finalize the in-progress divider in place, wherever it
// sits in the transcript.
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, ...compaction }
: entry,
);
openCompactionEntryRef.current = false;
} else {
appendEntry({ kind: "compaction", ...compaction });
}
break;
}
const label = resolveNonCompactionStatusLabel(event);
closeInlineStream();
const label = resolveStatusNoticeLabel(event);
if (label) {
appendEntry({ kind: "status", text: label });
}
@@ -281,7 +200,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
[
appendEntry,
updateLastEntry,
updateEntry,
closeInlineStream,
activeInlineStreamRef,
setIsRunning,
@@ -290,8 +208,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
onTurnErrorReported,
verbose,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
],
);
@@ -9,7 +9,6 @@ function makeActions(
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
): Omit<LocalSlashCommandActionInput, "name"> {
return {
isRunning: false,
openAccount: vi.fn(),
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
@@ -59,32 +58,6 @@ describe("runLocalSlashCommandAction", () => {
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
});
it("does not start compaction while a turn is running", () => {
const runCompact = vi.fn();
const actions = makeActions({ isRunning: true, runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).not.toHaveBeenCalled();
});
it("starts compaction while the session is idle", () => {
const runCompact = vi.fn();
const actions = makeActions({ runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).toHaveBeenCalledOnce();
});
it("waits for clear to reset the runtime session", async () => {
let resolveClear: (() => void) | undefined;
const clearConversation = vi.fn(
@@ -9,6 +9,7 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { AppView, TuiProps } from "../types";
import { formatCompactionStatus } from "../utils/compaction-status";
import { hydrateSessionMessages } from "../utils/hydrate-messages";
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
import { HistoryDialogContent } from "../views/history-view";
@@ -115,42 +116,21 @@ export function useLocalCommandActions(input: {
}, [dialog, refocusTextarea, termHeight]);
const runCompact = useCallback(async () => {
session.setIsRunning(true);
session.appendEntry({
kind: "compaction",
compactionMode: "manual",
status: "started",
kind: "status",
text: "Compacting context...",
});
try {
const result = await onCompact();
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? {
...entry,
status: result.compacted ? "completed" : "skipped",
messagesBefore: result.messagesBefore,
messagesAfter:
result.workingContextMessagesAfter ?? result.messagesAfter,
}
: entry,
);
session.updateLastEntry(() => ({
kind: "status",
text: formatCompactionStatus(result),
}));
} catch (error) {
const cancelled =
error instanceof Error &&
(error.name === "AbortError" || /abort/i.test(error.message));
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status: cancelled ? "cancelled" : "failed" }
: entry,
);
if (!cancelled) {
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
} finally {
session.setIsRunning(false);
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
}, [onCompact, session]);
@@ -179,15 +159,6 @@ export function useLocalCommandActions(input: {
kind: "status",
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
}));
if (result.carriedWorkingContext) {
session.appendEntry({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: result.carriedWorkingContext.canonicalMessages,
messagesAfter: result.carriedWorkingContext.workingContextMessages,
});
}
} else {
session.updateLastEntry(() => ({
kind: "error",
@@ -210,7 +181,6 @@ export function useLocalCommandActions(input: {
}
return runLocalSlashCommandAction({
name: resolved.name,
isRunning: session.isRunning,
invocation,
openAccount,
openConfig,
@@ -239,7 +209,6 @@ export function useLocalCommandActions(input: {
openSkills,
runCompact,
runFork,
session.isRunning,
slashCommandRegistry,
],
);
+3 -92
View File
@@ -6,7 +6,6 @@ import {
refreshProviderModelsFromSource,
resolveProviderConfig,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
@@ -22,9 +21,7 @@ import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
OAuthApiKeyInputContent,
OAuthLoginContent,
type OAuthLoginResult,
ProviderConfigInputContent,
ProviderPickerContent,
UseExistingOrReconfigureContent,
@@ -82,51 +79,6 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
/**
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
* using the provider's stored API key and headers, mirroring the extension's
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
* to manual model-id entry.
*/
async function fetchOpenAiCompatibleModelIds(
providerId: string,
): Promise<string[]> {
try {
const manager = new ProviderSettingsManager();
const config = manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
if (!baseUrl || !URL.canParse(baseUrl)) return [];
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
const apiKey = config?.apiKey?.trim();
if (
apiKey &&
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return [];
const payload = (await response.json()) as { data?: unknown };
const list = Array.isArray(payload?.data) ? payload.data : [];
const ids = list
.map((model) => {
const id = (model as { id?: unknown } | null)?.id;
return typeof id === "string" ? id.trim() : "";
})
.filter(Boolean);
return [...new Set(ids)];
} catch {
return [];
}
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
@@ -179,23 +131,6 @@ async function runProviderChange(
);
const existingSettings = manager.getProviderSettings(newProviderId);
// Manual API key entry is the escape hatch for when OAuth login isn't
// working; only the Cline providers accept a dashboard API key.
const supportsManualApiKey = isClineProvider(newProviderId);
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<OAuthApiKeyInputContent
{...ctx}
providerId={newProviderId}
providerName={displayName}
providerSettingsManager={manager}
/>
),
});
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
let option: ExistingProviderOption | undefined;
@@ -230,22 +165,17 @@ async function runProviderChange(
if (needsAuth) {
let saved: boolean | undefined;
if (isOAuthProvider(newProviderId)) {
const loginResult = await dialog.choice<OAuthLoginResult>({
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
content: (ctx: ChoiceContext<boolean>) => (
<OAuthLoginContent
{...ctx}
providerId={newProviderId}
providerName={displayName}
allowApiKeyFallback={supportsManualApiKey}
/>
),
});
saved =
loginResult === "use_api_key"
? await openManualApiKeyDialog()
: loginResult;
} else if (isOpenAICodexCliProvider(newProviderId)) {
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
@@ -345,28 +275,12 @@ export function useModelSelector(opts: {
config.knownModels as Record<string, Llms.ModelInfo>,
);
let providerDisplayName = config.providerId;
let endpointModelOptions: ModelOption[] = [];
const refreshProviderContext = async () => {
modelOptions = buildModelOptions(
config.knownModels as Record<string, Llms.ModelInfo>,
);
providerDisplayName = await getProviderDisplayName(config.providerId);
// Free-text providers (openai-compatible) can still suggest model
// ids when their endpoint answers /models; otherwise they keep the
// manual input.
endpointModelOptions = usesModelIdInput(config.providerId)
? buildModelOptions(
Object.fromEntries(
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
(id) => [id, { id, name: id }],
),
),
)
: [];
if (endpointModelOptions.length > 0) {
modelOptions = endpointModelOptions;
}
};
if (!options?.startWithProviderChange) {
@@ -402,10 +316,7 @@ export function useModelSelector(opts: {
let pickingModel = true;
while (pickingModel) {
if (
usesModelIdInput(config.providerId) &&
endpointModelOptions.length === 0
) {
if (usesModelIdInput(config.providerId)) {
const modelId = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -1,91 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type TerminalTitleRenderer,
useTerminalTitle,
} from "./use-terminal-title";
const reactMock = vi.hoisted(() => {
const cleanups: Array<() => void> = [];
return {
cleanups,
// Run effect bodies now, but retain their cleanups so each test can move
// the renderer across the native destruction boundary before unmount.
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
const cleanup = effect();
if (cleanup) {
cleanups.push(cleanup);
}
}),
};
});
vi.mock("react", () => ({
useEffect: reactMock.useEffect,
}));
function createTitleRenderer() {
let destroyed = false;
const setTerminalTitle = vi.fn(() => {
if (destroyed) {
throw new Error("setTerminalTitle called after renderer destruction");
}
});
const renderer: TerminalTitleRenderer = {
get isDestroyed() {
return destroyed;
},
setTerminalTitle,
};
return {
destroy: () => {
destroyed = true;
},
renderer,
setTerminalTitle,
};
}
beforeEach(() => {
reactMock.cleanups.length = 0;
reactMock.useEffect.mockClear();
});
describe("useTerminalTitle", () => {
it("sets and resets the title while the renderer is active", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
});
it("does not set the title when its effect runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
titleRenderer.destroy();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
});
it("does not reset the title when cleanup runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
titleRenderer.destroy();
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
});
});
@@ -1,29 +0,0 @@
import { useEffect } from "react";
export interface TerminalTitleRenderer {
readonly isDestroyed: boolean;
setTerminalTitle(title: string): void;
}
export function useTerminalTitle(
renderer: TerminalTitleRenderer,
terminalTitle: string,
): void {
// setTerminalTitle writes into memory owned by the native renderer, so it
// must never run after destroy. React can flush passive effects after the
// renderer's memory has been freed.
useEffect(() => {
if (renderer.isDestroyed) {
return;
}
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
};
}, [renderer]);
}
-37
View File
@@ -8,9 +8,7 @@ const rendererMock = vi.hoisted(() => ({
defaultBackground: null,
defaultForeground: null,
})),
isDestroyed: false,
on: vi.fn(),
setTerminalTitle: vi.fn(),
}));
const rootMock = vi.hoisted(() => ({
@@ -39,9 +37,7 @@ describe("renderOpenTui", () => {
beforeEach(() => {
destroyHandlers.length = 0;
rendererMock.isDestroyed = false;
rendererMock.destroy.mockReset();
rendererMock.setTerminalTitle.mockReset();
rendererMock.on.mockReset();
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
if (event === "destroy") {
@@ -100,37 +96,4 @@ describe("renderOpenTui", () => {
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
});
it("resets the terminal title before destroying the renderer", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
await Promise.resolve();
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
const titleCallOrder =
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
});
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
// Simulate OpenTUI's own signal handler destroying the renderer in the
// same dispatch (e.g. an idle SIGTERM fires both our handler and
// OpenTUI's exitHandler before microtasks drain).
rendererMock.isDestroyed = true;
for (const handler of destroyHandlers) {
handler();
}
await Promise.resolve();
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
});
});
-8
View File
@@ -67,14 +67,6 @@ export async function renderOpenTui(
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
// Reset the title while the native renderer is still alive; the
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
// Re-check here: OpenTUI's own signal handlers can destroy the
// renderer between destroy() queuing this microtask and it running
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
renderer.destroy();
});
};
+1 -2
View File
@@ -27,7 +27,6 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -196,7 +195,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+9 -2
View File
@@ -53,7 +53,6 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import type { AppView, TuiProps } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
@@ -473,7 +472,15 @@ function App(props: TuiProps) {
};
}, [renderer, showToast]);
useTerminalTitle(renderer, terminalTitle);
useEffect(() => {
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
renderer.setTerminalTitle("");
};
}, [renderer]);
useEffect(() => {
return () => {
+1 -18
View File
@@ -44,15 +44,6 @@ export type ChatEntry = (
}
| { kind: "error"; text: string }
| { kind: "status"; text: string }
| {
kind: "compaction";
compactionMode: "auto" | "manual" | "inherited";
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
tokensBefore?: number;
tokensAfter?: number;
messagesBefore?: number;
messagesAfter?: number;
}
| { kind: "team"; text: string }
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
| {
@@ -195,15 +186,7 @@ export interface TuiProps {
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
onCompact: () => Promise<InteractiveCompactionResult>;
onFork: () => Promise<
| {
forkedFromSessionId: string;
newSessionId: string;
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
}
| undefined
{ forkedFromSessionId: string; newSessionId: string } | undefined
>;
getCheckpointData: () => Promise<
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
@@ -1,168 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatCompactionDividerLabel,
formatTokenCount,
parseCompactionNoticeMetadata,
} from "./compaction-status";
describe("parseCompactionNoticeMetadata", () => {
it("extracts a divider entry from a completed auto-compaction notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
reason: "auto_compaction",
phase: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toEqual({
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
});
});
it("extracts a streaming divider entry from a started notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "started",
}),
).toEqual({ compactionMode: "auto", status: "started" });
});
it("maps manual compaction notices to manual mode", () => {
expect(
parseCompactionNoticeMetadata({
kind: "manual_compaction",
phase: "completed",
})?.compactionMode,
).toBe("manual");
});
it("maps a benign no-result terminal notice to skipped", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "skipped",
}),
).toEqual({ compactionMode: "auto", status: "skipped" });
});
it("ignores non-compaction metadata", () => {
expect(
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
).toBeUndefined();
expect(
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
).toBeUndefined();
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
});
it("drops non-numeric counters instead of rendering garbage", () => {
const parsed = parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "completed",
tokensBefore: "25000",
tokensAfter: Number.NaN,
});
expect(parsed?.tokensBefore).toBeUndefined();
expect(parsed?.tokensAfter).toBeUndefined();
});
});
describe("formatTokenCount", () => {
it("formats counts into compact units", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(6_300)).toBe("6.3k");
expect(formatTokenCount(25_000)).toBe("25k");
expect(formatTokenCount(1_200_000)).toBe("1.2M");
});
});
describe("formatCompactionDividerLabel", () => {
it("includes token and message deltas when present", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
});
it("labels in-progress compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "started",
}),
).toBe("Auto compacting messages");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "started",
}),
).toBe("Compacting messages");
});
it("labels failed and cancelled compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "failed",
}),
).toBe("Compaction failed");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "cancelled",
}),
).toBe("Compaction cancelled");
});
it("labels skipped compaction without calling it cancelled", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "skipped",
}),
).toBe("Compaction skipped");
});
it("labels inherited working context from forks and restarts", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: 60,
messagesAfter: 15,
}),
).toBe("Compacted working context carried over · 60 → 15 messages");
});
it("labels manual compaction and omits missing counters", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "completed",
}),
).toBe("Context compacted (manual)");
});
});
+1 -98
View File
@@ -1,106 +1,9 @@
import type { ChatEntry, InteractiveCompactionResult } from "../types";
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
import type { InteractiveCompactionResult } from "../types";
function formatMessageCount(count: number): string {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
function asFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
/**
* Extracts a compaction divider entry from a status notice's metadata.
* "started" notices produce a streaming (in-progress) divider; "completed"
* notices produce the final divider with counters. Returns undefined for
* non-compaction notices.
*/
export function parseCompactionNoticeMetadata(
metadata: Record<string, unknown> | undefined,
): Omit<CompactionDividerEntry, "kind"> | undefined {
if (
!metadata ||
(metadata.phase !== "started" &&
metadata.phase !== "completed" &&
metadata.phase !== "skipped")
) {
return undefined;
}
const kind = metadata.kind ?? metadata.reason;
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
return undefined;
}
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
if (metadata.phase === "started") {
return { compactionMode, status: "started" };
}
if (metadata.phase === "skipped") {
return { compactionMode, status: "skipped" };
}
return {
compactionMode,
status: "completed",
tokensBefore: asFiniteNumber(metadata.tokensBefore),
tokensAfter: asFiniteNumber(metadata.tokensAfter),
messagesBefore: asFiniteNumber(metadata.messagesBefore),
messagesAfter: asFiniteNumber(metadata.messagesAfter),
};
}
export function formatTokenCount(count: number): string {
if (count < 1_000) {
return `${count}`;
}
if (count < 1_000_000) {
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
export function formatCompactionDividerLabel(
entry: CompactionDividerEntry,
): string {
if (entry.status === "started") {
return entry.compactionMode === "manual"
? "Compacting messages"
: "Auto compacting messages";
}
if (entry.status === "failed") {
return "Compaction failed";
}
if (entry.status === "cancelled") {
return "Compaction cancelled";
}
if (entry.status === "skipped") {
return "Compaction skipped";
}
const parts: string[] = [
entry.compactionMode === "manual"
? "Context compacted (manual)"
: entry.compactionMode === "inherited"
? "Compacted working context carried over"
: "Context compacted",
];
if (
typeof entry.tokensBefore === "number" &&
typeof entry.tokensAfter === "number"
) {
parts.push(
`${formatTokenCount(entry.tokensBefore)}${formatTokenCount(entry.tokensAfter)} tokens`,
);
}
if (
typeof entry.messagesBefore === "number" &&
typeof entry.messagesAfter === "number"
) {
parts.push(`${entry.messagesBefore}${entry.messagesAfter} messages`);
}
return parts.join(" · ");
}
export function formatCompactionStatus(
result: InteractiveCompactionResult,
): string {
-9
View File
@@ -14,15 +14,6 @@ export type ChatCommandState = {
export type ForkSessionResult = {
forkedFromSessionId: string;
newSessionId: string;
/**
* Present when the source session had valid compaction state that was
* re-anchored onto the forked session, so the UI can surface why the
* next request is smaller than the canonical history.
*/
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
};
export type MuteCommandInput = {
@@ -1,13 +1,10 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -49,22 +46,4 @@ describe("cline-pass-errors", () => {
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
const raw =
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const detail =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClinePassLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Switch to Cline usage-based billing",
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
});
-41
View File
@@ -1,13 +1,10 @@
import {
type ClineSubscriptionPlan,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
isClinePassLimitError,
isClinePassLimitMessage,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
@@ -27,18 +24,6 @@ export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
export function getCliClinePassLimitMessage(message: string): string {
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
const lines = [
"ClinePass limit reached",
detail,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
];
return lines.filter((line) => line.trim().length > 0).join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -93,27 +78,6 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
);
}
export function getClinePassLimitDetailMessage(
error: unknown,
): string | undefined {
return extractClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
export function isClinePassLimitErrorMessage(error: unknown): boolean {
if (isClinePassLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClinePassLimitError" ||
isClinePassLimitMessage(error.message)
);
}
return typeof error === "string" && isClinePassLimitMessage(error);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
@@ -121,11 +85,6 @@ export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (isClinePassLimitErrorMessage(error)) {
return getCliClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (error instanceof Error) {
return error.message;
}
+9 -6
View File
@@ -15,29 +15,31 @@ function createConfig(compaction?: Config["compaction"]): Config {
}
describe("CLI compaction mode helpers", () => {
it("defaults enabled compaction to agentic summarization", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
it("defaults enabled compaction to basic truncation", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
expect(getCliCompactionMode(createConfig())).toBe(
DEFAULT_CLI_COMPACTION_MODE,
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
"Truncation",
);
});
it("maps basic and off modes to core compaction config", () => {
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
const config = createConfig({ enabled: true, maxInputTokens: 123 });
applyCliCompactionMode(config, "basic");
expect(config.compaction).toEqual({
enabled: true,
strategy: "basic",
preserveRecentTokens: 123,
maxInputTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("basic");
applyCliCompactionMode(config, "off");
expect(config.compaction).toEqual({
enabled: false,
preserveRecentTokens: 123,
maxInputTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("off");
});
@@ -45,6 +47,7 @@ describe("CLI compaction mode helpers", () => {
it("builds default and explicit core compaction config", () => {
expect(buildCliCompactionConfig()).toEqual({
enabled: true,
strategy: "basic",
});
expect(buildCliCompactionConfig("agentic")).toEqual({
enabled: true,
+6 -7
View File
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
CliCompactionMode,
"agentic" | "basic"
> = "agentic";
> = "basic";
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
agentic: "agentic",
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
} as const satisfies Record<CliCompactionMode, string>;
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
"Context compaction mode: agentic|basic|off (default: agentic)";
"Context compaction mode: agentic|basic|off (default: basic)";
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
@@ -31,11 +31,8 @@ export function parseCliCompactionMode(
}
export function buildCliCompactionConfig(
mode?: CliCompactionMode,
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
): NonNullable<Config["compaction"]> {
if (mode === undefined) {
return { enabled: true };
}
if (mode === "off") {
return { enabled: false };
}
@@ -46,7 +43,9 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
if (config.compaction?.enabled === false) {
return "off";
}
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
return config.compaction?.strategy === "agentic"
? "agentic"
: DEFAULT_CLI_COMPACTION_MODE;
}
export function applyCliCompactionMode(
+1 -6
View File
@@ -141,18 +141,13 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath,
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
-26
View File
@@ -42,23 +42,14 @@ describe("resolveStatusNoticeLabel", () => {
describe("handleEvent text formatting", () => {
let output = "";
let errorOutput = "";
beforeEach(() => {
output = "";
errorOutput = "";
setCurrentOutputMode("text");
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
output += String(chunk);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
errorOutput += String(chunk);
return true;
});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
errorOutput += `${args.map(String).join(" ")}\n`;
});
});
it("adds a ⎿ before text that follows a tool block", () => {
@@ -205,23 +196,6 @@ describe("handleEvent text formatting", () => {
expect(output).toContain("── aborted (2 iterations) ──");
});
it("formats ClinePass limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("ClinePass limit reached");
expect(errorOutput).toContain("Switch to Cline usage-based billing");
expect(errorOutput).toContain("--provider cline");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+1 -24
View File
@@ -1,9 +1,4 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import {
formatCompactionDividerLabel,
parseCompactionNoticeMetadata,
} from "../tui/utils/compaction-status";
import { formatCliErrorMessage } from "./cline-pass-errors";
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
import {
c,
@@ -28,24 +23,6 @@ const TEAM_RUN_ACTIVE_SUFFIX = `${c.dim} ...${c.reset}`;
export function resolveStatusNoticeLabel(
event: AgentEvent,
): string | undefined {
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (compaction) {
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
}
return resolveNonCompactionStatusLabel(event);
}
/**
* Label for a status notice already known not to be a compaction notice.
* Callers that have parsed the compaction metadata themselves use this to
* avoid re-parsing.
*/
export function resolveNonCompactionStatusLabel(
event: AgentEvent,
): string | undefined {
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
@@ -204,7 +181,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(formatCliErrorMessage(event.error));
writeErr(event.error.message);
}
break;
case "notice":
+6 -8
View File
@@ -42,12 +42,11 @@ export function getPersistedProviderApiKey(
* or endpoint config for the provider. Used by the picker to decide whether
* to offer "Use existing configuration?" before opening the configure dialog.
*
* Treats OAuth providers as configured when an access token or a manually
* saved API key is present (the /settings escape hatch for when OAuth isn't
* working); for everything else, any persisted API key, base URL, or model id
* counts. We don't enforce required fields here the runtime no longer
* pre-flights credentials, so a missing key only matters when the API call
* actually runs and the provider's own auth error is surfaced.
* Treats OAuth providers as configured when an access token is present; for
* everything else, any persisted API key, base URL, or model id counts. We
* don't enforce required fields here the runtime no longer pre-flights
* credentials, so a missing key only matters when the API call actually
* runs and the provider's own auth error is surfaced.
*/
export function isProviderConfigured(
providerId: string,
@@ -55,8 +54,7 @@ export function isProviderConfigured(
): boolean {
if (!settings) return false;
if (isOAuthProvider(providerId)) {
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
return Boolean(getPersistedProviderApiKey(providerId, settings));
return Boolean(settings.auth?.accessToken?.trim());
}
if (getPersistedProviderApiKey(providerId, settings)) return true;
if (settings.baseUrl?.trim()) return true;
+3 -5
View File
@@ -1,5 +1,4 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
@@ -136,11 +135,10 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
const mode = await p.select({
message: "Agent mode",
options: [
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
{ value: "act", label: "Act", hint: "execute tasks" },
{ value: "plan", label: "Plan", hint: "plan only" },
],
initialValue: "yolo",
initialValue: "act",
});
if (isCancel(mode)) return;
@@ -216,8 +214,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? CLINE_DEFAULT_MODEL_ID,
mode: mode as "act" | "plan" | "yolo",
model: model ?? "openai/gpt-5.3-codex",
mode: (mode as string) === "plan" ? "plan" : "act",
workspaceRoot: (workspace as string).trim(),
systemPrompt,
maxIterations,
+23 -46
View File
@@ -3,12 +3,6 @@ import {
HubScheduleCommandService,
HubScheduleService,
} from "@cline/core";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
readHubScheduleMode,
} from "@cline/shared";
import { asTrimmedString, toPositiveInt } from "./utils";
let scheduleService: HubScheduleService | undefined;
@@ -42,31 +36,6 @@ async function clientCommand(
return (reply.payload ?? {}) as Record<string, unknown>;
}
function routineScheduleTiming(
args?: Record<string, unknown>,
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
if (args?.schedule_type === "once") {
const runAt =
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
return Number.isFinite(runAt)
? {
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
}
: undefined;
}
const cronPattern = asTrimmedString(args?.cron_pattern);
return cronPattern ? { cronPattern } : undefined;
}
function asTrimmedStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const values = value
.map((item) => asTrimmedString(item))
.filter((item): item is string => item !== undefined);
return values.length > 0 ? values : undefined;
}
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
@@ -107,23 +76,23 @@ export async function handleRoutineScheduleCommand(
if (command === "create_routine_schedule") {
const name = asTrimmedString(args?.name);
const timing = routineScheduleTiming(args);
const cronPattern = asTrimmedString(args?.cron_pattern);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
throw new Error(
"createSchedule requires name, timing, prompt, and workspace_root",
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
);
}
const created = await clientCommand("schedule.create", {
name,
...timing,
cronPattern,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
},
mode: readHubScheduleMode(args, "yolo"),
mode: args?.mode === "plan" ? "plan" : "act",
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd),
systemPrompt: asTrimmedString(args?.system_prompt),
@@ -131,7 +100,12 @@ export async function handleRoutineScheduleCommand(
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags: asTrimmedStringArray(args?.tags),
tags:
Array.isArray(args?.tags) && args.tags.length > 0
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: undefined,
});
return { schedule: created.schedule ?? null };
}
@@ -139,26 +113,25 @@ export async function handleRoutineScheduleCommand(
const scheduleId = asTrimmedString(args?.schedule_id);
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
if (command === "update_routine_schedule") {
const mode = readHubScheduleMode(args);
const name = asTrimmedString(args?.name);
const timing = routineScheduleTiming(args);
const cronPattern = asTrimmedString(args?.cron_pattern);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
throw new Error(
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
);
}
const reply = await clientCommand("schedule.update", {
scheduleId,
name,
...timing,
cronPattern,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
},
...(mode === undefined ? {} : { mode }),
mode: args?.mode === "plan" ? "plan" : "act",
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd) ?? null,
systemPrompt:
@@ -175,7 +148,11 @@ export async function handleRoutineScheduleCommand(
: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags: asTrimmedStringArray(args?.tags) ?? [],
tags: Array.isArray(args?.tags)
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: [],
});
return { schedule: reply.schedule ?? null };
}
@@ -79,7 +79,6 @@ function summarizeClient(client: TrackedClient): {
if (
normalizedType === "code-sidecar" ||
normalizedType === "code-sidecar-approvals" ||
normalizedType === "code-sidecar-observer" ||
normalizedType === "code-sidecar-list"
) {
return { key: "code-app", label: "Code App", name: "Code App" };
@@ -18,7 +18,6 @@ import {
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -115,7 +114,7 @@ export async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
+1 -2
View File
@@ -10,7 +10,6 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@cline/shared": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
@@ -27,7 +26,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "11.16.0",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
@@ -20,13 +20,10 @@ import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
// react-markdown/streamdown pass the hast `Element` here, whose
// `properties` is a broad `Record`. Keep this assignable from that type
// (rather than a narrow `{ metastring?: string }`) so the component stays
// compatible with `Components` regardless of how strict the resolved
// hast/streamdown types are; the metastring value is validated at read time.
node?: {
properties?: Record<string, unknown>;
properties?: {
metastring?: string;
};
};
};
@@ -70,8 +67,7 @@ const MarkdownCode = ({
);
}
const metaValue = node?.properties?.metastring;
const meta = typeof metaValue === "string" ? metaValue : undefined;
const meta = node?.properties?.metastring;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
@@ -1,10 +1,5 @@
"use client";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
} from "@cline/shared";
import {
Circle,
Eye,
@@ -77,7 +72,6 @@ interface RoutineSchedule {
scheduleId: string;
name: string;
cronPattern: string;
metadata?: Record<string, unknown>;
prompt: string;
provider?: string;
model?: string;
@@ -151,7 +145,7 @@ interface ProcessContext {
}
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: [CLINE_DEFAULT_MODEL_ID],
cline: ["anthropic/claude-sonnet-4.6"],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.3-codex"],
openrouter: ["anthropic/claude-sonnet-4.6"],
@@ -176,9 +170,13 @@ interface RoutineFormState {
prompt: string;
provider: string;
model: string;
mode: "act" | "plan";
workspaceRoot: string;
cwd: string;
systemPrompt: string;
maxIterations: string;
timeoutSeconds: string;
maxParallel: string;
tags: string;
enabled: boolean;
}
@@ -205,15 +203,6 @@ function formatDateTime(value?: DateTimeValue | null): string {
return parsed.toLocaleString();
}
function getOneTimeScheduleRunAt(
schedule: RoutineSchedule,
): number | undefined {
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
return typeof runAt === "number" && Number.isFinite(runAt)
? runAt
: undefined;
}
function formatScheduleModel(schedule: RoutineSchedule): string {
const provider =
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
@@ -237,7 +226,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
model:
schedule.modelSelection?.modelId?.trim() ||
schedule.model?.trim() ||
CLINE_DEFAULT_MODEL_ID,
"openai/gpt-5.3-codex",
};
}
@@ -252,27 +241,20 @@ function formatExecutionResult(execution?: RoutineExecution): string {
return when === "-" ? status : `${status} at ${when}`;
}
function asTrimmedFormString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function parseOptionalPositiveInt(value: unknown): number | undefined {
const trimmed = asTrimmedFormString(value);
function parseOptionalPositiveInt(text: string): number | undefined {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
}
const parsedValue = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
const value = Number.parseInt(trimmed, 10);
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}
return parsedValue;
return value;
}
function parseTags(value: unknown): string[] | undefined {
if (typeof value !== "string") {
return undefined;
}
const tags = value
function parseTags(text: string): string[] | undefined {
const tags = text
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
@@ -400,10 +382,14 @@ export function RoutineSchedulesContent() {
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
prompt: "Review PRs opened yesterday and summarize issues.",
provider: "cline",
model: CLINE_DEFAULT_MODEL_ID,
model: "openai/gpt-5.3-codex",
mode: "act",
workspaceRoot: "",
cwd: "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -716,9 +702,13 @@ export function RoutineSchedulesContent() {
prompt: "Review PRs opened yesterday and summarize issues.",
provider: preferredProvider,
model: preferredModel,
mode: "act",
workspaceRoot: context.workspaceRoot || context.cwd,
cwd: context.cwd || "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -726,9 +716,6 @@ export function RoutineSchedulesContent() {
};
const openEditDialog = (schedule: RoutineSchedule) => {
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
return;
}
const { provider, model } = getScheduleProviderModel(schedule);
const parsedCron = parseCronPattern(schedule.cronPattern);
setEditingSchedule(schedule);
@@ -751,12 +738,22 @@ export function RoutineSchedulesContent() {
prompt: schedule.prompt,
provider,
model,
mode: schedule.mode === "plan" ? "plan" : "act",
workspaceRoot: schedule.workspaceRoot ?? "",
cwd: schedule.cwd ?? "",
systemPrompt: schedule.systemPrompt ?? "",
maxIterations:
typeof schedule.maxIterations === "number"
? String(schedule.maxIterations)
: "",
timeoutSeconds:
typeof schedule.timeoutSeconds === "number"
? String(schedule.timeoutSeconds)
: "",
maxParallel:
typeof schedule.maxParallel === "number"
? String(schedule.maxParallel)
: "1",
tags: schedule.tags?.join(",") ?? "",
enabled: schedule.enabled,
});
@@ -764,7 +761,7 @@ export function RoutineSchedulesContent() {
};
const submitCreateForm = async () => {
const name = asTrimmedFormString(createForm.name);
const name = createForm.name.trim();
if (!name) {
setCreateFormError("Routine name is required.");
return;
@@ -778,12 +775,12 @@ export function RoutineSchedulesContent() {
setCreateFormError("Select at least one day and a valid time.");
return;
}
const prompt = asTrimmedFormString(createForm.prompt);
const prompt = createForm.prompt.trim();
if (!prompt) {
setCreateFormError("Prompt is required.");
return;
}
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
const workspaceRoot = createForm.workspaceRoot.trim();
if (!workspaceRoot) {
setCreateFormError("Workspace root is required.");
return;
@@ -792,17 +789,18 @@ export function RoutineSchedulesContent() {
setIsCreating(true);
try {
const provider =
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
normalizeProviderId(createForm.provider) ||
availableProviders[0] ||
"cline";
const model =
asTrimmedFormString(createForm.model) ||
createForm.model.trim() ||
(visibleProviderModels[provider] ?? [])[0] ||
CLINE_DEFAULT_MODEL_ID;
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
"openai/gpt-5.3-codex";
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
const timeoutSeconds = parseOptionalPositiveInt(
createForm.timeoutSeconds,
);
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
const tags = parseTags(createForm.tags);
const command = editingSchedule
? "update_routine_schedule"
@@ -816,16 +814,19 @@ export function RoutineSchedulesContent() {
prompt,
provider,
model,
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
mode: createForm.mode,
workspace_root: workspaceRoot,
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
cwd: createForm.cwd.trim() || undefined,
system_prompt: editingSchedule
? systemPrompt || null
: systemPrompt || undefined,
? createForm.systemPrompt.trim() || null
: createForm.systemPrompt.trim() || undefined,
max_iterations: editingSchedule
? (maxIterations ?? null)
: maxIterations,
timeout_seconds: editingSchedule
? (timeoutSeconds ?? null)
: timeoutSeconds,
max_parallel: 1,
max_parallel: maxParallel,
enabled: createForm.enabled,
tags: tags ?? [],
});
@@ -947,9 +948,7 @@ export function RoutineSchedulesContent() {
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
: schedule.cronPattern}
{schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
@@ -968,10 +967,7 @@ export function RoutineSchedulesContent() {
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={
isBusy ||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
@@ -1377,6 +1373,27 @@ export function RoutineSchedulesContent() {
</Combobox>
</div>
<div>
<Label>Mode</Label>
<Select
value={createForm.mode}
onValueChange={(value) =>
setCreateForm((prev) => ({
...prev,
mode: value === "plan" ? "plan" : "act",
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="act">act</SelectItem>
<SelectItem value="plan">plan</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-workspace">Workspace root</Label>
<Input
@@ -1391,6 +1408,20 @@ export function RoutineSchedulesContent() {
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-cwd">CWD (optional)</Label>
<Input
id="routine-cwd"
value={createForm.cwd}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
cwd: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-system-prompt">
System prompt (optional)
@@ -1408,6 +1439,23 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-iterations">
Max iterations (optional)
</Label>
<Input
id="routine-max-iterations"
value={createForm.maxIterations}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxIterations: event.target.value,
}))
}
placeholder="50"
/>
</div>
<div>
<Label htmlFor="routine-timeout">
Timeout seconds (optional)
@@ -1425,6 +1473,21 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-parallel">Max parallel</Label>
<Input
id="routine-max-parallel"
value={createForm.maxParallel}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxParallel: event.target.value,
}))
}
placeholder="1"
/>
</div>
<div>
<Label htmlFor="routine-tags">
Tags (comma-separated, optional)
-27
View File
@@ -1,27 +0,0 @@
# Cline Code Desktop Changelog
## 0.0.4
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
- New first-run onboarding flow to get you set up on launch.
- Drag and drop files directly onto the chat to attach them.
- Image attachments now display inline in the chat transcript.
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
- New custom overlay title bar with in-app navigation.
- Redesigned channel setup as expandable cards.
- Added a setting to replay the new-user experience.
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
- Headless routines now default to YOLO mode so they can run unattended.
- Fixed request metering for the SAP AI Core provider.
## 0.0.3
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
## 0.0.2
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
- Automatic updates: the app checks on launch and every 2 hours, downloads new versions in the background, and prompts for a one-click restart. Ignored updates apply on the next launch.
- Download the DMG once from GitHub Releases — every future release arrives automatically.
+5 -61
View File
@@ -16,44 +16,7 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
agent-run commands would miss Homebrew-installed tools like `gh` even though
they work fine from a terminal. At startup the sidecar asks the user's login
shell — read from the account database via `getpwuid`, falling back to
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
package. Other Cline web surfaces can take only its tokens or opt into the
Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Releases & Auto-Updates
Releases are built, signed, notarized, and published by the `desktop-publish`
GitHub workflow. The step-by-step flow (version bumps, changelog, tag, repo
secrets) lives in the `publish-desktop` skill
(`.cline/skills/publish-desktop/SKILL.md`).
Installed apps auto-update via the Tauri updater: they poll the rolling
`desktop-latest` release's `latest.json` on launch and every 2 hours, install
updates in the background, and prompt for a restart. Two things must never be
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).
## Shareable Desktop Packages (manual fallback)
## Shareable Desktop Packages
Tauri desktop bundles are OS-specific, so build each package on the target OS:
@@ -100,9 +63,7 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
Startup flow:
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
2. The desktop backend starts the Bun sidecar, which discovers or starts the
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
for desktop commands, queries, and pushed events.
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
4. Tool approval updates are pushed from the backend instead of polled from the UI.
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
@@ -123,8 +84,8 @@ Desktop transport envelope:
## Key Files
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
@@ -138,28 +99,11 @@ Desktop transport envelope:
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
- Full v1 schema for the persisted messages file, including failure/retry semantics and golden fixtures, is documented in [`packages/core/docs/messages-contract-v1.md`](../../../sdk/packages/core/docs/messages-contract-v1.md).
## Sidecar observability
The desktop sidecar sends SDK telemetry through the same configured OpenTelemetry
pipeline used by the CLI and writes structured runtime logs to
`~/.cline/data/logs/code.log` by default. Telemetry continues to honor the global
opt-out setting exposed in the desktop settings UI. The sidecar truncates stale
logs and rotates the active file before it exceeds 50 MiB.
Logging can be configured with the same environment variables as the CLI:
- `CLINE_LOG_ENABLED=0` disables file logging.
- `CLINE_LOG_LEVEL` sets the Pino level (for example, `debug` or `warn`).
- `CLINE_LOG_PATH` overrides the log destination.
- `CLINE_LOG_NAME` overrides the logger name.
## Troubleshooting
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
The next desktop or CLI Hub connection will reuse a compatible running Hub or
replace an incompatible one through the shared discovery path.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
+4 -15
View File
@@ -1,14 +1,11 @@
{
"name": "@cline/code",
"version": "0.0.4",
"version": "0.0.1",
"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",
"dev": "tauri dev",
"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",
@@ -19,10 +16,7 @@
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"pretypecheck": "bun run build:ui",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -30,7 +24,6 @@
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource/azeret-mono": "^5.2.9",
"@hookform/resolvers": "^3.9.1",
@@ -61,9 +54,6 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
@@ -74,19 +64,19 @@
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"marked": "^17.0.3",
"next": "16.2.6",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"shiki": "^4.0.2",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.1",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
@@ -96,7 +86,6 @@
"@tailwindcss/postcss": "^4.2.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"jsdom": "^26.0.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
@@ -17,33 +17,13 @@ const resolveTargetTriple = async (): Promise<string> => {
return host;
};
// Bun cross-compiles --compile binaries, so a CI runner can produce the
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
// bundle from an arm64 runner). Without an explicit --target, bun always
// emits a host-arch binary even when Tauri is building for another triple.
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
if (targetTriple.startsWith("aarch64-apple-darwin"))
return "bun-darwin-arm64";
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
if (targetTriple.startsWith("aarch64-unknown-linux"))
return "bun-linux-arm64";
return undefined;
};
const main = async () => {
const targetTriple = await resolveTargetTriple();
const extension = targetTriple.includes("windows") ? ".exe" : "";
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
const bunTarget = resolveBunCompileTarget(targetTriple);
await $`mkdir -p src-tauri/bin`;
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
}
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
};
main().catch((error: unknown) => {
@@ -1,77 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildUpdateManifest } from "./generate-update-manifest";
const makeArtifactDir = (): string => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
"sig-aarch64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
"sig-x86_64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
return dir;
};
describe("buildUpdateManifest", () => {
test("maps updater artifacts to darwin platform entries", () => {
const dir = makeArtifactDir();
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.version).toBe("0.1.0");
expect(manifest.platforms["darwin-aarch64"]).toEqual({
signature: "sig-aarch64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
});
expect(manifest.platforms["darwin-x86_64"]).toEqual({
signature: "sig-x86_64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
});
// The DMG is a first-install artifact, not an updater artifact.
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("throws when a signature file is missing", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
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 no updater artifacts exist", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
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(/no updater artifacts/);
});
});
@@ -1,133 +0,0 @@
// Generates the Tauri updater manifest (latest.json) from the updater
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
// immutable per-version release assets.
//
// Usage:
// bun scripts/generate-update-manifest.ts \
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
type UpdaterPlatformEntry = {
signature: string;
url: string;
};
export type UpdateManifest = {
version: string;
notes: string;
pub_date: string;
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
aarch64: "darwin-aarch64",
x86_64: "darwin-x86_64",
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
return args[index + 1];
}
const prefix = `${name}=`;
const inline = args.find((arg) => arg.startsWith(prefix));
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
}
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
};
export const buildUpdateManifest = (options: {
version: string;
tag: string;
dir: string;
repo: string;
notes: string;
pubDate: string;
}): UpdateManifest => {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
const signature = readFileSync(signaturePath, "utf8").trim();
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
signature,
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
};
}
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
);
}
return {
version: options.version,
notes: options.notes,
pub_date: options.pubDate,
platforms,
};
};
const main = () => {
const args = process.argv.slice(2);
const version = getArgValue(args, "--version");
const tag = getArgValue(args, "--tag");
const dir = getArgValue(args, "--dir");
const out = getArgValue(args, "--out");
const repo =
getArgValue(args, "--repo") ??
process.env.GITHUB_REPOSITORY ??
"cline/cline";
const notesFile = getArgValue(args, "--notes-file");
if (!version || !tag || !dir || !out) {
throw new Error(
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
);
}
const notes = notesFile
? readFileSync(notesFile, "utf8").trim()
: `Cline Code v${version}`;
const manifest = buildUpdateManifest({
version,
tag,
dir,
repo,
notes,
pubDate: new Date().toISOString(),
});
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
console.log(`wrote ${out}`);
for (const [platform, entry] of Object.entries(manifest.platforms)) {
console.log(`- ${platform}: ${entry.url}`);
}
};
if (import.meta.main) {
main();
}
@@ -2,12 +2,9 @@
## Overview
The sidecar is a Bun process that adapts the desktop UI and native operations to
the shared Cline Hub.
The sidecar is a single Bun process that handles the desktop backend runtime directly.
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
sidecar does not own a private agent runtime Hub.
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
## Directory Structure
@@ -17,7 +14,7 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # Shared-Hub chat session adapter
├── chat-session.ts # In-process chat session management
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -34,23 +31,15 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
## Key Design Decisions
### 1. Chat Sessions — Shared Hub Client
### 1. Chat Sessions — In-Process via LocalRuntimeHost
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
the same compatible Hub discovered by the CLI or starts the canonical detached
Hub when the desktop is the first client:
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
```typescript
import { LocalRuntimeHost } from "@cline/core";
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
hub: {
strategy: "require-hub",
workspaceRoot,
cwd: workspaceRoot,
clientType: "code-sidecar",
displayName: "Code App sidecar",
},
capabilities: {
requestToolApproval: async (request) => {
// Push approval request to frontend via WebSocket event
@@ -77,15 +66,9 @@ sessionManager.subscribe((event) => {
});
```
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
### 2. Tool Approval — In-Memory Promise Resolution
### 2. Tool Approval — Client-Owned Promise Resolution
The shared Hub routes approval requests back to the client that created the
session. Desktop approvals use in-memory promise maps while the webview is
online:
No more file-system watchers. Tool approvals use in-memory promise maps:
```typescript
const pendingApprovals = new Map<string, {
@@ -113,11 +96,12 @@ const store = new SqliteSessionStore();
### 5. Routine Schedules — Direct Hub Commands
Routine operations use the same connected Hub client as chat session
observation. They never start a second in-process Hub:
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
```typescript
await ctx.hubClient.command("schedule.list", { limit: 200 });
import { ensureHubServer, sendHubCommand } from "@cline/core";
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
```
### 6. Native Commands
@@ -138,7 +122,7 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | shared Hub through `ClineCore` |
| `chat_session_command` | `LocalRuntimeHost` in-process |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_provider_settings` | `saveLocalProviderSettings` |
@@ -160,7 +144,7 @@ Supported commands:
| `get_process_context` | In-memory context |
| `poll_tool_approvals` | In-memory pending map |
| `respond_tool_approval` | In-memory promise resolution |
| `list_routine_schedules` | shared Hub schedule commands |
| `list_routine_schedules` | local hub schedule commands |
| `list_user_instruction_configs` | Direct core API |
| `pick_workspace_directory` | OS native dialog |
| `open_mcp_settings_file` | OS `open` command |
@@ -1,158 +0,0 @@
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
materializeUserFiles,
reconcileQueuedAttachments,
sessionAttachmentsDir,
trackQueuedAttachments,
} from "./attachments";
import type { LiveSession } from "./types";
const sessionId = "attachment-test-session";
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
function createSession(): LiveSession {
return {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
};
}
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
describe("materialized attachment lifecycle", () => {
it("only deletes files inside the session attachments dir", () => {
const [staged] = materializeUserFiles(sessionId, [
{ name: "notes.txt", content: "hello" },
]) as string[];
const outside = join(testSessionDataDir, "outside.txt");
writeFileSync(outside, "keep me", "utf8");
deleteMaterializedAttachments(sessionId, [staged, outside]);
expect(existsSync(staged)).toBe(false);
expect(existsSync(outside)).toBe(true);
});
it("deletes consumed files when the turn ends", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
expect(existsSync(files[0] as string)).toBe(true);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(false);
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
});
it("keeps files for a submitted prompt that gets requeued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
// Drain send failed → prompt is back in the queue snapshot.
reconcileQueuedAttachments(session, ["pending_1"]);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(true);
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
});
it("tracks files as consumed when the prompt is no longer queued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(session, [], files);
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
expect(session.consumedAttachmentFiles?.size).toBe(1);
});
it("discards all tracked files on session end", () => {
const session = createSession();
const queued = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const consumed = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: queued,
},
],
queued,
);
trackQueuedAttachments(session, [], consumed);
discardAllTrackedAttachments(sessionId, session);
expect(existsSync(queued[0] as string)).toBe(false);
expect(existsSync(consumed[0] as string)).toBe(false);
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
});
});
@@ -1,157 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve, sep } from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { SessionPendingPrompt } from "@cline/core";
import { sharedSessionDataDir } from "./paths";
import type { ChatTurnAttachments, LiveSession } from "./types";
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.queuedAttachmentFiles) {
session.queuedAttachmentFiles = new Map();
}
return session.queuedAttachmentFiles;
}
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.consumedAttachmentFiles) {
session.consumedAttachmentFiles = new Map();
}
return session.consumedAttachmentFiles;
}
// ---------------------------------------------------------------------------
// Materialized user-attachment lifecycle
//
// Non-image attachments arrive from the webview as inline content and are
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
// load them by path at turn start. The sidecar owns these files and must
// delete them once consumed (turn completed) or discarded (queued prompt
// removed / session ended) — otherwise user data accumulates on disk.
// ---------------------------------------------------------------------------
export function sessionAttachmentsDir(sessionId: string): string {
return join(sharedSessionDataDir(), sessionId, "user-attachments");
}
export function materializeUserFiles(
sessionId: string,
files: ChatTurnAttachments["userFiles"],
): string[] | undefined {
if (!files?.length) {
return undefined;
}
const attachmentDir = sessionAttachmentsDir(sessionId);
mkdirSync(attachmentDir, { recursive: true });
return files.map((file) => {
const requestedName = basename(file.name.trim());
const safeName =
requestedName && requestedName !== "." && requestedName !== ".."
? requestedName
: "attachment.txt";
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
writeFileSync(path, file.content, "utf8");
return path;
});
}
/**
* Delete materialized attachment files. Only paths inside the session's
* user-attachments directory are removed, so files referenced from elsewhere
* (e.g. `@`-mentions) are never touched.
*/
export function deleteMaterializedAttachments(
sessionId: string,
paths: string[] | undefined,
): void {
if (!paths?.length) return;
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
for (const path of paths) {
if (!resolve(path).startsWith(attachmentDir)) continue;
try {
rmSync(path, { force: true });
} catch {
// Best-effort cleanup; leftover files are removed with the session dir.
}
}
}
/**
* Track files staged for a queued/steered prompt so they can be deleted once
* the prompt is consumed or discarded. If the prompt is no longer in the
* queue (already submitted), the files are tracked as consumed and deleted
* when the running turn finishes.
*/
export function trackQueuedAttachments(
session: LiveSession | undefined,
prompts: SessionPendingPrompt[],
userFiles: string[] | undefined,
): void {
if (!session || !userFiles?.length) return;
const match = prompts.find((prompt) =>
isDeepStrictEqual(prompt.userFiles, userFiles),
);
if (match) {
queuedFilesMap(session).set(match.id, userFiles);
} else {
// Not in the queue → already being consumed by the running turn. Key by a
// fresh id so it never collides with a prompt-id key used elsewhere in the
// consumed bucket.
consumedFilesMap(session).set(randomUUID(), userFiles);
}
}
/** Move a submitted queued prompt's files into the consumed bucket. */
export function markQueuedAttachmentsSubmitted(
session: LiveSession | undefined,
promptId: string,
): void {
const files = session?.queuedAttachmentFiles?.get(promptId);
if (!session || !files) return;
session.queuedAttachmentFiles?.delete(promptId);
consumedFilesMap(session).set(promptId, files);
}
/**
* A prompt id reappearing in the queue means a submitted prompt was requeued
* (e.g. the drain send failed) move its files back to the queued bucket so
* the turn-end flush does not delete files still pending consumption.
*/
export function reconcileQueuedAttachments(
session: LiveSession | undefined,
queuedPromptIds: string[],
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const id of queuedPromptIds) {
const files = session.consumedAttachmentFiles.get(id);
if (!files) continue;
session.consumedAttachmentFiles.delete(id);
queuedFilesMap(session).set(id, files);
}
}
/** Delete files for prompts whose turn has finished. */
export function flushConsumedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const files of session.consumedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.consumedAttachmentFiles.clear();
}
/** Delete every tracked file for a session (queued prompts are discarded). */
export function discardAllTrackedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session) return;
flushConsumedAttachments(sessionId, session);
if (!session.queuedAttachmentFiles?.size) return;
for (const files of session.queuedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.queuedAttachmentFiles.clear();
}
@@ -1,19 +1,5 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
prewarmWorkspaceMetadata,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import type { SidecarContext } from "./types";
import { describe, expect, it } from "vitest";
import { buildSessionConnectionUpdate } from "./chat-session";
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
@@ -63,815 +49,3 @@ describe("buildSessionConnectionUpdate", () => {
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
});
describe("shouldUpdateSessionConnection", () => {
it("skips the redundant connection update on the first send", () => {
const config = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
});
it("updates the connection when the selected reasoning level changes", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "low",
};
expect(
shouldUpdateSessionConnection(current, {
...current,
reasoningEffort: "high",
}),
).toBe(true);
});
});
describe("hasProviderChanged", () => {
it("distinguishes provider switches from model switches", () => {
expect(
hasProviderChanged(
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
{ provider: "openai-codex", model: "gpt-5.3-codex" },
),
).toBe(true);
expect(
hasProviderChanged(
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
{ provider: "cline", model: "openai/gpt-5.3-codex" },
),
).toBe(false);
});
it("honors a providerId-only update when the stored config uses provider", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
};
const update = {
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
};
expect(hasProviderChanged(current, update)).toBe(true);
expect(mergeSessionConfig(current, update)).toMatchObject({
provider: "openai-codex",
providerId: "openai-codex",
model: "gpt-5.3-codex",
modelId: "gpt-5.3-codex",
});
});
});
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const ctx = {
liveSessions: new Map(),
sessionManager: { start },
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
enableTools: true,
},
})) as {
sessionId: string;
cwd: string;
workspaceRoot: string;
};
expect(result).toEqual({
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
function createContext(options?: {
attachedViaHub?: boolean;
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async (_input?: unknown) => ({
text: "done",
finishReason: "completed",
messages: [],
}));
const readMessages = vi.fn(async () => [
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
const readSessionCompactionState = vi.fn(async () => undefined);
const stop = vi.fn(async () => undefined);
const sessionId = "session-connection-test";
const start = vi.fn(async (_input?: unknown) => ({ sessionId }));
const ctx = {
liveSessions: new Map([
[
sessionId,
{
config: options?.config ?? baseConfig,
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
attachedViaHub: options?.attachedViaHub ?? false,
},
],
]),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
},
},
} as unknown as SidecarContext;
return {
ctx,
readMessages,
send,
sessionId,
start,
stop,
updateSessionConnection,
};
}
it("skips an identical update for a locally-created session", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledTimes(1);
});
it("allows an image-only user turn", async () => {
const { ctx, send, sessionId } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
attachments: {
userImages: ["data:image/png;base64,aGVsbG8="],
userFiles: [],
},
});
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery: undefined,
userImages: ["data:image/png;base64,aGVsbG8="],
});
});
it.each([
undefined,
"queue",
] as const)("forwards file attachments for %s delivery", async (delivery) => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
);
let sentFileContent: string | undefined;
send.mockImplementation(async (input?: unknown) => {
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
if (files?.[0]) {
sentFileContent = readFileSync(files[0], "utf8");
}
return { text: "done", finishReason: "completed", messages: [] };
});
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
delivery,
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const input = send.mock.calls[0]?.[0] as
| { userFiles?: string[] }
| undefined;
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery,
userImages: undefined,
userFiles: [expect.stringMatching(/notes\.txt$/)],
});
expect(sentFileContent).toBe("hello");
if (delivery === "queue") {
// Queued attachments stay on disk until the prompt is consumed.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
} else {
// Immediate turns delete the materialized file once the send resolves.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
}
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes materialized attachments when a queued prompt is removed", async () => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-remove-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const queue: Array<{
id: string;
prompt: string;
delivery: "queue";
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
delete: (input: {
sessionId: string;
promptId: string;
}) => Promise<unknown>;
};
};
manager.send = vi.fn(async (input?: unknown) => {
const { prompt, userFiles } = input as {
prompt: string;
userFiles?: string[];
};
queue.push({
id: "pending_1",
prompt,
delivery: "queue",
attachmentCount: userFiles?.length ?? 0,
userFiles,
});
return undefined;
}) as unknown as typeof send;
manager.pendingPrompts = {
list: vi.fn(async () => [...queue]),
delete: vi.fn(async ({ promptId }) => {
const index = queue.findIndex((entry) => entry.id === promptId);
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
return {
sessionId,
prompts: [...queue],
prompt: removed,
removed: index >= 0,
};
}),
};
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queued with file",
delivery: "queue",
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const filePath = queue[0]?.userFiles?.[0] ?? "";
expect(existsSync(filePath)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([filePath]);
await handleChatSessionCommand(ctx, {
action: "remove_pending_prompt",
sessionId,
promptId: "pending_1",
});
expect(existsSync(filePath)).toBe(false);
expect(
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
).toBe(0);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes tracked attachments when a session is reset", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-reset-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const [consumedFile] = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
session.consumedAttachmentFiles = new Map([
["pending_2", [consumedFile]],
]);
await handleChatSessionCommand(ctx, {
action: "reset",
sessionId,
});
expect(existsSync(queuedFile)).toBe(false);
expect(existsSync(consumedFile)).toBe(false);
expect(ctx.liveSessions.has(sessionId)).toBe(false);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("preserves tracked attachments across re-attach", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-attach-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
const queuedMap = new Map([["pending_1", [queuedFile]]]);
session.queuedAttachmentFiles = queuedMap;
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}),
);
await handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
});
expect(existsSync(queuedFile)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([queuedFile]);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("rebuilds the same session with its transcript before a provider switch", async () => {
const {
ctx,
readMessages,
send,
sessionId,
start,
stop,
updateSessionConnection,
} = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
expect(readMessages).toHaveBeenCalledWith(sessionId);
expect(stop).toHaveBeenCalledWith(sessionId);
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
sessionId,
}),
initialMessages: [
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
],
}),
);
expect(updateSessionConnection).toHaveBeenCalledWith(sessionId, {
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: null,
});
expect(start.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("blocks a concurrent send throughout provider-switch preparation", async () => {
let resolveMessages:
| ((messages: Array<{ role: string; content: string }>) => void)
| undefined;
const messages = new Promise<Array<{ role: string; content: string }>>(
(resolve) => {
resolveMessages = resolve;
},
);
const { ctx, readMessages, sessionId } = createContext();
readMessages.mockImplementationOnce(async () => await messages);
const switching = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "racing prompt",
config: { ...baseConfig },
}),
).rejects.toThrow("A provider switch is already in progress");
resolveMessages?.([
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
await switching;
});
it.each([
"queue",
"steer",
] as const)("locks provider-switch preparation for explicit %s delivery", async (delivery) => {
let resolveMessages:
| ((messages: Array<{ role: string; content: string }>) => void)
| undefined;
const messages = new Promise<Array<{ role: string; content: string }>>(
(resolve) => {
resolveMessages = resolve;
},
);
const { ctx, readMessages, sessionId } = createContext();
readMessages.mockImplementationOnce(async () => await messages);
const switching = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queue this for Codex",
delivery,
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "racing prompt",
config: { ...baseConfig },
}),
).rejects.toThrow("A provider switch is already in progress");
resolveMessages?.([
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
await switching;
});
it("restores the previous provider runtime when replacement startup fails", async () => {
const { ctx, send, sessionId, start, stop } = createContext();
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
const testKanbanDataDir = join(
tmpdir(),
`cline-provider-rollback-${process.pid}`,
);
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
start
.mockRejectedValueOnce(new Error("Codex bootstrap failed"))
.mockResolvedValueOnce({ sessionId });
try {
const result = (await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
})) as { result?: { finishReason?: string; text?: string } };
expect(stop).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledTimes(2);
expect(start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId,
}),
}),
);
expect(send).not.toHaveBeenCalled();
expect(result.result).toEqual({
finishReason: "error",
text: "Codex bootstrap failed",
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Cline",
config: { ...baseConfig },
});
expect(send).toHaveBeenCalledOnce();
} finally {
if (previousKanbanDataDir === undefined) {
delete process.env.CLINE_KANBAN_DATA_DIR;
} else {
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
}
rmSync(testKanbanDataDir, { recursive: true, force: true });
}
});
it("restores the previous provider when replacement label sync fails", async () => {
const { ctx, send, sessionId, start, stop, updateSessionConnection } =
createContext();
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
const testKanbanDataDir = join(
tmpdir(),
`cline-provider-label-rollback-${process.pid}`,
);
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
try {
updateSessionConnection
.mockRejectedValueOnce(new Error("manifest write failed"))
.mockResolvedValueOnce(undefined);
const result = (await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
})) as { result?: { finishReason?: string; text?: string } };
expect(stop).toHaveBeenCalledTimes(2);
expect(start).toHaveBeenCalledTimes(2);
expect(start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId,
}),
}),
);
expect(updateSessionConnection).toHaveBeenNthCalledWith(2, sessionId, {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: null,
});
expect(send).not.toHaveBeenCalled();
expect(result.result).toEqual({
finishReason: "error",
text: "manifest write failed",
});
expect(ctx.liveSessions.get(sessionId)?.config).toEqual(baseConfig);
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Cline",
config: { ...baseConfig },
});
expect(send).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledTimes(2);
} finally {
if (previousKanbanDataDir === undefined) {
delete process.env.CLINE_KANBAN_DATA_DIR;
} else {
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
}
rmSync(testKanbanDataDir, { recursive: true, force: true });
}
});
it("refreshes hub-attached sessions even when the cached config matches", async () => {
const { ctx, sessionId, updateSessionConnection } = createContext({
attachedViaHub: true,
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
});
});
describe("workspace metadata prewarming", () => {
it("reuses one in-flight scan and consumes it only once", async () => {
let resolveFirst: ((value: string) => void) | undefined;
const firstResult = new Promise<string>((resolve) => {
resolveFirst = resolve;
});
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockImplementationOnce(async () => await firstResult)
.mockResolvedValueOnce("fresh metadata");
const cwd = "/tmp/cline-desktop-prewarm-reuse";
prewarmWorkspaceMetadata(cwd, load);
const consumed = consumeWorkspaceMetadata(cwd, load);
expect(load).toHaveBeenCalledTimes(1);
resolveFirst?.("prewarmed metadata");
await expect(consumed).resolves.toBe("prewarmed metadata");
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"fresh metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("evicts failed scans so the next session can retry", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockRejectedValueOnce(new Error("git unavailable"))
.mockResolvedValueOnce("recovered metadata");
const cwd = "/tmp/cline-desktop-prewarm-retry";
prewarmWorkspaceMetadata(cwd, load);
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
"git unavailable",
);
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"recovered metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("keeps different workspaces in separate single-flight entries", () => {
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
expect(load).toHaveBeenCalledTimes(2);
});
it("refreshes a prewarm that is older than the startup window", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockResolvedValueOnce("startup metadata")
.mockResolvedValueOnce("current metadata");
const cwd = "/tmp/cline-desktop-prewarm-expired";
prewarmWorkspaceMetadata(cwd, load, () => 0);
await expect(
consumeWorkspaceMetadata(
cwd,
load,
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
),
).resolves.toBe("current metadata");
expect(load).toHaveBeenCalledTimes(2);
});
});
+121 -467
View File
@@ -1,26 +1,15 @@
import { existsSync, readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import { basename, join } from "node:path";
import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type ClineCoreStartConfig,
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
type CoreSessionConfig,
type SessionPendingPrompt,
SessionSource,
splitCoreSessionConfig,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import type {
@@ -35,69 +24,6 @@ type SessionConnectionUpdate = Parameters<
ClineCore["updateSessionConnection"]
>[1];
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
type WorkspaceMetadataCacheEntry = {
createdAt: number;
promise: Promise<string>;
};
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
const workspaceMetadataPromises = new Map<
string,
WorkspaceMetadataCacheEntry
>();
function getWorkspaceMetadataPromise(
cwd: string,
load: WorkspaceMetadataLoader,
now: () => number,
): { key: string; promise: Promise<string> } {
const key = resolve(cwd);
const existing = workspaceMetadataPromises.get(key);
const createdAt = now();
if (
existing &&
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
) {
return { key, promise: existing.promise };
}
const promise = load(key);
workspaceMetadataPromises.set(key, { createdAt, promise });
void promise.catch(() => {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
});
return { key, promise };
}
export function prewarmWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): void {
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
}
export async function consumeWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): Promise<string> {
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
try {
return await promise;
} finally {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
}
}
export function refreshWorkspaceMetadata(cwd: string): void {
workspaceMetadataPromises.delete(resolve(cwd));
prewarmWorkspaceMetadata(cwd);
}
// ---------------------------------------------------------------------------
// Session data helpers
// ---------------------------------------------------------------------------
@@ -168,8 +94,6 @@ function createLiveSession(
prompt: overrides?.prompt,
title: overrides?.title,
attachedViaHub: overrides?.attachedViaHub ?? false,
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
};
}
@@ -205,11 +129,6 @@ function readPositiveInteger(value: unknown): number | undefined {
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
const cwd =
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
@@ -228,11 +147,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
modelId: config.model ?? config.modelId ?? "",
mode: config.mode ?? "act",
apiKey: config.apiKey ?? config.api_key ?? "",
baseUrl: config.baseUrl,
headers: config.headers,
providerConfig: config.providerConfig,
...(workspaceRoot ? { workspaceRoot } : {}),
...(cwd ? { cwd } : {}),
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
@@ -263,101 +179,58 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
// into typed fields; the thinking/reasoning transition rules live in the
// shared @cline/core builder.
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
const updates: SessionConnectionUpdate = {};
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (providerId) {
updates.providerId = providerId;
}
const modelId = String(config.model ?? config.modelId ?? "").trim();
const rawApiKey =
if (modelId) {
updates.modelId = modelId;
}
const apiKey =
typeof config.apiKey === "string"
? config.apiKey.trim()
: typeof config.api_key === "string"
? config.api_key.trim()
: undefined;
const baseUrl =
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return buildConnectionUpdate({
...(providerId ? { providerId } : {}),
...(modelId ? { modelId } : {}),
...(rawApiKey ? { apiKey: rawApiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
...(config.headers && typeof config.headers === "object"
? { headers: config.headers as Record<string, string> }
: {}),
...(config.providerConfig && typeof config.providerConfig === "object"
? {
providerConfig:
config.providerConfig as SessionConnectionUpdate["providerConfig"],
}
: {}),
...(typeof config.thinking === "boolean"
? { thinking: config.thinking }
: {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
});
}
export function shouldUpdateSessionConnection(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
return !isDeepStrictEqual(
buildSessionConnectionUpdate(currentConfig),
buildSessionConnectionUpdate(nextConfig),
);
}
function readAliasedString(
config: JsonRecord,
primaryKey: string,
aliasKey: string,
): string | undefined {
for (const key of [primaryKey, aliasKey]) {
if (!Object.hasOwn(config, key)) continue;
const value = String(config[key] ?? "").trim();
return value || undefined;
if (apiKey) {
updates.apiKey = apiKey;
}
return undefined;
}
export function mergeSessionConfig(
currentConfig: JsonRecord,
updates: JsonRecord,
): JsonRecord {
const providerId =
readAliasedString(updates, "provider", "providerId") ??
readAliasedString(currentConfig, "provider", "providerId");
const modelId =
readAliasedString(updates, "model", "modelId") ??
readAliasedString(currentConfig, "model", "modelId");
return {
...currentConfig,
...updates,
...(providerId ? { provider: providerId, providerId } : {}),
...(modelId ? { model: modelId, modelId } : {}),
};
}
export function hasProviderChanged(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
const currentProviderId = readAliasedString(
currentConfig,
"provider",
"providerId",
);
const nextProviderId = readAliasedString(
nextConfig,
"provider",
"providerId",
);
return nextProviderId !== undefined && currentProviderId !== nextProviderId;
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
updates.baseUrl = config.baseUrl.trim();
}
if (config.headers && typeof config.headers === "object") {
updates.headers = config.headers as Record<string, string>;
}
if (config.providerConfig && typeof config.providerConfig === "object") {
updates.providerConfig =
config.providerConfig as SessionConnectionUpdate["providerConfig"];
}
if (thinking === false) {
updates.thinking = false;
updates.reasoningEffort = null;
updates.thinkingBudgetTokens = null;
return updates;
}
if (thinking === true) {
updates.thinking = true;
}
if (reasoningEffort) {
updates.thinking = true;
updates.reasoningEffort = reasoningEffort;
}
if (thinkingBudgetTokens !== undefined) {
updates.thinking = true;
updates.thinkingBudgetTokens = thinkingBudgetTokens;
}
return updates;
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
@@ -373,7 +246,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
: config.mode === "plan"
? "plan"
: "act";
const metadata = await consumeWorkspaceMetadata(cwd);
const metadata = await buildWorkspaceMetadata(cwd);
const inlineRules =
typeof config.rules === "string" && config.rules.trim().length > 0
? config.rules
@@ -415,13 +288,7 @@ function sendPromptsInQueueSnapshot(
const session = ctx.liveSessions.get(sessionId);
sendEvent(ctx, "prompts_in_queue_state", {
sessionId,
items:
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
})) ?? [],
items: session?.promptsInQueue ?? [],
});
}
@@ -431,7 +298,6 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
prompt: item.prompt,
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount,
userImages: item.userImages,
};
}
@@ -446,12 +312,7 @@ function applyPendingPrompts(
session.promptsInQueue = mapped;
}
sendPromptsInQueueSnapshot(ctx, sessionId);
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
}));
return mapped;
}
function getSessionManager(ctx: SidecarContext): ClineCore {
@@ -491,12 +352,11 @@ async function handleStart(
// the frontend call the separate "send" action to dispatch the prompt.
// This avoids a double-execution bug where start() would run the turn AND
// the subsequent manager.send() fire-and-forget would run it again.
ctx.logger?.log("Starting desktop chat session", {
providerId: String(coreConfig.providerId ?? ""),
modelId: String(coreConfig.modelId ?? ""),
});
console.error(
`[sidecar:handleStart] calling manager.start provider=${coreConfig.providerId} model=${coreConfig.modelId}`,
);
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
source: SessionSource.DESKTOP,
interactive: true,
...(initialMessages
@@ -505,24 +365,19 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
},
);
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
const session = createLiveSession(request.config, {
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
});
ctx.liveSessions.set(sessionId, session);
return { sessionId, cwd, workspaceRoot };
return { sessionId };
}
async function handleAttach(
@@ -580,11 +435,6 @@ async function handleAttach(
existing?.title,
endedAt: isoTimestampToMs(session.endedAt),
attachedViaHub: true,
// Preserve tracked attachment files so re-attach (called on every
// webview hydrate) does not orphan materialized files still awaiting
// cleanup.
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
@@ -600,251 +450,74 @@ async function handleAttach(
};
}
async function startRebuiltSession(
manager: ClineCore,
sessionId: string,
config: JsonRecord,
systemPrompt: string,
messages: Message[],
compactionState: SessionCompactionState | undefined,
): Promise<void> {
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
const restarted = await manager.start({
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
sessionId,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
initialMessages: messages,
...(projectedMessages
? {
initialCompactionState: createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
}),
}
: {}),
toolPolicies: resolveToolPolicies(config),
});
if (restarted.sessionId !== sessionId) {
throw new Error(
`Provider switch changed session id from ${sessionId} to ${restarted.sessionId}`,
);
}
}
async function rebuildSessionForProviderChange(
ctx: SidecarContext,
manager: ClineCore,
sessionId: string,
previousConfig: JsonRecord,
nextConfig: JsonRecord,
): Promise<void> {
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
await Promise.all([
manager.readMessages(sessionId),
manager.readSessionCompactionState(sessionId).catch((error) => {
ctx.logger?.log?.("Failed to read desktop session compaction state", {
sessionId,
error,
severity: "warn",
});
return undefined;
}),
resolveSystemPrompt(previousConfig),
resolveSystemPrompt(nextConfig),
]);
await manager.stop(sessionId);
let replacementStarted = false;
try {
await startRebuiltSession(
manager,
sessionId,
nextConfig,
nextSystemPrompt,
messages,
compactionState,
);
replacementStarted = true;
// Reusing a session id preserves its existing manifest. Treat refreshing
// its connection label as part of the replacement transaction so a
// persistence failure cannot leave runtime and cached state diverged.
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
);
} catch (replacementError) {
try {
if (replacementStarted) {
await manager.stop(sessionId);
}
await startRebuiltSession(
manager,
sessionId,
previousConfig,
previousSystemPrompt,
messages,
compactionState,
);
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(previousConfig),
);
} catch (rollbackError) {
throw new AggregateError(
[replacementError, rollbackError],
"Provider switch and rollback both failed",
);
}
throw replacementError;
}
}
async function handleSend(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim() ?? "";
const hasAttachments =
(request.attachments?.userImages?.length ?? 0) > 0 ||
(request.attachments?.userFiles?.length ?? 0) > 0;
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (session?.transitioningProvider) {
throw new Error("A provider switch is already in progress");
if (request.config) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
if (session) {
session.config = { ...session.config, ...request.config };
}
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
// via Core so that Core's own pending-prompts mechanism handles draining.
// This avoids a sidecar-only local queue that never calls manager.send().
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
}
const nextConfig = request.config
? mergeSessionConfig(session?.config ?? {}, request.config)
: undefined;
const providerChanged = Boolean(
session &&
request.config &&
hasProviderChanged(session.config, request.config),
);
if (providerChanged && session?.busy) {
throw new Error("Cannot switch providers while a turn is running");
}
const ownsBusyState = Boolean(
session && delivery !== "queue" && delivery !== "steer",
);
if (session) {
if (ownsBusyState) {
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
session.busy = true;
session.status = "running";
}
if (providerChanged) {
session.transitioningProvider = true;
}
// Delegate queuing to Core — it will drain the prompt once the current
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
await manager.send({
sessionId,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
};
}
if (session) {
session.prompt = prompt;
session.busy = true;
session.status = "running";
}
try {
if (request.config && nextConfig) {
if (providerChanged && session) {
await rebuildSessionForProviderChange(
ctx,
manager,
sessionId,
session.config,
nextConfig,
);
} else if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, nextConfig)
) {
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
);
}
if (session) {
session.config = nextConfig;
if (providerChanged) {
session.attachedViaHub = false;
}
}
}
const userFiles = materializeUserFiles(
sessionId,
request.attachments?.userFiles,
console.error(
`[sidecar:handleSend] calling manager.send sessionId=${sessionId} prompt=${prompt.slice(0, 80)}`,
);
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
}
await manager.send({
sessionId,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
userFiles,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
trackQueuedAttachments(session, prompts, userFiles);
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
};
}
ctx.logger?.debug("Sending desktop chat prompt", {
const result = await manager.send({
sessionId,
promptLength: prompt.length,
prompt,
delivery,
userImages: request.attachments?.userImages,
});
let result: Awaited<ReturnType<ClineCore["send"]>>;
try {
result = await manager.send({
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
userFiles,
});
} catch (error) {
deleteMaterializedAttachments(sessionId, userFiles);
throw error;
}
if (result === undefined) {
// The runtime queued or steered the prompt instead of running it
// (busy interactive session / steer delivery) — track the files so
// they are deleted once the prompt is consumed or discarded.
if (userFiles?.length) {
trackQueuedAttachments(
session,
await manager.pendingPrompts.list({ sessionId }),
userFiles,
);
}
} else {
deleteMaterializedAttachments(sessionId, userFiles);
}
ctx.logger?.log("Desktop chat prompt completed", {
sessionId,
finishReason: result?.finishReason,
textLength: result?.text?.length ?? 0,
});
if (session && ownsBusyState) {
console.error(
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
);
if (session) {
session.busy = false;
session.status = "idle";
if (result?.messages) session.messages = result.messages as unknown[];
}
@@ -863,8 +536,11 @@ async function handleSend(
: undefined,
};
} catch (error) {
ctx.logger?.error?.("Desktop chat prompt failed", { sessionId, error });
if (session && ownsBusyState) {
console.error(
`[sidecar:handleSend] manager.send THREW sessionId=${sessionId} error=${error instanceof Error ? error.message : String(error)}`,
);
if (session) {
session.busy = false;
session.status = "error";
}
emitChunk(
@@ -884,15 +560,6 @@ async function handleSend(
text: error instanceof Error ? error.message : String(error),
},
};
} finally {
if (session) {
if (ownsBusyState) {
session.busy = false;
}
if (providerChanged) {
session.transitioningProvider = false;
}
}
}
}
@@ -999,7 +666,7 @@ async function handleFork(
...forkConfig,
systemPrompt,
initialMessages: sourceMessages,
}) as unknown as ClineCoreStartConfig,
}) as unknown as CoreSessionConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1008,10 +675,6 @@ async function handleFork(
toolPolicies: resolveToolPolicies(forkConfig),
});
const newSessionId = startResult.sessionId;
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
@@ -1051,7 +714,6 @@ async function handleReset(
) {
await getSessionManager(ctx).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
@@ -1090,7 +752,7 @@ async function handleRestoreCheckpoint(
buildCoreSessionConfig({
...request.config,
systemPrompt: await resolveSystemPrompt(request.config),
}) as unknown as ClineCoreStartConfig,
}) as unknown as CoreSessionConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1102,10 +764,6 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
@@ -1207,10 +865,6 @@ async function handleRemovePendingPrompt(
sessionId,
promptId,
});
if (result.removed === true) {
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
}
return {
sessionId,
removed: result.removed === true,
File diff suppressed because it is too large Load Diff
@@ -263,24 +263,6 @@ export async function startConnectorChannel(
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
if (listActiveConnectors().some((connector) => connector.type === channel)) {
const stopResult = await runCliConnectCommand(workspaceRoot, [
"--stop",
channel,
]);
if (stopResult.code !== 0) {
throw new Error(
normalizeConnectorError(
stopResult.stderr || stopResult.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
}
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
if (result.code !== 0) {
throw new Error(
@@ -308,7 +290,7 @@ export async function stopConnectorChannel(
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
+38 -201
View File
@@ -1,19 +1,12 @@
import { existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RuntimeCapabilities } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import type { LiveSession, SidecarContext } from "./types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
const hubCommandMock = vi.hoisted(() => vi.fn());
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
const hubGetUrlMock = vi.hoisted(() => vi.fn());
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -24,16 +17,19 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
command = hubCommandMock;
getConnectionError = hubGetConnectionErrorMock;
getUrl = hubGetUrlMock;
isConnected = hubIsConnectedMock;
subscribe = subscribeMock;
dispose = vi.fn();
},
@@ -57,21 +53,20 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
ensureCompatibleLocalHubUrlMock.mockReset();
hubCommandMock.mockReset();
hubGetConnectionErrorMock.mockReset();
hubGetUrlMock.mockReset();
hubIsConnectedMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
"ws://127.0.0.1:25463/hub",
);
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
hubGetConnectionErrorMock.mockReturnValue(null);
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
hubIsConnectedMock.mockReturnValue(true);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -88,6 +83,15 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -98,115 +102,22 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
const hubOptions = createCoreMock.mock.calls[0][0].hub;
expect(hubOptions).not.toHaveProperty("endpoint");
expect(hubOptions).not.toHaveProperty("authToken");
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
displayName: "Code App observer",
authToken: "test-token",
clientType: "code-sidecar-approvals",
}),
);
});
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const logger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const telemetry = { capture: vi.fn() };
const ctx = createSidecarContext("/workspace/project", {
logger,
telemetry: telemetry as never,
});
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
logger,
telemetry,
}),
);
});
it("reports the connected shared Hub endpoint in process context", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
expect.objectContaining({
hub: {
status: "connected",
url: "ws://127.0.0.1:25463/hub",
error: null,
},
}),
);
});
it("starts or reuses the shared Hub when a command needs a client", async () => {
const { createSidecarContext, ensureSharedHubClient } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
});
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
}),
);
expect(connectMock).toHaveBeenCalledOnce();
});
it("serializes queued image data when a queued prompt starts", async () => {
const { serializeQueuedPromptStart } = await import("./context");
expect(
JSON.parse(
serializeQueuedPromptStart({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
}),
),
).toEqual({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
});
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -281,7 +192,8 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
strategy: "require-hub",
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -341,79 +253,4 @@ describe("Code sidecar runtime capabilities", () => {
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
).toEqual([]);
});
it("routes routine commands through the connected shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "pause_routine_schedule", {
schedule_id: "schedule-1",
}),
).resolves.toEqual({
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
scheduleId: "schedule-1",
});
});
});
describe("disposeSidecarContext attachment cleanup", () => {
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
it("deletes tracked attachments for all live sessions on shutdown", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const sessionId = "dispose-session";
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session: LiveSession = {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
};
ctx.liveSessions.set(sessionId, session);
await disposeSidecarContext(ctx, "test_shutdown");
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
});
+42 -132
View File
@@ -4,24 +4,18 @@ import { homedir } from "node:os";
import { dirname } from "node:path";
import {
type AgentToolContext,
type BasicLogger,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
ensureCompatibleLocalHubUrl,
type ITelemetryService,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import type { AgentEvent } from "@cline/shared";
import {
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -32,10 +26,6 @@ import type {
} from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
SidecarContext,
Promise<NodeHubClient>
>();
// ---------------------------------------------------------------------------
// Helpers — WebSocket broadcast
@@ -119,29 +109,7 @@ export function broadcastChunk(
// ---------------------------------------------------------------------------
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
return session.promptsInQueue.map(
({ id, prompt, steer, attachmentCount, userImages }) => ({
id,
prompt,
steer,
attachmentCount,
userImages,
}),
);
}
export function serializeQueuedPromptStart(input: {
promptId: string;
prompt: string;
attachmentCount?: number;
userImages?: string[];
}): string {
return JSON.stringify({
promptId: input.promptId,
prompt: input.prompt,
attachmentCount: input.attachmentCount ?? 0,
userImages: input.userImages,
});
return session.promptsInQueue;
}
function sendPromptsInQueueSnapshot(
@@ -333,16 +301,9 @@ function handleCoreSessionEvent(
prompt: item.prompt ?? "",
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount ?? 0,
userImages: item.userImages,
}))
.filter(
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
);
.filter((item) => item.id && item.prompt);
if (session) {
reconcileQueuedAttachments(
session,
mapped.map((item) => item.id),
);
const previous = session.promptsInQueue;
session.promptsInQueue = mapped;
if (
@@ -354,11 +315,9 @@ function handleCoreSessionEvent(
ctx,
sessionId,
"chat_queued_prompt_start",
serializeQueuedPromptStart({
promptId: previous[0].id,
JSON.stringify({
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
}),
);
}
@@ -367,18 +326,14 @@ function handleCoreSessionEvent(
break;
}
case "pending_prompt_submitted": {
const { sessionId, id, prompt, attachmentCount, userImages } =
event.payload;
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
const { sessionId, prompt, attachmentCount } = event.payload;
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
serializeQueuedPromptStart({
promptId: id,
JSON.stringify({
prompt,
attachmentCount: attachmentCount ?? 0,
userImages,
}),
);
break;
@@ -391,7 +346,6 @@ function handleCoreSessionEvent(
session.endedAt = nowMs();
session.status = reason || "ended";
}
discardAllTrackedAttachments(sessionId, session);
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
break;
}
@@ -411,10 +365,6 @@ function handleCoreSessionEvent(
if (session) {
session.status = status;
session.busy = status === "running";
if (status !== "running") {
// The turn that consumed submitted attachments has finished.
flushConsumedAttachments(sessionId, session);
}
}
sendEvent(ctx, "chat_session_status", { sessionId, status });
break;
@@ -430,13 +380,7 @@ function handleCoreSessionEvent(
// Context factory
// ---------------------------------------------------------------------------
export function createSidecarContext(
workspaceRoot: string,
observability: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
} = {},
): SidecarContext {
export function createSidecarContext(workspaceRoot: string): SidecarContext {
return {
liveSessions: new Map(),
streamIndices: new Map(),
@@ -445,9 +389,8 @@ export function createSidecarContext(
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
unsubscribeSessionEvents: null,
};
}
@@ -461,11 +404,6 @@ export async function disposeSidecarContext(
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
}
ctx.liveSessions.clear();
for (const client of ctx.wsClients) {
try {
client.close?.();
@@ -496,6 +434,12 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -748,14 +692,19 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
hub: {
strategy: "require-hub",
endpoint: hubServer.url,
authToken: hubServer.authToken,
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -768,64 +717,25 @@ export async function initializeSessionManager(
handleCoreSessionEvent(ctx, event);
});
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.unsubscribeSessionEvents = unsubscribe;
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
return await pending;
}
const initialization = (async () => {
const url =
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
}
const client = new NodeHubClient({
url,
clientType: "code-sidecar-observer",
displayName: "Code App observer",
const runtimeAddress = sessionManager.runtimeAddress?.trim();
let hubClient: NodeHubClient | null = null;
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
ctx.hubClient = client;
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
throw error;
}
})().finally(() => {
hubClientInitialization.delete(ctx);
});
await hubClient.connect();
hubClient.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
}
hubClientInitialization.set(ctx, initialization);
return await initialization;
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
+6 -80
View File
@@ -1,22 +1,13 @@
import { homedir } from "node:os";
import { setHomeDirIfUnset } from "@cline/core";
import { isHubDaemonProcess } from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import {
createSidecarContext,
disposeSidecarContext,
initializeSessionManager,
} from "./context";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { ensureLoginShellPath } from "./shell-path";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
import { BunRuntime, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
let activeObservability:
| ReturnType<typeof createDesktopObservability>
| undefined;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -40,48 +31,18 @@ async function main() {
throw new Error("sidecar must be run with Bun");
}
// When launched from Finder/the Dock the app inherits launchd's minimal
// PATH, so agent-spawned processes can't find shell-profile-installed
// tools like `gh`. Kick resolution off first so it overlaps the rest of
// startup, but await it before the session manager exists — that's what
// spawns children (agent sessions, MCP servers, scheduled runs).
const shellPathPromise = ensureLoginShellPath();
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
setHomeDirIfUnset(homedir());
const observability = createDesktopObservability();
activeObservability = observability;
const ctx = createSidecarContext(workspaceRoot, observability);
observability.logger.log("Desktop sidecar starting", {
workspaceRoot,
pid: process.pid,
});
const ctx = createSidecarContext(workspaceRoot);
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
await initializeSessionManager(ctx);
let shuttingDown = false;
let handlingFatalError = false;
const shutdown = async (reason = "code_sidecar_shutdown"): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
observability.logger.log("Desktop sidecar shutting down", { reason });
await withTimeout(
(async () => {
try {
await disposeSidecarContext(ctx, reason);
} finally {
await observability.dispose();
}
})(),
SHUTDOWN_TIMEOUT_MS,
);
await withTimeout(disposeSidecarContext(ctx, reason), SHUTDOWN_TIMEOUT_MS);
};
const shutdownAndExit = (signal: string): void => {
@@ -92,37 +53,14 @@ async function main() {
process.once("SIGINT", () => shutdownAndExit("SIGINT"));
process.once("SIGTERM", () => shutdownAndExit("SIGTERM"));
const handleFatalError = (kind: string, error: unknown): void => {
if (handlingFatalError) {
process.exit(1);
}
handlingFatalError = true;
observability.logger.error?.("Desktop sidecar process error", {
kind,
error,
});
void shutdown(`code_sidecar_${kind}`).finally(() => process.exit(1));
};
process.on("uncaughtException", (error) => {
handleFatalError("uncaught_exception", error);
});
process.on("unhandledRejection", (error) => {
handleFatalError("unhandled_rejection", error);
});
process.once("beforeExit", () => {
void shutdown("code_sidecar_before_exit");
});
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
observability.logger.log("Desktop sidecar ready", {
port,
mode: SIDECAR_MODE,
});
// 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 = `ws://${dialHost}:${port}/transport`;
const endpoint = `http://127.0.0.1:${port}`;
const wsEndpoint = `ws://127.0.0.1:${port}/transport`;
process.stdout.write(
`${JSON.stringify({
type: "ready",
@@ -134,20 +72,8 @@ async function main() {
);
}
async function runEntrypoint(): Promise<void> {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
await main();
}
runEntrypoint().catch(async (error) => {
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
activeObservability?.logger.error?.("Desktop sidecar process failed", {
error,
});
await activeObservability?.dispose();
process.stderr.write(`${message}\n`);
process.exit(1);
});
@@ -1,95 +0,0 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
truncateSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDesktopLoggerAdapter, DESKTOP_LOG_MAX_BYTES } from "./logging";
const originalEnv = {
CLINE_LOG_ENABLED: process.env.CLINE_LOG_ENABLED,
CLINE_LOG_LEVEL: process.env.CLINE_LOG_LEVEL,
CLINE_LOG_NAME: process.env.CLINE_LOG_NAME,
CLINE_LOG_PATH: process.env.CLINE_LOG_PATH,
};
afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("desktop sidecar logging", () => {
it("writes structured SDK logs to the configured file", () => {
const directory = mkdtempSync(join(tmpdir(), "cline-code-logging-"));
const destination = join(directory, "sidecar.log");
process.env.CLINE_LOG_PATH = destination;
process.env.CLINE_LOG_LEVEL = "debug";
delete process.env.CLINE_LOG_ENABLED;
try {
const adapter = createDesktopLoggerAdapter();
adapter.core.debug("desktop runtime event", { sessionId: "session-1" });
adapter.dispose();
const contents = readFileSync(destination, "utf8");
expect(contents).toContain("desktop runtime event");
expect(contents).toContain('"sessionId":"session-1"');
expect(adapter.runtimeConfig.name).toBe("cline-code.sidecar");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it("warns once before falling back to stderr when the log file cannot open", () => {
const directory = mkdtempSync(join(tmpdir(), "cline-code-fallback-"));
process.env.CLINE_LOG_PATH = directory;
delete process.env.CLINE_LOG_ENABLED;
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
try {
const adapter = createDesktopLoggerAdapter();
adapter.dispose();
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining("Unable to open log file"),
);
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining("falling back to stderr"),
);
} finally {
stderr.mockRestore();
rmSync(directory, { recursive: true, force: true });
}
});
it("rotates the active log before a write exceeds the size limit", () => {
const directory = mkdtempSync(join(tmpdir(), "cline-code-rotation-"));
const destination = join(directory, "sidecar.log");
process.env.CLINE_LOG_PATH = destination;
delete process.env.CLINE_LOG_ENABLED;
try {
mkdirSync(directory, { recursive: true });
writeFileSync(destination, "");
truncateSync(destination, DESKTOP_LOG_MAX_BYTES - 1);
const adapter = createDesktopLoggerAdapter();
adapter.core.log("rotate before writing this entry");
adapter.dispose();
expect(statSync(destination).size).toBeLessThan(1_024);
expect(readFileSync(destination, "utf8")).toContain(
"rotate before writing this entry",
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});
@@ -1,245 +0,0 @@
import {
closeSync,
mkdirSync,
openSync,
statSync,
truncateSync,
} from "node:fs";
import { dirname, join } from "node:path";
import {
type BasicLogger,
type RuntimeLoggerConfig,
resolveClineDataDir,
} from "@cline/core";
import pino, {
type DestinationStream,
type LevelWithSilent,
type Logger as PinoLogger,
} from "pino";
const LOG_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1_000;
export const DESKTOP_LOG_MAX_BYTES = 50 * 1024 * 1024;
const LOG_LEVELS: ReadonlySet<LevelWithSilent> = new Set([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
"silent",
]);
export interface DesktopLoggerAdapter {
readonly core: BasicLogger;
readonly runtimeConfig: Required<RuntimeLoggerConfig>;
flush(): void;
dispose(): void;
}
function resolveLogLevel(value: string | undefined): LevelWithSilent {
const candidate = value?.trim().toLowerCase() as LevelWithSilent | undefined;
return candidate && LOG_LEVELS.has(candidate) ? candidate : "info";
}
function resolveRuntimeConfig(): Required<RuntimeLoggerConfig> {
const enabledValue = process.env.CLINE_LOG_ENABLED?.trim().toLowerCase();
return {
enabled: enabledValue !== "0" && enabledValue !== "false",
level: resolveLogLevel(process.env.CLINE_LOG_LEVEL),
destination:
process.env.CLINE_LOG_PATH?.trim() ||
join(resolveClineDataDir(), "logs", "code.log"),
name: process.env.CLINE_LOG_NAME?.trim() || "cline-code.sidecar",
bindings: {},
};
}
type ManagedDestination = DestinationStream & {
flushSync(): void;
end(): void;
};
type DestinationResult =
| { destination: ManagedDestination; error?: never }
| { destination?: never; error: unknown };
function createDestination(path: string): DestinationResult {
try {
mkdirSync(dirname(path), { recursive: true });
const fd = openSync(path, "a");
closeSync(fd);
const initialStats = statSync(path);
if (
Date.now() - initialStats.mtimeMs >= LOG_MAX_AGE_MS ||
initialStats.size >= DESKTOP_LOG_MAX_BYTES
) {
truncateSync(path, 0);
}
const rawDestination = pino.destination({
dest: path,
mkdir: true,
sync: true,
});
const rawFlushSync = rawDestination.flushSync.bind(rawDestination);
let currentSize = statSync(path).size;
const destination: ManagedDestination = {
write(message: string) {
const messageSize = Buffer.byteLength(message);
if (currentSize + messageSize > DESKTOP_LOG_MAX_BYTES) {
try {
rawFlushSync();
truncateSync(path, 0);
currentSize = 0;
} catch {
// Rotation is best-effort; preserve the log entry if it fails.
}
}
rawDestination.write(message);
currentSize += messageSize;
},
flushSync() {
try {
rawFlushSync();
} catch {
// The synchronous stream may already be closed during teardown.
}
},
end() {
rawDestination.end();
},
};
return { destination };
} catch (error) {
return { error };
}
}
function writeDestinationFallbackWarning(path: string, error: unknown): void {
try {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`[cline-code] Unable to open log file ${path}; falling back to stderr (${message})\n`,
);
} catch {
// The fallback warning must never prevent sidecar startup.
}
}
function flushDestination(destination: ManagedDestination | undefined): void {
if (!destination) return;
try {
destination.flushSync();
} catch {
// Logging is best-effort during shutdown.
}
}
function closeDestination(destination: ManagedDestination | undefined): void {
if (!destination) return;
try {
destination.end();
} catch {
// Logging is best-effort during shutdown.
}
}
function createFallbackDestination(): DestinationStream {
return {
write(message: string) {
process.stderr.write(message);
},
};
}
/*
The adapter intentionally owns the destination lifecycle. Pino receives a
synchronous stream so telemetry and fatal-process logs can be flushed before
the sidecar exits.
*/
function createPinoLogger(
runtimeConfig: Required<RuntimeLoggerConfig>,
destination: ManagedDestination | undefined,
): PinoLogger {
return pino(
{
name: runtimeConfig.name,
level: runtimeConfig.enabled ? runtimeConfig.level : "silent",
enabled: runtimeConfig.enabled,
timestamp: pino.stdTimeFunctions.isoTime,
},
destination ?? createFallbackDestination(),
).child({ component: "sidecar" });
}
function flushLogger(logger: PinoLogger): void {
try {
logger.flush?.();
} catch {
// Logging is best-effort during shutdown.
}
}
function toFields(
metadata?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!metadata) return undefined;
const { error, ...rest } = metadata;
const fields = error === undefined ? rest : { ...rest, err: error };
return Object.keys(fields).length > 0 ? fields : undefined;
}
function createCoreLogger(logger: PinoLogger): BasicLogger {
return {
debug(message, metadata) {
const fields = toFields(metadata);
fields ? logger.debug(fields, message) : logger.debug(message);
},
log(message, metadata) {
const fields = toFields(metadata);
const write =
metadata?.severity === "error"
? logger.error
: metadata?.severity === "warn"
? logger.warn
: logger.info;
fields
? write.call(logger, fields, message)
: write.call(logger, message);
},
error(message, metadata) {
const fields = toFields(metadata);
fields ? logger.error(fields, message) : logger.error(message);
},
};
}
export function createDesktopLoggerAdapter(): DesktopLoggerAdapter {
const runtimeConfig = resolveRuntimeConfig();
const destinationResult = runtimeConfig.enabled
? createDestination(runtimeConfig.destination)
: undefined;
const destination = destinationResult?.destination;
if (destinationResult?.error !== undefined) {
writeDestinationFallbackWarning(
runtimeConfig.destination,
destinationResult.error,
);
}
const logger = createPinoLogger(runtimeConfig, destination);
let disposed = false;
const flush = () => {
flushDestination(destination);
flushLogger(logger);
};
return {
core: createCoreLogger(logger),
runtimeConfig,
flush,
dispose() {
if (disposed) return;
disposed = true;
flush();
closeDestination(destination);
},
};
}
@@ -1,81 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
captureExtensionActivated: vi.fn(),
createClineTelemetryServiceConfig: vi.fn((config: unknown) => config),
createConfiguredTelemetryHandle: vi.fn(),
disposeTelemetry: vi.fn(async () => {}),
disposeLogger: vi.fn(),
identifyAccount: vi.fn(),
setSdkLogger: vi.fn(),
}));
const logger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const telemetry = { capture: vi.fn() };
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
captureExtensionActivated: mocks.captureExtensionActivated,
createClineTelemetryServiceConfig: mocks.createClineTelemetryServiceConfig,
createConfiguredTelemetryHandle: mocks.createConfiguredTelemetryHandle,
identifyAccount: mocks.identifyAccount,
ProviderSettingsManager: class {
getProviderSettings() {
return { auth: { accountId: "account-1" } };
}
},
setSdkLogger: mocks.setSdkLogger,
};
});
vi.mock("./logging", () => ({
createDesktopLoggerAdapter: () => ({
core: logger,
dispose: mocks.disposeLogger,
}),
}));
describe("desktop observability", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.createConfiguredTelemetryHandle.mockReturnValue({
telemetry,
dispose: mocks.disposeTelemetry,
});
});
it("configures desktop telemetry, identity, activation, and lifecycle", async () => {
const { createDesktopObservability } = await import("./observability");
const observability = createDesktopObservability();
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
metadata: expect.objectContaining({
cline_type: "desktop",
platform: "Cline Code",
}),
});
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
expect.objectContaining({ logger }),
);
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
id: "account-1",
provider: "cline",
});
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
await observability.dispose();
await observability.dispose();
expect(mocks.disposeTelemetry).toHaveBeenCalledTimes(1);
expect(mocks.setSdkLogger).toHaveBeenLastCalledWith(undefined);
expect(mocks.disposeLogger).toHaveBeenCalledTimes(1);
});
});

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