Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70929a1c89 | ||
|
|
706ceac669 | ||
|
|
999a2b8e7b | ||
|
|
d6ede1a89c | ||
|
|
a9ca528141 | ||
|
|
ad521395f2 | ||
|
|
7f75100da2 | ||
|
|
721d185485 | ||
|
|
a776ce57e4 | ||
|
|
419829e9d0 | ||
|
|
9e12953bed | ||
|
|
27fe60d79f | ||
|
|
48285afa65 | ||
|
|
66bacb655c | ||
|
|
6224cc80aa | ||
|
|
cb0092e343 | ||
|
|
0905884ab5 | ||
|
|
d8221325fa | ||
|
|
0cf47f5851 | ||
|
|
063ce7c06d | ||
|
|
173837314b | ||
|
|
3871851962 | ||
|
|
025baa8fb7 | ||
|
|
d6d4566451 | ||
|
|
964a0fc36d | ||
|
|
fb1d838085 | ||
|
|
99db7582b0 | ||
|
|
15f001ad0b | ||
|
|
186ac22e29 | ||
|
|
fc68af9660 | ||
|
|
b675eb6189 | ||
|
|
82b8e1fa04 | ||
|
|
6e8bea1cba | ||
|
|
8739d4953c | ||
|
|
c17c82b79a | ||
|
|
cb0b20f791 | ||
|
|
2198414245 | ||
|
|
d718dd16f8 | ||
|
|
128ec277d5 | ||
|
|
18477898e0 | ||
|
|
9f19f047d2 | ||
|
|
e21b5903c2 | ||
|
|
17529c8cb1 | ||
|
|
36905f11cc | ||
|
|
722b640c28 | ||
|
|
067b865f97 | ||
|
|
c925a9c94c | ||
|
|
899e8f3da4 | ||
|
|
e10183f2a8 | ||
|
|
00daef8f89 | ||
|
|
94980446c9 | ||
|
|
c2ac5feecb | ||
|
|
97afeaccf0 | ||
|
|
b4b980d9a1 | ||
|
|
9de01af92d | ||
|
|
d896a0ac84 | ||
|
|
f80dd1eeff | ||
|
|
19ddebb3b9 | ||
|
|
c1c0b55ca0 | ||
|
|
ccb62e6b39 | ||
|
|
81abe4978c | ||
|
|
ea7c7f11a9 | ||
|
|
cfe9cadab9 | ||
|
|
e7d224dec6 | ||
|
|
632fce5071 | ||
|
|
e15bb0a7e5 | ||
|
|
efa9a17caf | ||
|
|
ac082be7c5 | ||
|
|
7e20bbc0e0 | ||
|
|
a7c1dfc298 | ||
|
|
23924da84f | ||
|
|
dd50b97192 | ||
|
|
5142921a54 | ||
|
|
e4f1df2c5a | ||
|
|
3f74808a43 | ||
|
|
1636215231 | ||
|
|
a0a8c060fb | ||
|
|
31b0dd9900 | ||
|
|
ea368a1fc5 | ||
|
|
f6359842a5 | ||
|
|
a35f9c7e35 | ||
|
|
05dbe644d1 | ||
|
|
7877d0e0a3 | ||
|
|
4063e1731e | ||
|
|
75ac98a3c8 | ||
|
|
258227dbe0 | ||
|
|
fc3273bbe0 | ||
|
|
ae0c86f11f | ||
|
|
46ba1d3228 | ||
|
|
35a0597471 | ||
|
|
c393958c52 | ||
|
|
be65a78b38 | ||
|
|
c8a0ace8b6 | ||
|
|
3033c394c7 | ||
|
|
2be718baba | ||
|
|
97a0d27d16 | ||
|
|
c52e87d52d | ||
|
|
35b4cd010a | ||
|
|
038102987e | ||
|
|
00fa85c164 | ||
|
|
08df88d6ec | ||
|
|
01aeb2147d | ||
|
|
1063db45d8 | ||
|
|
194214af76 | ||
|
|
2dd8f11ada | ||
|
|
bf4a364e08 | ||
|
|
fee4fb96f2 | ||
|
|
eb18ce3407 | ||
|
|
9fb1e6fc25 | ||
|
|
40f1d75bd2 | ||
|
|
b18de0904f | ||
|
|
de1d2a322c | ||
|
|
245a7d0ccb | ||
|
|
23f2197c53 | ||
|
|
9df2876c89 | ||
|
|
b63c738c22 | ||
|
|
bbacedd437 | ||
|
|
4096eca8a1 | ||
|
|
2f0f78bb8e | ||
|
|
62ba65397f | ||
|
|
79bf1e8c48 | ||
|
|
ffd65af086 | ||
|
|
119fa4ea03 | ||
|
|
40a7e6526d | ||
|
|
3d5070705c | ||
|
|
5a3b870d85 | ||
|
|
43bb575b6d | ||
|
|
50f5664683 | ||
|
|
8a520150f8 | ||
|
|
7e8063857b | ||
|
|
8ff5f22cf9 | ||
|
|
f5af82140b | ||
|
|
fc28a5fe33 | ||
|
|
5746f74ee7 | ||
|
|
d56cd10a6c | ||
|
|
6abb7cee76 | ||
|
|
afa1b01be1 | ||
|
|
c21b17255b | ||
|
|
dac3b35ba4 | ||
|
|
adbfbd97d3 | ||
|
|
952df213ee | ||
|
|
d7cb79aea5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Warn when attached images will be ignored because the selected model does not support image input: image thumbnails get a warning badge and the composer offers to switch to an image-capable model, instead of the images being silently dropped before the API call
|
||||
@@ -1,34 +1,33 @@
|
||||
---
|
||||
name: publish-extension
|
||||
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
|
||||
description: Use when releasing the Cline VS Code extension — stable (standalone SDK build of main via ext-vscode-publish-stable), nightly (ext-vscode-publish-nightly, manual dispatch), or an emergency legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, workflow dispatch, environment approvals, tagging, post-publish verification, and the remaining retirement of the finished A/B rollout machinery.
|
||||
---
|
||||
|
||||
# VS Code Extension Release
|
||||
|
||||
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
|
||||
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or an emergency legacy hotfix — or to retire the leftover A/B rollout machinery.
|
||||
|
||||
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
|
||||
|
||||
## The current era: combined A/B rollout
|
||||
## The current era: standalone SDK extension from `main`
|
||||
|
||||
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
|
||||
**The legacy → SDK migration is complete.** The PostHog flag `ext-sdk-bundle-rollout` reached 100% (verified empirically 2026-09-15: 200/200 `/decide` probes returned `true`), so every user on the combined VSIX runs the `next` (SDK, bun) bundle and the `legacy/` half is dead weight. Stable releases now ship a **plain build of `main`** through `ext-vscode-publish-stable.yml`. The combined A/B path (`ext-vscode-ab-package.yml`) is no longer used for releases and is pending deletion — see "Retiring the A/B machinery" at the bottom for what is still left to clean up and the one caveat (leave the flag at 100%).
|
||||
|
||||
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
|
||||
History, for context only: the A/B era ran `4.1.0` → `4.1.17` (Jul–Sep 2026). Design docs remain at `apps/vscode-rollout/README.md` and PR #12253 until that directory is removed.
|
||||
|
||||
### The listings and the workflows
|
||||
|
||||
| Channel | Marketplace ID | Workflow | Trigger | Version |
|
||||
|---|---|---|---|---|
|
||||
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
|
||||
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
|
||||
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
|
||||
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
|
||||
| **Stable** | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch, from `main` | `apps/vscode/package.json` on `main`; tag `v<version>` must match |
|
||||
| Nightly | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | **manual dispatch only** (cron deliberately removed) | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
|
||||
| Legacy hotfix (emergency only) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | `apps/vscode/package.json` on `legacy-extension` |
|
||||
|
||||
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish` → `Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
|
||||
Stable runs the reusable bun suite (`ext-vscode-test.yml`, tests `main`) before an environment-gated publish job (`publish` environment — required reviewers approve in the Actions UI). Nightly uses `PublishNightly` (branch policy only). Nightly **still builds the combined loader VSIX** (loader + `next/` + `legacy/`) until it is converted back to a plain build — that conversion is on the retirement list below.
|
||||
|
||||
## Golden rules (read before any release)
|
||||
|
||||
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
|
||||
1. **One listing, one version line.** `claude-dev` has been published from multiple workflows and branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
@@ -37,13 +36,123 @@ All three publish paths gate on tests before publishing: nightly and ab-package
|
||||
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
|
||||
```
|
||||
|
||||
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
|
||||
**`ext-vscode-publish-stable` has no marketplace-monotonicity preflight** (that check only ever lived in the retired ab-package workflow), so this query is the only guard. The `legacy-extension` branch sits at `4.0.12`, so it cannot collide with the `4.1.x` line, but a legacy hotfix would have to be numbered above the live stable version (see the emergency section).
|
||||
|
||||
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
|
||||
2. **Tag == package version, enforced.** The workflow reads `apps/vscode/package.json`, requires the `tag` input to equal `v<that version>`, and hard-fails otherwise. Bump the version on `main` first.
|
||||
|
||||
3. **Changelog lives at the repo ROOT** (`CHANGELOG.md`) — not `apps/vscode/CHANGELOG.md` (doesn't exist). The workflow hard-fails unless the first `## [` heading is exactly `## [<version>]`. The section body becomes the GitHub release notes and the Slack post (Slack copy is trimmed to 3000 chars with a link out; the release body stays whole).
|
||||
|
||||
4. **Ask before pushing** commits or tags. **Never approve the `publish` environment gate yourself via `gh api`** — hand the maintainer the run URL to click "Review deployments".
|
||||
|
||||
5. **Concurrency**: the workflow groups on the tag with `cancel-in-progress: false`. A publish run left `waiting` on approval blocks every later dispatch of the same tag until cancelled (`gh run cancel <id>`).
|
||||
|
||||
## Stable release — the current path
|
||||
|
||||
### Pre-flight
|
||||
|
||||
```bash
|
||||
# 1. What's live (rule 1) → pick <VERSION> strictly above it (normally patch bump).
|
||||
# 2. Confirm main's package.json is at the *previous* published version, i.e. the repo
|
||||
# reflects the live line and nothing unreleased is already bumped:
|
||||
node -p "require('./apps/vscode/package.json').version"
|
||||
# 3. What's in the release:
|
||||
git fetch origin main --tags
|
||||
git log v<PREV>..origin/main --oneline --no-merges -- apps/vscode sdk/packages
|
||||
```
|
||||
|
||||
The CLI/SDK notes are the best starting point for the extension notes — the extension bundles `@cline/*` from source, so an SDK release in the same window ships here too. Read `sdk/CHANGELOG.md` for the matching SDK version and translate what's extension-visible; skip CLI-only and desktop-only items.
|
||||
|
||||
### Release prep on `main` (PR, not direct push)
|
||||
|
||||
- Bump `apps/vscode/package.json` → `<VERSION>`.
|
||||
- Prepend `## [<VERSION>]` to root `CHANGELOG.md` with the approved notes.
|
||||
- Side effect of the bump: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
|
||||
|
||||
Optional local rehearsal of the exact packaging step (the workflow has no dry-run input, and the build runs inside the gated publish job, so this is the only pre-approval check):
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile && bun run build:sdk
|
||||
cd apps/vscode && npx @vscode/vsce package --no-dependencies --allow-package-secrets sendgrid --out /tmp/rehearsal.vsix
|
||||
# vsce runs vscode:prepublish → `bun run package` (check-types + build:webview + lint + esbuild --production),
|
||||
# i.e. the same build the workflow performs. Telemetry env is NOT set locally, so expect
|
||||
# telemetry to be dark in this artifact — that is fine for a build rehearsal, not for shipping.
|
||||
```
|
||||
|
||||
### Dispatch
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-stable.yml --ref main \
|
||||
-f release-type=release \
|
||||
-f auto_create_tag_from_main=true \
|
||||
-f tag=v<VERSION>
|
||||
gh run list --workflow=ext-vscode-publish-stable.yml --limit 1 --json databaseId,url,status
|
||||
```
|
||||
|
||||
What the run does, in order: test gate on `main` → publish job checks out `main`, **creates and pushes `v<VERSION>` at the tested SHA** (auto-create mode; refuses if the tag already exists elsewhere) → `bun install --frozen-lockfile` → `bun run build:sdk` → asserts the `better-sqlite3` native binary → verifies tag/version/changelog/PATs → `vsce package` → `bun run publish:marketplace` (vsce publish **and** `npx ovsx publish`, both `--no-dependencies`) → GitHub release with the `.vsix` attached → Slack post. The publish job waits for `publish` environment approval before any of that. Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
```
|
||||
|
||||
`release-type=pre-release` publishes to the pre-release channel of the same listing; it is a real publish, not a rehearsal.
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify both registries serve the new version (expect minutes-to-an-hour of Marketplace validation lag after "Published" appears in the logs):
|
||||
|
||||
```bash
|
||||
# Marketplace: query from rule 1
|
||||
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
|
||||
```
|
||||
|
||||
2. Verify the bookkeeping landed: `git fetch --tags && git tag --list 'v<VERSION>'`, `gh release view v<VERSION>`, Slack post in the release channel. Tag creation happens **before** the build in this workflow, so a tag-push failure fails the run early (no publish) rather than leaving a published-but-untagged release. Known cause: the built commit touches `.github/workflows/**` (default token cannot create such refs). Workaround: create and push the tag yourself at `origin/main` HEAD (ask first), then re-dispatch with `auto_create_tag_from_main=false` from `main` — the workflow requires the existing tag to point at the exact SHA it tested.
|
||||
|
||||
3. Artifact check (`gh run download <run-id>` or the release asset): `package.json` inside is `saoudrizwan.claude-dev@<VERSION>`; `grep -c 'process.env.TELEMETRY_SERVICE_API_KEY' extension/dist/extension.js` must be **0** (a leftover literal means the build ran without its env and telemetry is silently dead). `CLINE_ROLLOUT_VARIANT` is defined to `""` for ordinary builds by `apps/vscode/esbuild.mjs`, so no literal is expected and telemetry carries no `extension_variant` — correct for a standalone build.
|
||||
|
||||
4. Monitor errors on `extension_version = '<VERSION>'` in `otel.otel_logs` (stable cohort is cleanly separable — nightly versions are timestamps). Metabase dashboards 17 (task error rate) and 19 (error deep dive).
|
||||
|
||||
## Nightly release
|
||||
|
||||
**Manual dispatch only.** The cron was removed on purpose: the `PublishNightly` environment made scheduled runs sit `waiting`, hold the concurrency group, and silently cancel every later scheduled run behind them. A stale nightly listing is therefore expected, not a bug.
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
|
||||
```
|
||||
|
||||
No changelog/version prep — the version is computed. Verify with the Marketplace query against `saoudrizwan.cline-nightly`. Until converted, nightly still ships the combined loader VSIX with a `legacy/` bundle built from `legacy-extension`.
|
||||
|
||||
**Red run ≠ failed publish** on this path: its tag-push step runs *after* publishing and fails whenever main's HEAD touches `.github/workflows/**`. If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
|
||||
|
||||
## Emergency rollback
|
||||
|
||||
Preferred: **ship a fixed build of `main` at a higher version** through the stable workflow above. It is the same path, fully gated, and the only rollback that keeps users on the SDK extension.
|
||||
|
||||
Last resort — the legacy hotfix path — still exists but is degraded: `legacy-extension` is the pre-SDK npm codebase, last touched 2026-08-18 at `4.0.12`, and a publish from it would move every user back onto code that is weeks behind. If it is ever needed:
|
||||
|
||||
```bash
|
||||
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
|
||||
# live stable version (rule 1 — e.g. 4.1.18 live -> hotfix is 4.1.19, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main -f release-type=release
|
||||
# (the branch is hardcoded to legacy-extension in the workflow)
|
||||
```
|
||||
|
||||
The npm suite runs ungated, the publish job waits on the `publish` environment, the workflow tags and creates the GitHub release itself, and it publishes to Marketplace **and** Open VSX. On that branch use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
|
||||
|
||||
## Retiring the A/B machinery (still to do)
|
||||
|
||||
The rollout is done but the scaffolding is still in the repo. Retire it in this order, each as its own PR:
|
||||
|
||||
1. **Nightly → plain build of `main`**: drop the loader/`legacy-src` stitching from `ext-vscode-publish-nightly.yml` so nightly matches stable. Preserve the `|| 'default'` fallbacks for `inputs.*` while editing.
|
||||
2. Delete `ext-vscode-ab-package.yml` and, once the emergency path above is judged unnecessary, `ext-vscode-publish-legacy.yml`; keep the `legacy-extension` branch for history.
|
||||
3. Remove `apps/vscode-rollout/` and the rollout-only code paths in `apps/vscode/src/services/telemetry/rollout-metadata.ts` (the `extension_variant` metadata and `extension.rollout.bundle_activated` event).
|
||||
4. Optionally port the marketplace-monotonicity preflight from the old ab-package workflow into `ext-vscode-publish-stable.yml` — the only automated guard for rule 1 was retired with it.
|
||||
5. **Archive the PostHog flag last, and not yet.** Machines still on a combined VSIX (`≤ 4.1.17`) consult `ext-sdk-bundle-rollout` on every window load and treat a *deleted* flag as `legacy`. Leave it at 100% until `extension.rollout.bundle_activated` for combined versions flatlines, then archive. Re-verify the percentage empirically before touching it (no PostHog admin needed — the key is inlined in any shipped combined loader; download the 4.1.17 VSIX from the Marketplace `vspackage` URL, `gunzip`, `unzip`, `grep -o 'phc_[A-Za-z0-9]*' extension/extension.js`):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
|
||||
const KEY = process.argv[1];
|
||||
(async () => {
|
||||
let t = 0, n = 200;
|
||||
for (let i = 0; i < n; i += 20) {
|
||||
@@ -58,129 +167,15 @@ All three publish paths gate on tests before publishing: nightly and ab-package
|
||||
})()' "$KEY"
|
||||
```
|
||||
|
||||
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
|
||||
|
||||
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
|
||||
|
||||
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
|
||||
|
||||
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
|
||||
## Stable release (combined A/B VSIX) — the current stable path
|
||||
|
||||
### Pre-flight
|
||||
|
||||
```bash
|
||||
# 1. What's live, and what version comes next (must exceed it — rule 1)
|
||||
# 2. Flag percentage (rule 2) — decide where it should be for this release
|
||||
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
|
||||
git fetch origin main legacy-extension
|
||||
git log --oneline -3 origin/legacy-extension
|
||||
|
||||
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
|
||||
# hard-fails if views/viewsContainers/configuration diverged between branches.
|
||||
git show origin/main:apps/vscode/package.json > /tmp/next.json
|
||||
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
|
||||
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
|
||||
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
|
||||
```
|
||||
|
||||
Release prep on `main` (PR, not direct push):
|
||||
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
|
||||
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
|
||||
|
||||
### Dispatch
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f publish=true
|
||||
# (the legacy bundle always builds from the protected legacy-extension branch;
|
||||
# it is deliberately not an input)
|
||||
# publish=false builds an installable .vsix artifact without publishing and
|
||||
# needs NO environment approval — the ungated build job uploads the artifact
|
||||
# and the run completes.
|
||||
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
|
||||
```
|
||||
|
||||
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
```
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
|
||||
|
||||
```bash
|
||||
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
|
||||
```
|
||||
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
|
||||
```
|
||||
|
||||
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
### Known caveats of this path
|
||||
|
||||
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
|
||||
- A red run can still mean a successful publish on paths that tag (see Gotchas).
|
||||
|
||||
## Nightly release
|
||||
|
||||
Happens automatically (cron 12:00 UTC). Manual cut:
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
|
||||
gh run watch <run-id> --exit-status --interval 60
|
||||
```
|
||||
|
||||
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
|
||||
|
||||
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
|
||||
|
||||
## Legacy hotfix release (and emergency full rollback)
|
||||
|
||||
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
|
||||
|
||||
```bash
|
||||
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
|
||||
# highest version ever published to the listing (rule 1 — including combined
|
||||
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main \
|
||||
-f release-type=release
|
||||
# (the branch is hardcoded to legacy-extension in the workflow; it is
|
||||
# deliberately not an input)
|
||||
```
|
||||
|
||||
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
|
||||
|
||||
## Cutover: retiring the A/B machinery (the endgame)
|
||||
|
||||
When the next bundle has held at 100% long enough to trust:
|
||||
|
||||
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
|
||||
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
|
||||
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX.
|
||||
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
|
||||
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
|
||||
6. Update this skill: delete the combined-era sections and keep the standalone flow.
|
||||
6. Update this skill: delete this section and the combined-loader notes under Nightly.
|
||||
|
||||
## Gotchas index
|
||||
|
||||
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
|
||||
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
|
||||
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (the workflows handle this).
|
||||
- Every ext workflow pins `bun-version: 1.3.14` while the root `packageManager` is `bun@1.3.13`. This is consistent across all of them and has shipped fine — don't "fix" it in one workflow alone.
|
||||
- The publish job pins **Node 22** on purpose: Node 24 / npm 11 can make vsce's `npm list` detection fail with `ELSPROBLEMS` during packaging. `setup-bun` provides no Node runtime, and the publish scripts and `npx ovsx` need one.
|
||||
- `gh run watch --exit-status` has returned exit 0 on a failed run. Always confirm with `gh run view <id> --json status,conclusion` before acting on a result.
|
||||
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
|
||||
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group.
|
||||
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
|
||||
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; no publish workflow has an untrusted trigger surface.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and block their tag's concurrency group.
|
||||
- Open VSX has held a first-time publish in moderation before (logs say "Published", API 404s for hours). Verify with the API query rather than the log line.
|
||||
|
||||
@@ -154,6 +154,8 @@ jobs:
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_TRACES_EXPORTER: otlp
|
||||
CLINE_TRACE_RECORD_CONTENT: "true"
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
@@ -423,6 +425,8 @@ jobs:
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_TRACES_EXPORTER: otlp
|
||||
CLINE_TRACE_RECORD_CONTENT: "true"
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
name: desktop-publish
|
||||
|
||||
# Like ext-vscode-publish-nightly, nightlies are manually dispatched because
|
||||
# PublishDesktop requires approval. Dispatch from main with channel=nightly.
|
||||
# Nightlies upload Actions artifacts only; they do not publish releases or feeds.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_tag:
|
||||
description: "Existing release tag to publish, for example desktop-v0.1.0"
|
||||
required: true
|
||||
description: "Existing stable/beta tag; leave empty for nightly"
|
||||
required: false
|
||||
type: string
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm the desktop release.'
|
||||
@@ -18,8 +21,13 @@ on:
|
||||
options:
|
||||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
default: stable
|
||||
|
||||
concurrency:
|
||||
group: desktop-publish-${{ inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -32,10 +40,12 @@ jobs:
|
||||
name: Validate release tag
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ref: ${{ steps.version.outputs.ref }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
@@ -84,7 +94,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_tag }}
|
||||
ref: ${{ inputs.channel == 'nightly' && github.sha || inputs.git_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
@@ -103,6 +113,20 @@ jobs:
|
||||
# manifest landing on desktop-latest would auto-update every stable
|
||||
# install onto the beta. The stable regex rejects prerelease
|
||||
# suffixes for the same reason.
|
||||
if [ "$CHANNEL" = "nightly" ]; then
|
||||
if [ -n "$TAG" ]; then
|
||||
echo "nightly builds do not use a release tag; leave git_tag empty"
|
||||
exit 1
|
||||
fi
|
||||
BASE=$(node -p "require('./apps/examples/desktop-app/package.json').version.split('-')[0]")
|
||||
VERSION="${BASE}-nightly.$(date -u +%Y%m%d%H%M%S).${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}"
|
||||
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=nightly" >> "$GITHUB_OUTPUT"
|
||||
echo "product=Cline Nightly" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
@@ -155,6 +179,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ref=${HEAD_COMMIT}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
@@ -237,7 +262,22 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
ref: ${{ needs.validate.outputs.ref }}
|
||||
|
||||
- name: Stamp nightly version
|
||||
if: needs.validate.outputs.channel == 'nightly'
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
node <<'JS'
|
||||
const fs = require('node:fs');
|
||||
for (const file of ['apps/examples/desktop-app/package.json', 'apps/examples/desktop-app/src-tauri/tauri.conf.json']) {
|
||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
config.version = process.env.VERSION;
|
||||
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
JS
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -292,7 +332,7 @@ jobs:
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
|
||||
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
|
||||
# this step and inlines these values into the binary via `--define`
|
||||
@@ -319,6 +359,12 @@ jobs:
|
||||
# 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 }}
|
||||
# The DMG background, window size, and icon positions from
|
||||
# tauri.conf.json are applied by a Finder AppleScript in Tauri's
|
||||
# bundle_dmg.sh. When CI=true (always set on Actions) the bundler
|
||||
# passes --skip-jenkins and silently skips that script, shipping a
|
||||
# bare DMG. macOS runners have a GUI session, so opt out of the skip.
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
@@ -363,11 +409,15 @@ jobs:
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-(beta|nightly)/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-(latest|nightly)/latest.json"
|
||||
;;
|
||||
nightly)
|
||||
WANT=""
|
||||
FORBID="releases/download/desktop-(latest|beta|nightly)/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
@@ -380,22 +430,22 @@ jobs:
|
||||
# pipefail.
|
||||
found=0
|
||||
for bin in "$APP/Contents/MacOS/"*; do
|
||||
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
|
||||
if strings -a "$bin" | grep -E "$FORBID" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if strings -a "$bin" | grep "$WANT" >/dev/null; then
|
||||
if [ -n "$WANT" ] && strings -a "$bin" | grep "$WANT" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
if [ -n "$WANT" ] && [ "$found" -ne 1 ]; then
|
||||
echo "No binary in ${APP}/Contents/MacOS embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
echo "Updater endpoint verified: ${WANT:-disabled}"
|
||||
|
||||
# Guardrail: assert the telemetry config actually made it into the
|
||||
# compiled sidecar. Missing env on the build step (or a regression in
|
||||
@@ -459,6 +509,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-universal
|
||||
retention-days: 30
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -522,13 +573,59 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
ref: ${{ needs.validate.outputs.ref }}
|
||||
|
||||
- name: Stamp nightly version
|
||||
if: needs.validate.outputs.channel == 'nightly'
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
node <<'JS'
|
||||
const fs = require('node:fs');
|
||||
for (const file of ['apps/examples/desktop-app/package.json', 'apps/examples/desktop-app/src-tauri/tauri.conf.json']) {
|
||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
config.version = process.env.VERSION;
|
||||
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
JS
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# build-sidecar-bin.ts cross-compiles the Linux SSH remote helpers on
|
||||
# every platform, and the Windows installer ships them (they are Tauri
|
||||
# resources, bundle.resources in tauri.conf.json). Bun cross-compiles by
|
||||
# downloading the target's release zip and extracting the executable
|
||||
# from it, and on a Windows host that extraction fails deterministically
|
||||
# ("Failed to extract executable for 'bun-linux-x64-v1.3.13'. The
|
||||
# download may be incomplete."): the download and archive are fine, Bun
|
||||
# just never finds the `bun-linux-x64/bun` entry it needs. Bun only
|
||||
# downloads when the cache lacks `<target>-v<version>`, so seed the cache
|
||||
# with the same runtimes from npm (@oven/bun-<target>, byte-identical to
|
||||
# the zip payload) and the build never enters the broken path. The macOS
|
||||
# job does not need this; the extractor works there.
|
||||
- name: Seed Bun cache with Linux remote-helper runtimes
|
||||
shell: bash
|
||||
run: |
|
||||
BUN_VERSION="$(bun --version)"
|
||||
CACHE_DIR="${RUNNER_TEMP//\\//}/bun-install-cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
for target in linux-x64 linux-aarch64; do
|
||||
dest="$CACHE_DIR/bun-$target-v$BUN_VERSION"
|
||||
work="$(mktemp -d)"
|
||||
(cd "$work" && npm pack "@oven/bun-$target@$BUN_VERSION" --silent >/dev/null && tar -xzf ./*.tgz package/bin/bun)
|
||||
mv "$work/package/bin/bun" "$dest"
|
||||
if [ "$(head -c 4 "$dest" | od -An -c | tr -d ' ')" != "177ELF" ]; then
|
||||
echo "$dest is not an ELF executable; refusing to seed a bad runtime"
|
||||
exit 1
|
||||
fi
|
||||
echo "seeded $dest ($(stat -c %s "$dest") bytes)"
|
||||
done
|
||||
echo "BUN_INSTALL_CACHE_DIR=${CACHE_DIR}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
|
||||
with:
|
||||
@@ -594,7 +691,7 @@ jobs:
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry inlined into the sidecar at compile time, same as macOS.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -626,11 +723,15 @@ jobs:
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-(beta|nightly)/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-(latest|nightly)/latest.json"
|
||||
;;
|
||||
nightly)
|
||||
WANT=""
|
||||
FORBID="releases/download/desktop-(latest|beta|nightly)/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
@@ -640,22 +741,22 @@ jobs:
|
||||
|
||||
found=0
|
||||
for bin in src-tauri/target/release/*.exe; do
|
||||
if grep -a "$FORBID" "$bin" >/dev/null; then
|
||||
if grep -aE "$FORBID" "$bin" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if grep -a "$WANT" "$bin" >/dev/null; then
|
||||
if [ -n "$WANT" ] && grep -a "$WANT" "$bin" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
if [ -n "$WANT" ] && [ "$found" -ne 1 ]; then
|
||||
echo "No exe in src-tauri/target/release embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
echo "Updater endpoint verified: ${WANT:-disabled}"
|
||||
|
||||
# Same guardrail as the macOS job, run natively on the Windows sidecar.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
@@ -732,12 +833,14 @@ jobs:
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-windows-x64
|
||||
retention-days: 30
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build, build-windows]
|
||||
if: needs.validate.outputs.channel != 'nightly'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -745,7 +848,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
ref: ${{ needs.validate.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
@@ -823,15 +926,15 @@ jobs:
|
||||
- name: Get Previous Desktop Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
|
||||
SOURCE_REF: ${{ needs.validate.outputs.ref }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
# Stable compare links skip beta tags so they read stable -> stable;
|
||||
# beta compares against whatever shipped last on either channel.
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-beta*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-*' "$SOURCE_REF^" 2>/dev/null || echo "")
|
||||
else
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$SOURCE_REF^" 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -839,6 +942,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
target_commitish: ${{ needs.validate.outputs.ref }}
|
||||
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.
|
||||
@@ -878,10 +982,10 @@ jobs:
|
||||
fi
|
||||
|
||||
if ! gh release view "$FEED" >/dev/null 2>&1; then
|
||||
if [ "$CHANNEL" = "beta" ]; then
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop beta (auto-update feed)" \
|
||||
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
|
||||
--title "Cline desktop ${CHANNEL} (auto-update feed)" \
|
||||
--notes "Rolling release backing the ${CHANNEL} desktop app auto-updater. Only ${CHANNEL} installs poll this feed. Do not delete." \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
|
||||
@@ -284,6 +284,8 @@ jobs:
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_TRACES_EXPORTER: otlp
|
||||
CLINE_TRACE_RECORD_CONTENT: "true"
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
@@ -161,6 +161,8 @@ jobs:
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_TRACES_EXPORTER: otlp
|
||||
CLINE_TRACE_RECORD_CONTENT: "true"
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
@@ -272,6 +272,8 @@ jobs:
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_TRACES_EXPORTER: otlp
|
||||
CLINE_TRACE_RECORD_CONTENT: "true"
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
@@ -6,13 +6,13 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- "sdk/**"
|
||||
- "apps/examples/desktop-app/**"
|
||||
- ".github/workflows/sdk-test.yml"
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "sdk/**"
|
||||
- "apps/examples/desktop-app/**"
|
||||
- ".github/workflows/sdk-test.yml"
|
||||
workflow_call:
|
||||
|
||||
@@ -98,6 +98,16 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
|
||||
run: bun -F './sdk/packages/**' test
|
||||
|
||||
- name: Run Desktop Sidecar Tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'ubuntu-latest' }}
|
||||
run: |
|
||||
bun -F @cline/code test:sidecar
|
||||
bun -F @cline/code test:sidecar-startup
|
||||
|
||||
- name: Run Desktop Settings UI Tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
|
||||
run: bun -F @cline/code test:settings-ui
|
||||
|
||||
- name: Smoke test SQLite under Node
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -64,6 +64,9 @@ jobs:
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Build LLM package for desktop chat tests
|
||||
run: bun -F @cline/llms build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@ Emission ownership:
|
||||
|
||||
- `user.extension_activated`: emitted **once per host process** by host-specific helpers
|
||||
(`captureCliExtensionActivated` for the CLI, `captureExtensionActivated` for VS Code).
|
||||
- `workspace.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
|
||||
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
|
||||
- `workspace.initialized` / `workspace.init_error`: emitted by a de-duplicated emitter in
|
||||
`prepareLocalRuntimeBootstrap`. A dedicated host emits once per workspace; a shared Hub emits
|
||||
once per client surface and workspace. Hosts must NOT re-emit these.
|
||||
- `workspace.path_resolved`: emitted from default tool executors **only when**
|
||||
`WorkspaceManager` exposes more than one root.
|
||||
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
|
||||
@@ -104,6 +105,19 @@ hub-backed session, so the daemon must own its own `ITelemetryService`. It build
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
|
||||
The Hub transport forwards the serializable `ExtensionContext.client` and
|
||||
`ExtensionContext.user` values with session create/restore requests. The daemon wraps its
|
||||
process-owned service with `createClientScopedTelemetryService()` so lifecycle events use the
|
||||
originating client's `cline_type`, platform/version, and current account/organization without
|
||||
mutating the singleton shared by concurrent clients. Keep canonical task fields named
|
||||
`provider` and `model`; do not reintroduce host-specific aliases such as `apiProvider` or
|
||||
`modelId` for `task.created`, `task.restarted`, or `task.completed`.
|
||||
|
||||
`UserContext.distinctId` may be an anonymous machine ID. Set `UserContext.accountId` to the
|
||||
authenticated account ID (or `null` for an explicitly signed-out client) whenever a client
|
||||
forwards user context; this prevents machine IDs and stale daemon identity from becoming
|
||||
`user_id` / `organization_id` on task events.
|
||||
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.19]
|
||||
|
||||
### Added
|
||||
|
||||
- Images attached to a model that cannot read them are now flagged instead of silently discarded. Thumbnails get a warning badge and the composer explains that the images will be ignored, with a button to switch to an image-capable model. Previously the thumbnail looked normal and the image was replaced with a text placeholder just before the request, so there was no way to tell it had been dropped. Model info and the attachment picker also report image support accurately for models that declare text-only input without listing capabilities.
|
||||
|
||||
### Changed
|
||||
|
||||
- Cline's logo and icons have been refreshed throughout the extension, including the activity bar and panel icons.
|
||||
- The W&B Inference provider is now listed as CoreWeave, with its sign-up and API-key links updated to match CoreWeave's current documentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Long tasks now compact when they actually need to rather than running out of room. The trigger compared a character-based estimate of the conversation (~3 characters per token) against the model's context limit, so content that tokenizes far denser than that — disassembly, image dumps, minified sources — could fill the real context window while the estimate stayed under the threshold and compaction never fired; the turn was then squeezed down to a handful of output tokens. The trigger now also uses the token count the provider itself reports, and how much history is kept is scaled by how far off the estimate turned out to be. The summarizer's output budget also doubled, so a model that reasons before answering can no longer spend the whole budget thinking and return no summary at all.
|
||||
- On Windows, opening a repository that contains a file named `rg.exe`, `git.exe`, or `powershell.exe` no longer runs that file in place of the real program. Bare program names were resolved through the workspace directory before PATH, so a planted executable ran with your privileges as soon as the workspace was indexed. Cline now sets Windows' `NoDefaultCurrentDirectoryInExePath` opt-out at startup, in both the VS Code extension and the JetBrains core. Processes Cline launches inherit it, so inside a Command Prompt shell a program in the current directory now needs `.\` as it already does in PowerShell.
|
||||
- A model turn that fails mid-stream with a transient provider error is now retried up to three times with backoff instead of ending the task. A single rate-limit response forwarded by a gateway previously surfaced as a failed task. A turn that has already streamed output is never retried, so nothing is duplicated.
|
||||
- Terminal commands that succeed without printing anything (`git add -A` on a clean tree, for example) are now reported as empty output. They were treated as a shell-integration failure, which fed the model a snapshot of unrelated terminal scrollback prefixed with a warning that the output could not be captured, so silent commands intermittently looked like failures.
|
||||
- Checkpoints no longer re-hash every untracked file on each message. In workspaces holding large untracked directories this delayed every message by seconds to minutes; a persistent per-task index now lets git skip files it has already seen.
|
||||
- `run_commands` no longer hangs until its timeout after a command that backgrounds a child process. The command had finished, but the backgrounded process held the output pipes open.
|
||||
- `apply_patch` no longer silently overwrites an existing file when the model uses "Add File" on a path that already exists. The file's contents were replaced with no error and no record of what was lost.
|
||||
- PowerShell commands the model wrapped in another `powershell -Command "..."` are no longer parsed twice. The outer shell consumed `$_` before the inner command ran, so pipelines using it emitted an error for every item processed while still reporting success.
|
||||
- Opening your home directory as a workspace no longer drives the extension host to exhaustion. Typing an `@` mention indexed every file beneath it and re-ranked the whole index on each keystroke.
|
||||
- The "Supports Images" checkbox in OpenAI Compatible model settings now stays where you put it. The checkbox rendered the last committed value, and the re-sync that arrived during the save round-trip was re-emitted as a change event that wrote the old value back over your edit.
|
||||
- The error shown to the model when an `editor` call omits the text to replace now names the file and explains how to recover. The previous message was terse enough that some models re-sent the identical call until the task stopped.
|
||||
- Credentials are now stripped of invisible characters when saved, not only when pasted, and the cleanup covers AWS, GCP, and SAP fields and custom header values in addition to API keys.
|
||||
- Cline Pass now defaults to a model from your subscription rather than a free one. Its model list contains both tiers and the default was whichever model was published most recently, so a subscriber who never picked a model could be left on the free tier.
|
||||
- Cline Pass and free models now show no cost rather than the underlying market price, which is not what you are billed for.
|
||||
- Model pickers now fall back to the full Recommended, Free, and Subscribed lists when the models endpoint cannot be reached. The offline fallback was a short hardcoded list with no subscription tier at all.
|
||||
- Model lists for providers sharing the built-in catalog now refresh from the live catalog, so newly published models appear without an extension update, with timeouts so an unresponsive provider endpoint cannot stall the list.
|
||||
- Claude Code and OpenCode no longer ask for an API key they never read. Both authenticate from their own local CLI's credentials, but they were treated as key-based providers; the workaround was storing a dummy key. A missing CLI on `PATH` now warns rather than blocking, since a configured path or bundled binary also works.
|
||||
- The OpenAI Codex (ChatGPT subscription) model list no longer offers models the backend rejects, and Codex context limits are applied to every Codex model instead of being inherited from the OpenAI API catalog, which inflated both the context budget and the usage figures derived from it.
|
||||
- Models served by OpenCode Go are now sent over the wire protocol each one actually speaks. Every model was sent over the OpenAI chat-completions adapter, so models on that endpoint speaking other protocols failed or misbehaved.
|
||||
- Task history no longer goes blank when a task spawns many subagents. Subagent rows crowded out the tasks that created them, hiding the parent task and everything older.
|
||||
- Langfuse tracing, when configured, is now limited to the Cline and Cline Pass providers. The provider was ignored, so prompts and responses sent to third-party and bring-your-own-key providers were exported too.
|
||||
|
||||
### Changed
|
||||
|
||||
- Web search is now enabled by default on models that support it, outside YOLO mode. It can still be turned off in settings, and a settings file that cannot be read leaves it disabled rather than silently on.
|
||||
- The `run_commands` tool description now names the PowerShell edition in use — `Windows PowerShell (powershell.exe)` versus `PowerShell (pwsh.exe)` — quotes its guidance against that executable, and tells the model to run commands directly rather than wrapping them in another shell invocation. It also no longer describes the environment as Windows when `pwsh` is the configured shell on macOS or Linux.
|
||||
- Refreshed the built-in model catalog. Adds four providers (Infer by Flow7, Melious, NaN, and Wallaby) and takes the catalog from 5,788 to 6,079 models. This is a wide refresh: the resolved default model changes for 44 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Nebius, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go. Gemini and Vertex now resolve to Gemini 3.8 Flash, GitHub Copilot and Vivgrid to GPT-6 Astra, and NVIDIA to GLM 5.3 Flash. If you use a provider without pinning a model, expect a different default.
|
||||
|
||||
## [4.1.17]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Cline</h1>
|
||||
|
||||
<p align="center">
|
||||
The open source coding agent in your IDE and terminal.
|
||||
The open source coding agent in your IDE, terminal, & desktop.
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -44,7 +44,7 @@ The open source coding agent in your IDE and terminal.
|
||||
### CLI
|
||||
|
||||
Run Cline in your terminal.
|
||||
Interactive chat or fully headless
|
||||
Interactive chat or fully headless
|
||||
for CI/CD and scripting.
|
||||
|
||||
```
|
||||
@@ -57,17 +57,13 @@ npm i -g cline
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
|
||||
### Kanban
|
||||
### Desktop App
|
||||
|
||||
Run many agents in parallel from a
|
||||
web-based task board. Each card gets its own
|
||||
worktree, auto-commit, and dependency chains.
|
||||
Cline as a native app for macOS and Windows.
|
||||
Run agent sessions in any folder, schedule
|
||||
routines, and manage models, plugins, and MCP servers.
|
||||
|
||||
```
|
||||
npm i -g kanban
|
||||
```
|
||||
|
||||
<a href="https://github.com/cline/kanban">Learn more</a>
|
||||
<a href="https://cline.bot/desktop">Download for macOS and Windows</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -108,7 +104,7 @@ the JetBrains family.
|
||||
|
||||
### SDK
|
||||
|
||||
Build your own AI agents and integrations powered by the same engine that runs the CLI, Kanban, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
|
||||
Build your own AI agents and integrations powered by the same engine that runs the CLI, desktop app, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
|
||||
|
||||
```
|
||||
npm install @cline/sdk
|
||||
@@ -131,8 +127,8 @@ npm install @cline/sdk
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
|
||||
|
||||
## Edits Code Across Your Project
|
||||
@@ -186,7 +182,7 @@ const deployTool = createTool({
|
||||
|
||||
const agent = new Agent({ tools: [deployTool], /* ... */ })
|
||||
```
|
||||
...or use [MCP servers](https://github.com/modelcontextprotocol) to connect to databases, query APIs, manage cloud infrastructure, and interact with external systems. Use [community-built servers](https://github.com/modelcontextprotocol/servers) or ask Cline to create custom tools on the fly. In the CLI, manage servers with `cline mcp`.
|
||||
...or use [MCPs](https://github.com/modelcontextprotocol) to connect to databases, query APIs, manage cloud infrastructure, and interact with external systems. Use [community-built servers](https://github.com/modelcontextprotocol/servers) or ask Cline to create custom tools on the fly. In the CLI, manage servers with `cline mcp`.
|
||||
|
||||
## Multi-Agent Teams
|
||||
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.62
|
||||
|
||||
- Introducing Cline Desktop: a native app for working with open-weight models, with task import from Claude Code and Codex, scheduled runs, web search and voice input, and a marketplace for plugins, MCP servers, and skills. The CLI now shows a one-time startup notice pointing at cline.bot/desktop. At most one notice appears per launch, and `CLINE_DISABLE_CLINE_PASS_NOTICE=1` still suppresses all of them
|
||||
- Agent Plugins are now managed through the Hub. Packages under `~/.agents/plugins/*` are discovered and validated, their skills are exposed through the skills tool as `plugin-name:skill-name`, and their MCP servers start without touching `cline_mcp_settings.json`. The config screen lists them separately from Cline Plugins and Space toggles them. Workspace `.agents/plugins` directories are deliberately not scanned, so opening a repo cannot implicitly start repo-controlled MCP servers
|
||||
- A model turn that dies mid-stream with a transient provider error is now retried up to 3 times with backoff instead of failing the run — a single forwarded 429 from OpenRouter previously ended the run with exit 1. A turn that already streamed output is never retried, so nothing is duplicated
|
||||
- Streaming output is no longer throttled by the Hub. Every streamed token was proxied to client hooks as a round trip carrying a full copy of the session, with the agent loop waiting on it
|
||||
- Checkpoints no longer stall every message in workspaces with large untracked directories. Each turn re-hashed every untracked file before the model call, which on multi-GB workspaces blocked messages for seconds to minutes; a persistent per-session snapshot index now lets git skip unchanged files
|
||||
- Fixed `run_commands` hanging until timeout after a command that backgrounds a child process. The command had finished, but the backgrounded child held the output pipes open
|
||||
- Fixed `cline` being OOM-killed when run from your home directory. Typing an `@` mention indexed every file under `$HOME` and re-ranked it on each keystroke
|
||||
- Fixed `apply_patch` silently overwriting an existing file when the model used "Add File" on a path that already exists
|
||||
- Fixed PowerShell commands that the model wrapped in another `powershell -Command "..."` being parsed twice, which stripped `$_` out of pipelines and produced an error per enumerated item while still reporting success. The tool description now also names which PowerShell edition is in use and tells the model not to wrap commands
|
||||
- API keys pasted with an invisible character (BOM, zero-width space) are now cleaned before being saved. They were stored corrupted and the provider's 401 was indistinguishable from a wrong key
|
||||
- Signing in with Claude Code no longer demands an API key it never reads. It authenticates from the `claude` CLI's own credential store, but onboarding treated it as an API-key provider and dropped you into the sign-in wizard; the workaround was storing a dummy key. A missing `claude` on PATH now warns instead of blocking, since a configured path, bundled binary, or npx also works. OpenCode gets the same local-CLI treatment
|
||||
- Web search is now on by default in non-yolo sessions on models that support it
|
||||
- A running Hub on the same core version as your CLI no longer prompts you to update it. Anyone with both the desktop app and the CLI installed saw a "Cline Hub was updated" dialog on every launch that could never resolve
|
||||
- Scheduled runs no longer stall behind each other — one long turn blocked dispatch of every other schedule. Parallelism limits are now enforced at claim time, work resumed after system sleep is no longer started twice, and a schedule created without a timezone uses your local one instead of an implicit default
|
||||
- Session history no longer goes blank when a session spawns many subagents. Child rows crowded out the roots, hiding the parent session and everything older
|
||||
- Fixed TUI toasts being clipped to their first line. The Hub messages that use them are all longer than that, so the keep-Hub reminder never showed the `cline hub upgrade` command it exists to deliver
|
||||
- Cline Pass now defaults to a subscribed model instead of a free one. Its model list mixes both tiers and the default was whichever model shipped most recently, so subscribers who never picked a model were put on the free tier
|
||||
- Cline Pass and free models now show zero cost instead of the upstream market price for requests you are not billed per token for
|
||||
- Model pickers now fall back to the full Recommended, Free, and Subscribed tiers when the models endpoint is unreachable. Previously the offline fallback was six hardcoded models with no subscribed tier at all
|
||||
- The OpenAI Codex (ChatGPT subscription) model list no longer includes models the backend rejects, and Codex context limits are applied to every Codex model instead of being inherited from the OpenAI API catalog, which was inflating the context budget and the usage math
|
||||
- Model lists for all shared-catalog providers now refresh from the live catalog, so newly published models appear without a CLI update, with timeouts so a hung provider endpoint cannot stall the list
|
||||
- Cline Pass now shows as a configured provider after sign-in — it stores credentials under Cline, so one sign-in configured both but only Cline appeared
|
||||
- Fixed OpenCode Go serving several wire protocols behind one URL while every model was sent over the OpenAI chat-completions adapter
|
||||
- Collapsed a nested `undici@5.29.0` (CVE-2026-1525) onto 7.x. The earlier remediation's version-scoped override key was silently ignored by Bun, so a vulnerable copy survived
|
||||
- Refreshed the model catalog. Adds four providers (Infer by Flow7, Melious, NaN, and Wallaby) and takes the bundled catalog from 5,788 to 6,079 models. This is a wide refresh: the resolved default model changes for 44 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Nebius, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go. Gemini and Vertex now resolve to Gemini 3.8 Flash, GitHub Copilot and Vivgrid to GPT-6 Astra, and NVIDIA to GLM 5.3 Flash. If you use any provider without pinning a model, expect a different default
|
||||
|
||||
## 3.0.61
|
||||
|
||||
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
|
||||
|
||||
@@ -109,6 +109,12 @@ const result = await Bun.build({
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
),
|
||||
"process.env.OTEL_LOGS_EXPORTER": defineProcessEnv("OTEL_LOGS_EXPORTER"),
|
||||
"process.env.OTEL_TRACES_EXPORTER": defineProcessEnv(
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
),
|
||||
"process.env.CLINE_TRACE_RECORD_CONTENT": defineProcessEnv(
|
||||
"CLINE_TRACE_RECORD_CONTENT",
|
||||
),
|
||||
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": defineProcessEnv(
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.61",
|
||||
"version": "3.0.62",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -32,6 +32,9 @@ const BUILD_TIME_INLINED_ENV_VARS = [
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
"CLINE_TRACE_SAMPLE_PERCENT",
|
||||
"CLINE_TRACE_RECORD_CONTENT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { registerClineClientIdentity } from "../utils/cline-client-identity";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
export interface AcpModeOptions {
|
||||
@@ -11,6 +12,8 @@ export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
|
||||
);
|
||||
const { AcpAgent } = await import("./acpAgent");
|
||||
|
||||
registerClineClientIdentity("cline-acp");
|
||||
|
||||
writeDiagnostic("[acp] starting ACP mode over stdio…");
|
||||
|
||||
const stream = ndJsonStream(
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
|
||||
// Note: discord.js@14 declares undici ^6.27.0, but the root package.json
|
||||
// override ("undici": ">=7.29.0 <8") forces undici 7.x for CVE-2026-1525.
|
||||
import type { ChatStartSessionRequest } from "@cline/core";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isMainThread } from "node:worker_threads";
|
||||
import {
|
||||
claimHubDaemonProcess,
|
||||
claimSupervisedConnectorProcess,
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
disposeAll,
|
||||
initVcr,
|
||||
setConnectorCliLaunchSpec,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
cleanupActiveRuntime,
|
||||
isAbortInProgress,
|
||||
} from "./runtime/active-runtime";
|
||||
import { registerClineClientIdentity } from "./utils/cline-client-identity";
|
||||
import { resolveCliLaunchSpec } from "./utils/internal-launch";
|
||||
import { writeErr } from "./utils/output";
|
||||
|
||||
@@ -21,6 +23,9 @@ import { writeErr } from "./utils/output";
|
||||
// Set CLINE_VCR=record|playback and CLINE_VCR_CASSETTE=<path> to enable.
|
||||
initVcr(process.env.CLINE_VCR);
|
||||
|
||||
// Before any personality below can spawn a child with the workspace as cwd.
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (claimHubDaemonProcess()) {
|
||||
@@ -28,6 +33,7 @@ if (!isMainThread) {
|
||||
// daemon-hosted session spawns do not inherit it and try to become daemons.
|
||||
// The hub daemon owns its process-level abort handling. Installing the CLI's
|
||||
// fatal rejection handler first would make expected abort rejections exit it.
|
||||
registerClineClientIdentity("cline-cli");
|
||||
void import("@cline/core/hub/daemon-entry");
|
||||
} else {
|
||||
// Same reasoning as the daemon sentinel above: consume the supervised-connector
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useDialogPalette } from "../tui/hooks/use-theme";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
} from "../tui/utils/dialog-keys";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import open from "../utils/open";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
/**
|
||||
* Enter opens the subscription page; any other (unmodified) key dismisses the
|
||||
* Enter opens the notice's page; any other (unmodified) key dismisses the
|
||||
* dialog; modifier-held keys are ignored.
|
||||
*
|
||||
* The dialog used to be dismissible only with Esc, but Esc is the least
|
||||
@@ -20,7 +19,7 @@ import type { CliMigrationNotice } from "./notice";
|
||||
* timeout disambiguation, and Windows console input layers are known to
|
||||
* swallow it), which left users stuck behind the promo with no way out.
|
||||
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
|
||||
* subscription link never dismisses the dialog mid-click.
|
||||
* link never dismisses the dialog mid-click.
|
||||
*/
|
||||
export function resolveMigrationNoticeKeyAction(
|
||||
key: DialogDismissKey,
|
||||
@@ -36,27 +35,26 @@ export function MigrationNoticeContent(
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const palette = useDialogPalette();
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
const openSubscriptionPage = useCallback(() => {
|
||||
setStatus("Opening ClinePass in your browser...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
const openNoticePage = useCallback(() => {
|
||||
setStatus("Opening in your browser...");
|
||||
void open(notice.url, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened ClinePass in your browser.");
|
||||
setStatus("Opened in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus(
|
||||
"Could not open the browser automatically. Use the URL below.",
|
||||
);
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
}, [notice.url]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveMigrationNoticeKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "open") {
|
||||
openSubscriptionPage();
|
||||
openNoticePage();
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
@@ -66,20 +64,20 @@ export function MigrationNoticeContent(
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
ClinePass is a $9.99/month subscription plan to get access to the
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
{notice.body.split("\n").map((line) => (
|
||||
<text key={line} selectable>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
<a href={notice.url}>{notice.url}</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Open ClinePass</text>
|
||||
<text fg={palette.textOnSelection}>{notice.openLabel}</text>
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
|
||||
@@ -55,11 +55,42 @@ describe("migration notice", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show after the notice is marked as shown", () => {
|
||||
it("does not show after every notice is marked as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
markClineCliMigrationNoticeShown(dataDir, "cline-cli-desktop-launch");
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the desktop launch notice once the ClinePass intro was shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
const notice = getClineCliMigrationNotice(dataDir);
|
||||
expect(notice?.id).toBe("cline-cli-desktop-launch");
|
||||
expect(notice?.url).toBe("https://cline.bot/desktop");
|
||||
});
|
||||
|
||||
it("shows only one notice per launch", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
|
||||
"cline-cli-cline-pass-intro",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the desktop launch notice as shown by id", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
markClineCliMigrationNoticeShown(dataDir, "cline-cli-desktop-launch");
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain('"cline-cli-cline-pass-intro": true');
|
||||
expect(rawState).toContain('"cline-cli-desktop-launch": true');
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -85,7 +116,7 @@ describe("migration notice", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not show when ClinePass is already the active provider", () => {
|
||||
it("does not show the ClinePass intro when ClinePass is already the active provider", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
@@ -93,8 +124,8 @@ describe("migration notice", () => {
|
||||
dataDir,
|
||||
{},
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeUndefined();
|
||||
)?.id,
|
||||
).toBe("cline-cli-desktop-launch");
|
||||
});
|
||||
|
||||
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
|
||||
@@ -141,6 +172,8 @@ describe("migration notice", () => {
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain("cline-cli-cline-pass-intro");
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
expect(getClineCliMigrationNotice(dataDir)?.id).not.toBe(
|
||||
"cline-cli-cline-pass-intro",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
|
||||
const NOTICE_ID = "cline-cli-cline-pass-intro";
|
||||
export const CLINE_PASS_NOTICE_ID = "cline-cli-cline-pass-intro";
|
||||
const DESKTOP_NOTICE_ID = "cline-cli-desktop-launch";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
|
||||
// Historically named for the ClinePass promo; disables every startup notice.
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
|
||||
const DESKTOP_APP_URL = "https://cline.bot/desktop";
|
||||
|
||||
export interface CliMigrationNotice {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
openLabel: string;
|
||||
}
|
||||
|
||||
export interface CliMigrationNoticeOptions {
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
function getClinePassNotice(): CliMigrationNotice {
|
||||
return {
|
||||
id: CLINE_PASS_NOTICE_ID,
|
||||
title: "Try ClinePass",
|
||||
body: "ClinePass is a $9.99/month subscription plan to get access to the latest open-weight coding models with enough quota for day-to-day work, at a much lower cost than paying API costs directly.",
|
||||
url: getCliSubscriptionUrl(),
|
||||
openLabel: "Open ClinePass",
|
||||
};
|
||||
}
|
||||
|
||||
function getDesktopNotice(): CliMigrationNotice {
|
||||
return {
|
||||
id: DESKTOP_NOTICE_ID,
|
||||
title: "Introducing Cline Desktop",
|
||||
body: [
|
||||
"A native app for working with open weights models. Use it with ClinePass and our free models, or BYOK.",
|
||||
"- Import tasks from Claude Code and Codex",
|
||||
"- Run Cline on a regular schedule",
|
||||
"- Use web search tool and voice input",
|
||||
"- Browse Marketplace for plugins, MCPs, and skills",
|
||||
"Available for macOS and Windows.",
|
||||
].join("\n"),
|
||||
url: DESKTOP_APP_URL,
|
||||
openLabel: "Get Cline Desktop",
|
||||
};
|
||||
}
|
||||
|
||||
interface CliNoticeState {
|
||||
shown: Record<string, boolean>;
|
||||
}
|
||||
@@ -84,32 +118,33 @@ export function getClineCliMigrationNotice(
|
||||
if (disableNotice && !forceNotice) {
|
||||
return undefined;
|
||||
}
|
||||
// At most one notice per launch, oldest first, so a user who has already
|
||||
// dismissed the ClinePass intro sees the desktop launch on their next start.
|
||||
if (
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(
|
||||
!shouldSuppressClineCliMigrationNoticeForActiveProvider(
|
||||
options.activeProviderId,
|
||||
env,
|
||||
)
|
||||
) &&
|
||||
(forceNotice || !noticeState.shown[CLINE_PASS_NOTICE_ID])
|
||||
) {
|
||||
return undefined;
|
||||
return getClinePassNotice();
|
||||
}
|
||||
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
|
||||
return undefined;
|
||||
if (!noticeState.shown[DESKTOP_NOTICE_ID]) {
|
||||
return getDesktopNotice();
|
||||
}
|
||||
return {
|
||||
id: NOTICE_ID,
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function markClineCliMigrationNoticeShown(
|
||||
dataDir = resolveClineDataDir(),
|
||||
noticeId = CLINE_PASS_NOTICE_ID,
|
||||
): void {
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
const noticeState = readNoticeState(noticePath);
|
||||
const nextState: CliNoticeState = {
|
||||
shown: {
|
||||
...noticeState.shown,
|
||||
[NOTICE_ID]: true,
|
||||
[noticeId]: true,
|
||||
},
|
||||
};
|
||||
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -165,8 +165,14 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", async () => {
|
||||
// Keep dispatch tests independent of the full SDK runtime import graph.
|
||||
// Only persisted-settings behavior needs its real implementation here.
|
||||
const { readGlobalSettings } = await vi.importActual<
|
||||
typeof import("../../../sdk/packages/core/src/services/global-settings")
|
||||
>("../../../sdk/packages/core/src/services/global-settings");
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
readGlobalSettings,
|
||||
setSdkLogger: vi.fn(),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
@@ -787,6 +793,9 @@ describe("runCli lightweight command dispatch", () => {
|
||||
const notice = {
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
body: "ClinePass body",
|
||||
url: "https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
openLabel: "Open ClinePass",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
@@ -810,7 +819,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
await options?.onInitialNoticeShown?.(notice);
|
||||
expect(
|
||||
migrationNoticeMocks.markClineCliMigrationNoticeShown,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
).toHaveBeenCalledWith(undefined, notice.id);
|
||||
});
|
||||
|
||||
it("passes the active ClinePass provider into the migration notice gate", async () => {
|
||||
@@ -1171,6 +1180,9 @@ describe("runCli lightweight command dispatch", () => {
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
body: "ClinePass body",
|
||||
url: "https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
openLabel: "Open ClinePass",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import type { TuiStartupTarget } from "./tui/types";
|
||||
import { filterChatModels } from "./utils/chat-models";
|
||||
import { registerClineClientIdentity } from "./utils/cline-client-identity";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
@@ -146,6 +147,7 @@ function startupTargetTakesPrecedenceOverMigrationNotice(
|
||||
}
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
registerClineClientIdentity("cline-cli");
|
||||
installStreamErrorGuards();
|
||||
autoUpdateOnStartup();
|
||||
|
||||
@@ -1198,8 +1200,8 @@ export async function runCli(): Promise<void> {
|
||||
activeProviderId: provider,
|
||||
});
|
||||
if (initialNotice) {
|
||||
markInitialNoticeShown = () => {
|
||||
markClineCliMigrationNoticeShown();
|
||||
markInitialNoticeShown = (notice) => {
|
||||
markClineCliMigrationNoticeShown(undefined, notice.id);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
@@ -158,38 +160,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;
|
||||
@@ -213,16 +183,12 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
const accountContext = resolveClineAccountTelemetryIdentity(user);
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
persistClineAccountTelemetryIdentity(
|
||||
new ProviderSettingsManager(),
|
||||
accountContext,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -14,27 +14,6 @@ export function resolveHubUpdateRequiredKeyAction(
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human phrase for the live work an outdated Hub is serving, used by the
|
||||
* "Hub update required" dialog. Falls back to an unquantified phrase when the
|
||||
* Hub could not answer the activity query.
|
||||
*/
|
||||
export function describeOutdatedHubSessions(counts: {
|
||||
activeSessionCount?: number;
|
||||
participantClientCount?: number;
|
||||
}): string {
|
||||
const sessions = counts.activeSessionCount;
|
||||
if (typeof sessions !== "number" || sessions <= 0) {
|
||||
return "active sessions from other Cline clients";
|
||||
}
|
||||
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
|
||||
const clients = counts.participantClientCount;
|
||||
if (typeof clients !== "number" || clients <= 0) {
|
||||
return sessionsPhrase;
|
||||
}
|
||||
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo and sandbox sessions force the local backend and never attach to the
|
||||
* shared managed Hub (see the forceLocalBackend condition in the interactive
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import { describeOutdatedHubSessions } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
resolveHubUpdateRequiredKeyAction,
|
||||
} from "./hub-update-required-helpers";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -15,11 +16,10 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
checkLocalCliInstalled,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
import { getProviderSection } from "../../utils/provider-sections";
|
||||
import { canContinueLocalCliSetup } from "../../views/onboarding/model";
|
||||
import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
@@ -82,7 +83,7 @@ export function ProviderPickerContent(
|
||||
// just a model id and base URL) still render as configured.
|
||||
isConfigured: p.enabled === true,
|
||||
isOAuth: isOAuthProvider(p.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(p.id),
|
||||
isLocalAuth: isLocalAuthProvider(p.id),
|
||||
capabilities: p.capabilities,
|
||||
}));
|
||||
setProviders(providerItems);
|
||||
@@ -654,29 +655,25 @@ export function ProviderConfigInputContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexCliStatusContent(
|
||||
export function LocalCliStatusContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
cli?: ProviderLocalCli;
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const { resolve, dismiss, dialogId, cli, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [status, setStatus] = useState<LocalCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!cli) return;
|
||||
setStatus(undefined);
|
||||
setChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
checkLocalCliInstalled(cli)
|
||||
.then(setStatus)
|
||||
.catch((error: unknown) => {
|
||||
setStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => setChecking(false));
|
||||
}, []);
|
||||
}, [cli]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -691,7 +688,7 @@ export function CodexCliStatusContent(
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" && status?.installed) {
|
||||
if (key.name === "return" && canContinueLocalCliSetup(cli, status)) {
|
||||
resolve(true);
|
||||
}
|
||||
}, dialogId);
|
||||
@@ -702,31 +699,37 @@ export function CodexCliStatusContent(
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
{checking && <text fg="gray">Checking for Codex CLI...</text>}
|
||||
{checking && <text fg="gray">Checking for {providerName}...</text>}
|
||||
|
||||
{status?.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={palette.success}>
|
||||
{"\u25cf"} {providerName} installed
|
||||
</text>
|
||||
<text fg="gray">{status.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{status && !status.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="yellow">{providerName} was not found</text>
|
||||
<text fg="gray">{status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
{cli?.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {providerName} from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{status?.installed
|
||||
{cli
|
||||
? "Enter to continue, R to recheck, Esc to go back"
|
||||
: "R to recheck, Esc to go back"}
|
||||
: "Enter to continue, Esc to go back"}
|
||||
</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function QueuedPrompts(props: {
|
||||
? "Waiting. ↑/↓ navigate, Tab edit, Esc cancels turn"
|
||||
: `Steered next. ↑/↓ navigate, Tab edit, ${escapeHint}`
|
||||
: `↑/↓ navigate, Enter steer, Tab edit, ${escapeHint}`
|
||||
: "↑ steer or edit messages";
|
||||
: "Enter with empty input to steer first · ↑ select or edit";
|
||||
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -23,6 +23,9 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
};
|
||||
const availableWidth = Math.max(1, width - 4);
|
||||
const maxWidth = Math.min(44, availableWidth);
|
||||
// Border and horizontal padding take four columns. An explicit width (not
|
||||
// maxWidth) is what makes the text wrap instead of clipping at the edge.
|
||||
const boxWidth = Math.min(maxWidth, props.toast.message.length + 4);
|
||||
const right = width < 32 ? 0 : 2;
|
||||
const color = variantColor[props.toast.variant];
|
||||
|
||||
@@ -32,7 +35,7 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
zIndex={100}
|
||||
top={1}
|
||||
right={right}
|
||||
maxWidth={maxWidth}
|
||||
width={boxWidth}
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={color}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
|
||||
import { getLocalCliInfo } from "../../utils/local-cli";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
@@ -20,8 +20,8 @@ import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
LocalCliStatusContent,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type ThinkingLevel,
|
||||
ThinkingLevelContent,
|
||||
} from "../components/model-selector/model-selector";
|
||||
import { resolveProviderSetupRoute } from "../views/onboarding/model";
|
||||
|
||||
export interface OpenModelSelectorOptions {
|
||||
onCancel?: () => Promise<void> | void;
|
||||
@@ -178,6 +179,9 @@ async function runProviderChange(
|
||||
async () => await getProviderDisplayName(newProviderId),
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
const needsLocalCliSetup =
|
||||
resolveProviderSetupRoute(newProviderId) === "local_cli";
|
||||
const localCliProvider = getLocalCliInfo(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.
|
||||
@@ -246,12 +250,16 @@ async function runProviderChange(
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
} else if (needsLocalCliSetup) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<CodexCliStatusContent {...ctx} providerName={displayName} />
|
||||
<LocalCliStatusContent
|
||||
{...ctx}
|
||||
cli={localCliProvider}
|
||||
providerName={displayName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { KeyEvent } from "@opentui/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useRootKeyboard } from "./use-root-keyboard";
|
||||
import { shouldHandleInputHistory } from "./root-keyboard-routing";
|
||||
|
||||
describe("root keyboard input history routing", () => {
|
||||
@@ -29,3 +31,58 @@ describe("root keyboard input history routing", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const keyboard = vi.hoisted(() => ({ handle: (_key: KeyEvent) => {} }));
|
||||
vi.mock("@opentui/react", () => ({
|
||||
useKeyboard: (handle: (key: KeyEvent) => void) => {
|
||||
keyboard.handle = handle;
|
||||
},
|
||||
}));
|
||||
vi.mock("react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react")>()),
|
||||
useRef: (current: unknown) => ({ current }),
|
||||
}));
|
||||
vi.mock("../contexts/session-context", () => ({
|
||||
useSession: () => ({ isRunning: true, isExitRequested: false }),
|
||||
}));
|
||||
|
||||
describe("empty-input queue steering", () => {
|
||||
it.each([
|
||||
{ text: "", shift: false, promote: true },
|
||||
{ text: " ", shift: false, promote: true },
|
||||
{ text: "new draft", shift: false, promote: false },
|
||||
{ text: "", shift: true, promote: false },
|
||||
])("routes Enter with $text and shift=$shift", ({ text, shift, promote }) => {
|
||||
const promotePrompt = vi.fn();
|
||||
useRootKeyboard({
|
||||
isDialogOpen: false,
|
||||
appView: "chat",
|
||||
autocomplete: { mode: false },
|
||||
transcriptScrollRef: { current: null },
|
||||
getCurrentInputText: () => text,
|
||||
queuedPromptSelection: {
|
||||
items: [
|
||||
{ id: "first", prompt: "first", steer: false, attachmentCount: 0 },
|
||||
{ id: "second", prompt: "second", steer: false, attachmentCount: 0 },
|
||||
],
|
||||
selectedId: null,
|
||||
editingId: null,
|
||||
promote: promotePrompt,
|
||||
},
|
||||
} as unknown as Parameters<typeof useRootKeyboard>[0]);
|
||||
const preventDefault = vi.fn();
|
||||
keyboard.handle({
|
||||
name: "return",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift,
|
||||
repeated: false,
|
||||
preventDefault,
|
||||
} as unknown as KeyEvent);
|
||||
expect(promotePrompt).toHaveBeenCalledTimes(promote ? 1 : 0);
|
||||
if (promote) {
|
||||
expect(promotePrompt).toHaveBeenCalledWith("first");
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +209,22 @@ export function useRootKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasInputText &&
|
||||
hasQueuedPrompts &&
|
||||
!key.shift &&
|
||||
!key.ctrl &&
|
||||
!key.meta &&
|
||||
(key.name === "enter" || key.name === "return")
|
||||
) {
|
||||
key.preventDefault();
|
||||
const firstPrompt = queuedSelection.items[0];
|
||||
if (firstPrompt && !firstPrompt.steer && !key.repeated) {
|
||||
queuedSelection.promote(firstPrompt.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "up" && canHandleInputHistory) {
|
||||
if (
|
||||
input.inputHistory.navigateHistory("up", input.inputValueRef.current)
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
useDialogState,
|
||||
} from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
|
||||
import {
|
||||
CLINE_PASS_NOTICE_ID,
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider,
|
||||
} from "../kanban-migration/notice";
|
||||
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
|
||||
import {
|
||||
isSameRepoStatus,
|
||||
@@ -552,6 +555,7 @@ function App(props: TuiProps) {
|
||||
if (initialNoticeShownRef.current) return;
|
||||
if (appView !== "home") return;
|
||||
if (
|
||||
notice.id === CLINE_PASS_NOTICE_ID &&
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
|
||||
) {
|
||||
initialNoticeShownRef.current = true;
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
getIndividualPlanFeatures,
|
||||
} from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
checkLocalCliInstalled,
|
||||
getLocalCliInfo,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
@@ -59,6 +60,7 @@ import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
type OnboardingStep,
|
||||
type ProviderEntry,
|
||||
type ReasoningEffort,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
type ThinkingLevel,
|
||||
toModelEntriesFromKnownModels,
|
||||
@@ -103,6 +106,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [authError, setAuthError] = useState("");
|
||||
const [activeProviderId, setActiveProviderId] = useState("");
|
||||
const [activeProviderName, setActiveProviderName] = useState("");
|
||||
const localCli = useMemo(
|
||||
() => getLocalCliInfo(activeProviderId),
|
||||
[activeProviderId],
|
||||
);
|
||||
const [byoFields, setByoFields] = useState<ProviderConfigFields["fields"]>(
|
||||
{},
|
||||
);
|
||||
@@ -110,10 +117,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
|
||||
const [byoFocusedField, setByoFocusedField] =
|
||||
useState<ProviderConfigFieldKey>("apiKey");
|
||||
const [codexCliStatus, setCodexCliStatus] = useState<
|
||||
CodexCliStatus | undefined
|
||||
const [localCliStatus, setLocalCliStatus] = useState<
|
||||
LocalCliStatus | undefined
|
||||
>();
|
||||
const [codexCliChecking, setCodexCliChecking] = useState(false);
|
||||
const [localCliChecking, setLocalCliChecking] = useState(false);
|
||||
const localCliProbeRef = useRef(0);
|
||||
const authAbortRef = useRef(false);
|
||||
|
||||
// Device code flow
|
||||
@@ -486,18 +494,23 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
.then(setCodexCliStatus)
|
||||
.catch((error: unknown) => {
|
||||
setCodexCliStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const refreshLocalCliStatus = useCallback((provider: ProviderLocalCli) => {
|
||||
// Probing spawns the provider's CLI, so a result can land long after the
|
||||
// user moved on. Two local-CLI providers share this single status, so an
|
||||
// unlabelled result could mark the selected provider ready off a probe of
|
||||
// the previous one (or block it off a stale failure). Only the newest
|
||||
// probe may write.
|
||||
const probeId = ++localCliProbeRef.current;
|
||||
const isCurrentProbe = () => localCliProbeRef.current === probeId;
|
||||
setLocalCliStatus(undefined);
|
||||
setLocalCliChecking(true);
|
||||
checkLocalCliInstalled(provider)
|
||||
.then((status) => {
|
||||
if (isCurrentProbe()) setLocalCliStatus(status);
|
||||
})
|
||||
.finally(() => setCodexCliChecking(false));
|
||||
.finally(() => {
|
||||
if (isCurrentProbe()) setLocalCliChecking(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
@@ -510,12 +523,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (provider.isLocalAuth || isOpenAICodexCliProvider(provider.id)) {
|
||||
if (resolveProviderSetupRoute(provider.id) === "local_cli") {
|
||||
setActiveProviderId(provider.id);
|
||||
setActiveProviderName(provider.name);
|
||||
setCodexCliStatus(undefined);
|
||||
setStep("codex_cli_setup");
|
||||
refreshCodexCliStatus();
|
||||
setStep("local_cli_setup");
|
||||
// Only providers that name a CLI have something to probe; the
|
||||
// rest reach the screen with readiness simply unknown.
|
||||
const localCliProvider = getLocalCliInfo(provider.id);
|
||||
if (localCliProvider) refreshLocalCliStatus(localCliProvider);
|
||||
return;
|
||||
}
|
||||
const config = getProviderConfigFields(provider.id);
|
||||
@@ -575,11 +590,17 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setByoFocusedField(firstField ?? "apiKey");
|
||||
setStep("byo_apikey");
|
||||
},
|
||||
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
|
||||
[providers, startOAuthFlow, refreshLocalCliStatus, providerSettingsManager],
|
||||
);
|
||||
|
||||
const saveCodexCliConfig = useCallback(() => {
|
||||
if (!codexCliStatus?.installed) {
|
||||
const recheckLocalCli = useCallback(() => {
|
||||
if (localCli) {
|
||||
refreshLocalCliStatus(localCli);
|
||||
}
|
||||
}, [localCli, refreshLocalCliStatus]);
|
||||
|
||||
const saveLocalCliConfig = useCallback(() => {
|
||||
if (!canContinueLocalCliSetup(localCli, localCliStatus)) {
|
||||
return;
|
||||
}
|
||||
saveLocalProviderSettings(providerSettingsManager, {
|
||||
@@ -588,7 +609,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
transitionToModelPicker(activeProviderId);
|
||||
}, [
|
||||
activeProviderId,
|
||||
codexCliStatus,
|
||||
localCli,
|
||||
localCliStatus,
|
||||
providerSettingsManager,
|
||||
transitionToModelPicker,
|
||||
]);
|
||||
@@ -798,13 +820,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
deviceAbortRef.current = true;
|
||||
},
|
||||
resetAuth,
|
||||
refreshCodexCliStatus,
|
||||
refreshLocalCliStatus: recheckLocalCli,
|
||||
startOAuthFlow,
|
||||
startDeviceCodeFlow,
|
||||
selectProvider,
|
||||
loadModelsForProvider,
|
||||
saveClineModelSelection,
|
||||
saveCodexCliConfig,
|
||||
saveLocalCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
saveThinkingLevel,
|
||||
@@ -820,8 +842,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
byoFields,
|
||||
byoFocusedField,
|
||||
byoValues,
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
localCli,
|
||||
localCliChecking,
|
||||
localCliStatus,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
@@ -860,7 +883,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providersLoading,
|
||||
recommendedLoading: recommended.loading,
|
||||
saveByoConfig,
|
||||
saveCodexCliConfig,
|
||||
saveLocalCliConfig,
|
||||
saveCustomModelId,
|
||||
selectedModelName,
|
||||
step,
|
||||
|
||||
@@ -51,13 +51,13 @@ export function useOnboardingKeyboard(input: {
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
refreshCodexCliStatus: () => void;
|
||||
refreshLocalCliStatus: () => void;
|
||||
startOAuthFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveLocalCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
saveThinkingLevel: (level: ThinkingLevel) => void;
|
||||
@@ -98,7 +98,7 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (input.step === "local_cli_setup") {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
@@ -227,13 +227,13 @@ export function useOnboardingKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (input.step === "local_cli_setup") {
|
||||
if (key.name === "r") {
|
||||
input.refreshCodexCliStatus();
|
||||
input.refreshLocalCliStatus();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
input.saveCodexCliConfig();
|
||||
input.saveLocalCliConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getLocalCliInfo } from "../../../utils/local-cli";
|
||||
|
||||
vi.mock("../../../utils/local-cli", () => ({
|
||||
getLocalCliInfo: () => undefined,
|
||||
}));
|
||||
|
||||
import {
|
||||
canContinueLocalCliSetup,
|
||||
getMainMenuOptions,
|
||||
getOAuthProviderLabel,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
@@ -77,6 +85,20 @@ describe("onboarding model helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the Claude Code provider as local auth", () => {
|
||||
expect(
|
||||
toProviderEntry({
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
models: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "claude-code",
|
||||
isOAuth: false,
|
||||
isLocalAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps model names and reasoning support strictly", () => {
|
||||
expect(
|
||||
toModelEntry({
|
||||
@@ -166,3 +188,31 @@ describe("onboarding model helpers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-auth setup routing", () => {
|
||||
// A provider can declare `local-auth` without naming a CLI we can probe.
|
||||
// Routing must follow the capability; the descriptor is only for probing.
|
||||
// Otherwise it falls through to the API-key form, which renders no fields
|
||||
// for a local-auth provider.
|
||||
it("routes a local-auth provider with no CLI descriptor to local setup", () => {
|
||||
expect(getLocalCliInfo("claude-code")).toBeUndefined();
|
||||
expect(resolveProviderSetupRoute("claude-code")).toBe("local_cli");
|
||||
});
|
||||
|
||||
it("routes OAuth and API-key providers unchanged", () => {
|
||||
expect(resolveProviderSetupRoute("anthropic")).toBe("api_key");
|
||||
});
|
||||
|
||||
// The probe only looks on PATH, while the runtime also accepts an explicit
|
||||
// pathToClaudeCodeExecutable and a bundled platform binary. A PATH miss
|
||||
// therefore means "not on PATH", not "unusable", so it must not block.
|
||||
it("lets the user continue when the CLI is not found on PATH", () => {
|
||||
const cli = { command: "claude", docsUrl: "https://example.invalid" };
|
||||
expect(
|
||||
canContinueLocalCliSetup(cli, {
|
||||
installed: false,
|
||||
reason: "The claude executable was not found on PATH.",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,14 @@ import type {
|
||||
ModelOperation,
|
||||
} from "@cline/shared";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
} from "../../../utils/provider-auth";
|
||||
|
||||
export type OnboardingStep =
|
||||
| "menu"
|
||||
@@ -13,7 +19,7 @@ export type OnboardingStep =
|
||||
| "device_code"
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "local_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
@@ -85,6 +91,35 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Which setup flow a provider needs. Keyed off how the provider authenticates,
|
||||
* so every caller routes the same way.
|
||||
*/
|
||||
export type ProviderSetupRoute = "oauth" | "local_cli" | "api_key";
|
||||
|
||||
export function resolveProviderSetupRoute(
|
||||
providerId: string,
|
||||
): ProviderSetupRoute {
|
||||
if (isOAuthProvider(providerId)) return "oauth";
|
||||
if (isLocalAuthProvider(providerId)) return "local_cli";
|
||||
return "api_key";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local-CLI setup screen lets the user connect.
|
||||
*/
|
||||
export function canContinueLocalCliSetup(
|
||||
_cli: ProviderLocalCli | undefined,
|
||||
_status: LocalCliStatus | undefined,
|
||||
): boolean {
|
||||
// The probe only looks on PATH, while the runtime also accepts an explicit
|
||||
// pathToClaudeCodeExecutable and a bundled platform binary, and Codex falls
|
||||
// back through `npx`. A PATH miss therefore means "not on PATH", not
|
||||
// "unusable", so the screen reports it without blocking — a provider that
|
||||
// really cannot start says so on the first turn, in its own words.
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMainMenuOptions(options?: {
|
||||
isClinePassEnabled?: boolean;
|
||||
}): MenuOption[] {
|
||||
@@ -174,7 +209,7 @@ export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isOAuth: isOAuthProvider(provider.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(provider.id),
|
||||
isLocalAuth: isLocalAuthProvider(provider.id),
|
||||
hasAuth:
|
||||
Boolean(provider.apiKey) || provider.oauthAccessTokenPresent === true,
|
||||
...(provider.capabilities ? { capabilities: provider.capabilities } : {}),
|
||||
|
||||
@@ -2,10 +2,10 @@ import "opentui-spinner/react";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
} from "../../../utils/codex-cli";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
ClineModelPicker,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -25,6 +25,7 @@ import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
@@ -362,18 +363,20 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingCodexCliScreen(props: {
|
||||
export function OnboardingLocalCliScreen(props: {
|
||||
activeProviderName: string;
|
||||
checking: boolean;
|
||||
cli?: ProviderLocalCli;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
mouse: MouseTrackerState;
|
||||
status?: CodexCliStatus;
|
||||
status?: LocalCliStatus;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
const installedStatus =
|
||||
props.status?.installed === true ? props.status : undefined;
|
||||
const canContinue = canContinueLocalCliSetup(props.cli, props.status);
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
@@ -386,31 +389,37 @@ export function OnboardingCodexCliScreen(props: {
|
||||
{props.checking && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">Checking for Codex CLI...</text>
|
||||
<text fg="gray">Checking for {props.activeProviderName}...</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{installedStatus && (
|
||||
<box flexDirection="column" gap={1} alignItems="center">
|
||||
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={colors.success}>
|
||||
{"\u25cf"} {props.activeProviderName} installed
|
||||
</text>
|
||||
<text fg="gray">{installedStatus.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{props.status && !props.status.installed && (
|
||||
{props.cli && props.status && !props.status.installed && (
|
||||
<box flexDirection="column" gap={1} width={props.contentWidth}>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="yellow">{props.activeProviderName} was not found</text>
|
||||
<text fg="gray">{props.status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
{props.cli.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {props.activeProviderName} from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{props.cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{installedStatus
|
||||
{canContinue
|
||||
? "Enter to continue, R to recheck, Esc to go back, Ctrl+C to exit"
|
||||
: "R to recheck, Esc to go back, Ctrl+C to exit"}
|
||||
</em>
|
||||
|
||||
@@ -7,10 +7,10 @@ import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
OnboardingDoneScreen,
|
||||
OnboardingLocalCliScreen,
|
||||
OnboardingMainMenuScreen,
|
||||
OnboardingModelPickerScreen,
|
||||
OnboardingOAuthPendingScreen,
|
||||
@@ -83,15 +83,16 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "codex_cli_setup") {
|
||||
if (state.step === "local_cli_setup" && state.localCli) {
|
||||
return (
|
||||
<OnboardingCodexCliScreen
|
||||
<OnboardingLocalCliScreen
|
||||
activeProviderName={state.activeProviderName}
|
||||
checking={state.codexCliChecking}
|
||||
checking={state.localCliChecking}
|
||||
cli={state.localCli}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
mouse={mouse}
|
||||
status={state.codexCliStatus}
|
||||
status={state.localCliStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { setClineClientIdentity } from "@cline/shared";
|
||||
import { getCliBuildInfo } from "./common";
|
||||
|
||||
export function registerClineClientIdentity(name: string): void {
|
||||
const { version } = getCliBuildInfo();
|
||||
setClineClientIdentity({
|
||||
name,
|
||||
version,
|
||||
platform: "cli",
|
||||
platformVersion: version,
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const OPENAI_CODEX_CLI_PROVIDER_ID = "openai-codex-cli";
|
||||
export const CODEX_CLI_INSTALL_URL = "https://developers.openai.com/codex/cli";
|
||||
|
||||
export type CodexCliStatus =
|
||||
| {
|
||||
installed: true;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function isOpenAICodexCliProvider(providerId: string): boolean {
|
||||
return providerId.trim().toLowerCase() === OPENAI_CODEX_CLI_PROVIDER_ID;
|
||||
}
|
||||
|
||||
export async function checkCodexCliInstalled(): Promise<CodexCliStatus> {
|
||||
try {
|
||||
const result = await execFileAsync("codex", ["--version"], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = (result.stdout || result.stderr).trim();
|
||||
return {
|
||||
installed: true,
|
||||
version: version || "codex",
|
||||
};
|
||||
} catch (error) {
|
||||
const details =
|
||||
error && typeof error === "object"
|
||||
? (error as { code?: unknown; message?: unknown })
|
||||
: undefined;
|
||||
const code = typeof details?.code === "string" ? details.code : "";
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
installed: false,
|
||||
reason: "The codex executable was not found on PATH.",
|
||||
};
|
||||
}
|
||||
const message =
|
||||
typeof details?.message === "string"
|
||||
? details.message
|
||||
: "Could not run codex --version.";
|
||||
return {
|
||||
installed: false,
|
||||
reason: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { setClineClientIdentity } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerClineClientIdentity } from "./cline-client-identity";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
setClineClientIdentity(undefined);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -39,6 +42,29 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies the CLI client on the free model request", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(JSON.stringify({ free: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
registerClineClientIdentity("cline-cli");
|
||||
|
||||
await shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||
"X-CLIENT-TYPE": "cline-cli",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { type AgentEvent, buildClineClientHeaders } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
@@ -37,6 +37,7 @@ async function fetchClineFreeModelIds(
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
headers: buildClineClientHeaders(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isLocalAuthProvider } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLocalCliInfo } from "./local-cli";
|
||||
|
||||
describe("local CLI providers", () => {
|
||||
it("reads the CLI a local-auth provider borrows credentials from", () => {
|
||||
expect(getLocalCliInfo("openai-codex-cli")).toEqual({
|
||||
command: "codex",
|
||||
docsUrl: "https://developers.openai.com/codex/cli",
|
||||
});
|
||||
expect(getLocalCliInfo("claude-code")).toEqual({
|
||||
command: "claude",
|
||||
docsUrl: "https://code.claude.com/docs/en/setup",
|
||||
});
|
||||
});
|
||||
|
||||
it("names no CLI for providers that authenticate with an API key", () => {
|
||||
expect(getLocalCliInfo("anthropic")).toBeUndefined();
|
||||
expect(getLocalCliInfo("openai-codex")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Routing is keyed off the capability alone, so a local-auth provider whose
|
||||
// credentials come from somewhere unprobeable still reaches the local setup
|
||||
// screen instead of an empty API-key form.
|
||||
it("routes on the capability, not on knowing a CLI", () => {
|
||||
expect(isLocalAuthProvider("claude-code")).toBe(true);
|
||||
expect(isLocalAuthProvider("openai-codex-cli")).toBe(true);
|
||||
expect(isLocalAuthProvider("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { Llms } from "@cline/core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type ProviderLocalCli = Llms.ProviderLocalCli;
|
||||
|
||||
export type LocalCliStatus =
|
||||
| {
|
||||
installed: true;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The CLI a `local-auth` provider borrows credentials from, as declared in
|
||||
* the provider catalog. `undefined` for providers that name none — those are
|
||||
* connected without a readiness check rather than probing a guessed command.
|
||||
*/
|
||||
export function getLocalCliInfo(
|
||||
providerId: string,
|
||||
): ProviderLocalCli | undefined {
|
||||
return Llms.resolveProviderLocalCli(providerId);
|
||||
}
|
||||
|
||||
export async function checkLocalCliInstalled(
|
||||
cli: ProviderLocalCli,
|
||||
): Promise<LocalCliStatus> {
|
||||
try {
|
||||
const result = await execFileAsync(cli.command, ["--version"], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = (result.stdout || result.stderr).trim();
|
||||
return {
|
||||
installed: true,
|
||||
version: version || cli.command,
|
||||
};
|
||||
} catch (error) {
|
||||
const details = error as NodeJS.ErrnoException | undefined;
|
||||
if (details?.code === "ENOENT") {
|
||||
return {
|
||||
installed: false,
|
||||
reason: `The ${cli.command} executable was not found on PATH.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: false,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Could not run ${cli.command} --version.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
@@ -21,7 +22,7 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
export { isOAuthProvider };
|
||||
export { isLocalAuthProvider, isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
|
||||
@@ -109,7 +109,9 @@ export async function handleDesktopCommand(
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
return await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
providerSettingsManager.getProviderConfig(provider, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
|
||||
@@ -106,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<script type="module" src="${devServerUrl}/@vite/client"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -85,7 +85,9 @@ export async function loadModels(
|
||||
if (!provider) return;
|
||||
const payload = await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
providerSettingsManager.getProviderConfig(provider, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter((model) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
|
||||
</svg>
|
||||
<svg width="113" height="113" viewBox="0 0 113 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M56.4998 8.99805L56.4999 5.29805H56.4998V8.99805ZM68.4998 20.708L72.1998 20.7081V20.708H68.4998ZM67.9929 25L64.3662 24.2671L63.4704 28.7H67.9929V25ZM84.4998 25L84.4998 21.3H84.4998V25ZM95.7 49.7998H92V51.2066L92.9346 52.258L95.7 49.7998ZM108.027 63.667L110.793 61.2088L110.792 61.2088L108.027 63.667ZM108.027 64.7305L110.792 67.1887L110.793 67.1883L108.027 64.7305ZM95.7 78.5977L92.9346 76.1394L92 77.1909V78.5977H95.7ZM95.7 92.2002L99.3999 92.2002V92.2002H95.7ZM84.4998 103.4V107.1H84.4998L84.4998 103.4ZM17.2996 92.2002H13.5996V92.2002L17.2996 92.2002ZM17.2996 78.5986H20.9996V77.1918L20.0649 76.1404L17.2996 78.5986ZM4.97144 64.7305L2.20602 67.1886L2.20611 67.1887L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L7.73685 66.1251L4.97144 63.667ZM17.2996 49.7979L20.065 52.256L20.9996 51.2046V49.7979H17.2996ZM45.0066 25V28.7H49.5292L48.6333 24.267L45.0066 25ZM44.4998 20.708H40.7998V20.7081L44.4998 20.708ZM56.4998 8.99805L56.4996 12.698C61.1812 12.6982 64.7998 16.3247 64.7998 20.708H68.4998H72.1998C72.1998 12.0452 65.0728 5.29829 56.4999 5.29805L56.4998 8.99805ZM68.4998 20.708L64.7998 20.7079C64.7997 21.6154 64.6614 22.8066 64.3662 24.2671L67.9929 25L71.6196 25.7329C71.969 24.004 72.1997 22.2864 72.1998 20.7081L68.4998 20.708ZM67.9929 25V28.7H84.4998V25V21.3H67.9929V25ZM84.4998 25L84.4997 28.7C88.6419 28.7001 92 32.0582 92 36.2002H95.7H99.3999C99.3999 27.9712 92.7287 21.3001 84.4998 21.3L84.4998 25ZM95.7 36.2002H92V49.7998H95.7H99.3999V36.2002H95.7ZM95.7 49.7998L92.9346 52.258L105.262 66.1252L108.027 63.667L110.792 61.2088L98.4653 47.3416L95.7 49.7998ZM108.027 63.667L105.262 66.1251C104.285 65.026 104.285 63.371 105.261 62.2727L108.027 64.7305L110.793 67.1883C112.308 65.4837 112.308 62.9141 110.793 61.2088L108.027 63.667ZM108.027 64.7305L105.262 62.2722L92.9346 76.1394L95.7 78.5977L98.4653 81.0559L110.792 67.1887L108.027 64.7305ZM95.7 78.5977H92V92.2002H95.7H99.3999V78.5977H95.7ZM95.7 92.2002L92 92.2002C91.9999 96.3422 88.6418 99.7003 84.4997 99.7004L84.4998 103.4L84.4998 107.1C92.7287 107.1 99.3999 100.429 99.3999 92.2002L95.7 92.2002ZM84.4998 103.4V99.7004H28.4998V103.4V107.1H84.4998V103.4ZM28.4998 103.4V99.7004C24.3577 99.7004 20.9996 96.3423 20.9996 92.2002L17.2996 92.2002L13.5996 92.2002C13.5996 100.429 20.2707 107.1 28.4998 107.1V103.4ZM17.2996 92.2002H20.9996V78.5986H17.2996H13.5996V92.2002H17.2996ZM17.2996 78.5986L20.0649 76.1404L7.73676 62.2722L4.97144 64.7305L2.20611 67.1887L14.5342 81.0569L17.2996 78.5986ZM4.97144 64.7305L7.73685 62.2723C8.71353 63.3711 8.71353 65.0264 7.73685 66.1251L4.97144 63.667L2.20602 61.2088C0.690476 62.9138 0.690476 65.4836 2.20602 67.1886L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L20.065 52.256L17.2996 49.7979L14.5341 47.3397L2.20602 61.2088L4.97144 63.667ZM17.2996 49.7979H20.9996V36.2002H17.2996H13.5996V49.7979H17.2996ZM17.2996 36.2002H20.9996C20.9996 32.0581 24.3576 28.7 28.4998 28.7V25V21.3C20.2707 21.3 13.5996 27.9712 13.5996 36.2002H17.2996ZM28.4998 25V28.7H45.0066V25V21.3H28.4998V25ZM45.0066 25L48.6333 24.267C48.3381 22.8066 48.1998 21.6154 48.1998 20.7079L44.4998 20.708L40.7998 20.7081C40.7998 22.2864 41.0305 24.0039 41.3799 25.733L45.0066 25ZM44.4998 20.708H48.1998C48.1998 16.3244 51.8182 12.698 56.4998 12.698V8.99805V5.29805C47.9264 5.29805 40.7998 12.0451 40.7998 20.708H44.4998Z" fill="#1C1C24"/>
|
||||
<rect x="66.1001" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
|
||||
<rect x="37.3" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 957 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,16 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="92px" height="96px" viewBox="0 0 92 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Group Copy 2</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="icon-copy" transform="translate(-34, -40)" fill="#24292F">
|
||||
<g id="Group-Copy-2" transform="translate(34, 40.5)">
|
||||
<g id="Group-3-Copy-4" transform="translate(0, 0)">
|
||||
<path d="M65.4492701,16.3 C76.3374701,16.3 85.1635558,25.16479 85.1635558,36.1 L85.1635558,42.7 L90.9027661,54.1647464 C91.4694141,55.2966923 91.4668177,56.6300535 90.8957658,57.7597839 L85.1635558,69.1 L85.1635558,75.7 C85.1635558,86.63554 76.3374701,95.5 65.4492701,95.5 L26.0206986,95.5 C15.1328272,95.5 6.30641291,86.63554 6.30641291,75.7 L6.30641291,69.1 L0.448507752,57.7954874 C-0.14693501,56.6464093 -0.149634367,55.2802504 0.441262896,54.1288283 L6.30641291,42.7 L6.30641291,36.1 C6.30641291,25.16479 15.1328272,16.3 26.0206986,16.3 L65.4492701,16.3 Z M62.9301895,22 L29.189529,22 C19.8723267,22 12.3191987,29.5552188 12.3191987,38.875 L12.3191987,44.5 L7.44288578,53.9634655 C6.84794449,55.1180686 6.85066096,56.4896598 7.45017099,57.6418974 L12.3191987,67 L12.3191987,72.625 C12.3191987,81.9450625 19.8723267,89.5 29.189529,89.5 L62.9301895,89.5 C72.2476729,89.5 79.8005198,81.9450625 79.8005198,72.625 L79.8005198,67 L84.5682187,57.6061395 C85.1432011,56.473244 85.1458141,55.1345713 84.5752587,53.9994398 L79.8005198,44.5 L79.8005198,38.875 C79.8005198,29.5552188 72.2476729,22 62.9301895,22 Z" id="Combined-Shape" fill-rule="nonzero"></path>
|
||||
<circle id="Oval" cx="45.7349843" cy="11" r="11"></circle>
|
||||
</g>
|
||||
<rect id="Rectangle-Copy" stroke="#24292F" stroke-width="8" x="31" y="44.5" width="5" height="22" rx="2.5"></rect>
|
||||
<rect id="Rectangle-Copy-2" stroke="#24292F" stroke-width="8" x="55" y="44.5" width="5" height="22" rx="2.5"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg width="113" height="113" viewBox="0 0 113 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M56.4998 8.99805L56.4999 5.29805H56.4998V8.99805ZM68.4998 20.708L72.1998 20.7081V20.708H68.4998ZM67.9929 25L64.3662 24.2671L63.4704 28.7H67.9929V25ZM84.4998 25L84.4998 21.3H84.4998V25ZM95.7 49.7998H92V51.2066L92.9346 52.258L95.7 49.7998ZM108.027 63.667L110.793 61.2088L110.792 61.2088L108.027 63.667ZM108.027 64.7305L110.792 67.1887L110.793 67.1883L108.027 64.7305ZM95.7 78.5977L92.9346 76.1394L92 77.1909V78.5977H95.7ZM95.7 92.2002L99.3999 92.2002V92.2002H95.7ZM84.4998 103.4V107.1H84.4998L84.4998 103.4ZM17.2996 92.2002H13.5996V92.2002L17.2996 92.2002ZM17.2996 78.5986H20.9996V77.1918L20.0649 76.1404L17.2996 78.5986ZM4.97144 64.7305L2.20602 67.1886L2.20611 67.1887L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L7.73685 66.1251L4.97144 63.667ZM17.2996 49.7979L20.065 52.256L20.9996 51.2046V49.7979H17.2996ZM45.0066 25V28.7H49.5292L48.6333 24.267L45.0066 25ZM44.4998 20.708H40.7998V20.7081L44.4998 20.708ZM56.4998 8.99805L56.4996 12.698C61.1812 12.6982 64.7998 16.3247 64.7998 20.708H68.4998H72.1998C72.1998 12.0452 65.0728 5.29829 56.4999 5.29805L56.4998 8.99805ZM68.4998 20.708L64.7998 20.7079C64.7997 21.6154 64.6614 22.8066 64.3662 24.2671L67.9929 25L71.6196 25.7329C71.969 24.004 72.1997 22.2864 72.1998 20.7081L68.4998 20.708ZM67.9929 25V28.7H84.4998V25V21.3H67.9929V25ZM84.4998 25L84.4997 28.7C88.6419 28.7001 92 32.0582 92 36.2002H95.7H99.3999C99.3999 27.9712 92.7287 21.3001 84.4998 21.3L84.4998 25ZM95.7 36.2002H92V49.7998H95.7H99.3999V36.2002H95.7ZM95.7 49.7998L92.9346 52.258L105.262 66.1252L108.027 63.667L110.792 61.2088L98.4653 47.3416L95.7 49.7998ZM108.027 63.667L105.262 66.1251C104.285 65.026 104.285 63.371 105.261 62.2727L108.027 64.7305L110.793 67.1883C112.308 65.4837 112.308 62.9141 110.793 61.2088L108.027 63.667ZM108.027 64.7305L105.262 62.2722L92.9346 76.1394L95.7 78.5977L98.4653 81.0559L110.792 67.1887L108.027 64.7305ZM95.7 78.5977H92V92.2002H95.7H99.3999V78.5977H95.7ZM95.7 92.2002L92 92.2002C91.9999 96.3422 88.6418 99.7003 84.4997 99.7004L84.4998 103.4L84.4998 107.1C92.7287 107.1 99.3999 100.429 99.3999 92.2002L95.7 92.2002ZM84.4998 103.4V99.7004H28.4998V103.4V107.1H84.4998V103.4ZM28.4998 103.4V99.7004C24.3577 99.7004 20.9996 96.3423 20.9996 92.2002L17.2996 92.2002L13.5996 92.2002C13.5996 100.429 20.2707 107.1 28.4998 107.1V103.4ZM17.2996 92.2002H20.9996V78.5986H17.2996H13.5996V92.2002H17.2996ZM17.2996 78.5986L20.0649 76.1404L7.73676 62.2722L4.97144 64.7305L2.20611 67.1887L14.5342 81.0569L17.2996 78.5986ZM4.97144 64.7305L7.73685 62.2723C8.71353 63.3711 8.71353 65.0264 7.73685 66.1251L4.97144 63.667L2.20602 61.2088C0.690476 62.9138 0.690476 65.4836 2.20602 67.1886L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L20.065 52.256L17.2996 49.7979L14.5341 47.3397L2.20602 61.2088L4.97144 63.667ZM17.2996 49.7979H20.9996V36.2002H17.2996H13.5996V49.7979H17.2996ZM17.2996 36.2002H20.9996C20.9996 32.0581 24.3576 28.7 28.4998 28.7V25V21.3C20.2707 21.3 13.5996 27.9712 13.5996 36.2002H17.2996ZM28.4998 25V28.7H45.0066V25V21.3H28.4998V25ZM45.0066 25L48.6333 24.267C48.3381 22.8066 48.1998 21.6154 48.1998 20.7079L44.4998 20.708L40.7998 20.7081C40.7998 22.2864 41.0305 24.0039 41.3799 25.733L45.0066 25ZM44.4998 20.708H48.1998C48.1998 16.3244 51.8182 12.698 56.4998 12.698V8.99805V5.29805C47.9264 5.29805 40.7998 12.0451 40.7998 20.708H44.4998Z" fill="#1C1C24"/>
|
||||
<rect x="66.1001" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
|
||||
<rect x="37.3" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -60,6 +60,7 @@ import { desktopClient } from "@/lib/desktop-client";
|
||||
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import {
|
||||
filterChatModels,
|
||||
loadProviderModelCatalog,
|
||||
loadProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
@@ -492,7 +493,9 @@ export function RoutineSchedulesContent() {
|
||||
|
||||
async function loadModelsForProvider() {
|
||||
try {
|
||||
const models = await loadProviderModels(normalizedProvider);
|
||||
const models = filterChatModels(
|
||||
await loadProviderModels(normalizedProvider),
|
||||
);
|
||||
if (cancelled || models.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,57 +16,48 @@ export type ProviderModelCatalog = {
|
||||
providerReasoningModels: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function toModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter(
|
||||
(model) =>
|
||||
model.supportsReasoning &&
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.map((model) => model.id);
|
||||
export function filterChatModels(
|
||||
models: ProviderModel[] | undefined,
|
||||
): ProviderModel[] {
|
||||
return (models ?? []).filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderModelCatalog(
|
||||
providers: Provider[],
|
||||
): ProviderModelCatalog {
|
||||
const providerEntries = providers.map((provider) => {
|
||||
const chatModels = filterChatModels(provider.modelList);
|
||||
return {
|
||||
provider,
|
||||
modelIds: chatModels.map((model) => model.id),
|
||||
reasoningModelIds: chatModels
|
||||
.filter((model) => model.supportsReasoning)
|
||||
.map((model) => model.id),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
providers,
|
||||
enabledProviderIds: providers
|
||||
enabledProviderIds: providerEntries
|
||||
.filter(
|
||||
(provider) =>
|
||||
provider.enabled && toModelIds(provider.modelList).length > 0,
|
||||
({ provider, modelIds }) => provider.enabled && modelIds.length > 0,
|
||||
)
|
||||
.map((provider) => provider.id),
|
||||
.map(({ provider }) => provider.id),
|
||||
providerModels: Object.fromEntries(
|
||||
providers.map((provider) => [
|
||||
provider.id,
|
||||
toModelIds(provider.modelList),
|
||||
]),
|
||||
providerEntries.map(({ provider, modelIds }) => [provider.id, modelIds]),
|
||||
),
|
||||
providerReasoningModels: Object.fromEntries(
|
||||
providers.map((provider) => [
|
||||
providerEntries.map(({ provider, reasoningModelIds }) => [
|
||||
provider.id,
|
||||
toReasoningModelIds(provider.modelList),
|
||||
reasoningModelIds,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,121 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.32
|
||||
|
||||
- Cline starts again after updating. In 0.0.31 the app could fail at launch with "desktop backend exited before publishing its endpoint" and never reach the workspace picker. Cline runs its own copy of the Cline Hub in the background, and the two halves had been built from different copies of the same code — so the Hub announced one build identity while the app expected another, the app shut down the very Hub it had just started, and startup timed out reporting "No compatible hub runtime is available". Both halves now load the same copy of that code, and the real compiled backend is now booted end-to-end in CI on every change so the two cannot drift apart again
|
||||
|
||||
## 0.0.31
|
||||
|
||||
- Cline can now work on a remote machine over SSH, with the app itself staying local. Add and test a host under **Settings → Remote**, then pick it from the environment selector beside the workspace picker on the welcome screen — saving or testing a profile does not activate it. The connection opens at the remote user's home directory; **Add project…** in the workspace picker browses that machine for a project, and **Local** disconnects. Agent tools, workspace discovery, Git metadata, and session persistence all run on the host while approvals and live session events return to the desktop, because only the authenticated Cline Hub protocol is forwarded through the tunnel. SSH config aliases work: leave **Port** blank to use the alias's own configuration, or enter one to override it. Recent and last-used workspaces are remembered separately per host. Host metadata lives at `~/.cline/data/settings/remote-environments.json` (mode `0600`) and records an identity-file path, never private-key contents; the host key must already be trusted by your SSH client, and unknown or changed keys are rejected. On first connect Cline uploads a self-contained helper to `~/.cline/remote/` — no `apt`, `npm`, root access, global CLI install, or public port — and caches it for faster reconnects, leaving any existing Cline CLI hub on that account running. Linux x64 and arm64 hosts are supported (32-bit Raspberry Pi operating systems are not; macOS hosts need a locally built helper via `CLINE_REMOTE_HELPER_BINARY`). File attachments and opening a remote file in a local editor are not available in remote sessions yet — text, images, file mentions and search, Git branch operations, session history, and remote agent tools are
|
||||
- Sub-agents spawned in the same step now run at the same time instead of one after another, so delegating three independent pieces of work takes as long as the slowest one rather than the sum of all three. Tools that must run in order still do, and only back-to-back parallel calls overlap
|
||||
|
||||
## 0.0.30
|
||||
|
||||
- Windows updates no longer fail with "Error opening file for writing". The installer hook meant to stop the running backend never terminated anything: Tauri ships a 32-bit NSIS installer, so its PowerShell ran under WOW64, where `Get-Process` reports an empty path for every 64-bit process — the filter matched no sidecar on any x64 machine. The installer then wrote over an executable the detached Hub daemon still held open, which is why updating from 0.0.25 through 0.0.28 failed exactly as 0.0.24 did. The installer now asks the Windows Restart Manager which processes hold this install's files and shuts those down; that is bitness-independent, covers the connector processes the Hub spawns, and leaves a side-by-side Cline Beta alone. If Restart Manager itself fails you get a Retry/Cancel prompt rather than a half-replaced install
|
||||
- On Windows, Cline no longer runs a program out of your workspace when it means to run the system one. Windows resolves a bare program name through the child process's working directory before it searches PATH, and Cline runs `rg`, `git`, and `powershell` with your workspace as that directory — so a repository that happened to contain its own `rg.exe` would have that copy executed as soon as the workspace was indexed. Cline now opts out of current-directory executable search at startup, and every process it spawns inherits the setting
|
||||
- Long sessions now compact when they actually need to rather than running out of room. The trigger compared a character-based estimate of the transcript (~3 characters per token) against the model's context limit, so content that tokenizes far denser than that — disassembly, image dumps, minified sources — could fill the real context window while the estimate stayed under the threshold and compaction never fired; the turn was then squeezed down to a handful of output tokens. The trigger now also uses the token count the provider itself reports, and how much history is kept is scaled by how far off the estimate turned out to be. The summarizer's output budget also doubled, so a model that reasons before answering can no longer spend the whole budget thinking and return no summary at all
|
||||
- A tool that streams its output no longer keeps spinning after it has finished. When a tool streamed everything as it went, its completion event carried no final output, which chat read as "still running" — so the tool kept its running presentation, spinner included, for the rest of the turn
|
||||
- Refreshed Cline's logo and app icons throughout the app, the Dock and taskbar, and the installer
|
||||
- The CoreWeave provider's branding and setup links now match CoreWeave's current documentation, and Weights & Biases gained a direct link to its API key page from provider setup
|
||||
|
||||
## 0.0.29
|
||||
|
||||
- Queued messages can now be sent into the running turn with Enter. While the agent is working, pressing Enter with an empty composer steers the first message in the queue, so it is picked up at the next turn boundary instead of waiting for the whole task to finish; the composer placeholder says so whenever the queue is non-empty. The queue head is claimed atomically, so a fast second Enter can't steer a message that has already left the queue, and a steer that fails now surfaces as a toast rather than silently doing nothing
|
||||
- The macOS Dock icon now has the standard margin other apps use. The bundled icon and the selectable runtime icons (Classic, Chip, Hologram, Midnight) were edge-to-edge, so Cline rendered visibly larger than its neighbours in the Dock. Windows keeps its existing artwork
|
||||
- The session sidebar's sort toggle now shows the mode a click switches to, not the one already active — a folder icon while sorted by time, a clock while grouped by project
|
||||
|
||||
## 0.0.28
|
||||
|
||||
- The app no longer gets stuck on "Desktop backend unavailable" when the backend is slow to start. The window asked for the backend's address exactly once and the shell stops waiting after about 15 seconds, so on a slower machine — where starting the hub pushed past that — you were left on the error screen until you relaunched, and the relaunch could lose the same race. Connection attempts now keep retrying and re-resolve the address each time, so a backend that has since restarted is reached at its current one
|
||||
- Confirming text with a Chinese or Japanese IME no longer sends the message. The Enter that commits an in-progress composition also reached the composer, so choosing a candidate fired off a half-typed message; Enter and the arrow keys now belong to the IME while you are composing
|
||||
- The model picker refreshes its list every time you open it, instead of staying frozen until you restart the app. The Recommended and Free groupings come from a live feed, so a newly promoted free model would otherwise not show up for the rest of the session
|
||||
- Signing in with Cline now explains what the plan includes. The sign-in card lists the regular free model promotions, ClinePass for generous usage across open-weight models like DeepSeek, Kimi, and GLM, and that no API key is needed; the last onboarding step then shows the current free models alongside a ClinePass summary. This only appears when you sign in with Cline, not when you bring your own API key
|
||||
- Cline Pass now starts you on a model from your subscription. Its model list carries both subscription and free models and the default was simply the most recently published entry, so a subscriber who never picked a model was left on a free model instead of the tier they pay for
|
||||
- A turn that fails partway through with a temporary provider error is now retried up to three times instead of failing the task. A single rate-limit response passed along by a provider gateway previously ended the turn outright. A turn that has already produced output is never retried, so nothing is duplicated
|
||||
- Long responses stream faster. Every chunk of a streaming reply was being handed to client-side hooks as a round trip carrying a full copy of the session, with the agent waiting on each one before continuing
|
||||
- The command-running tool now tells the model which PowerShell edition it is talking to — `Windows PowerShell (powershell.exe)` versus `PowerShell (pwsh.exe)` — and to write commands directly rather than wrapping them in a second shell invocation, which was corrupting pipelines that use `$_`
|
||||
- Refreshed the model catalog. Adds three providers (Infer by Flow7, Melious, and Wallaby) and takes the catalog from 5,923 to 6,079 models. The resolved default model changes for 33 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go — while NVIDIA moves to GLM 5.3 Flash and NanoGPT to GPT Astra. If you use a provider without pinning a model, expect a different default
|
||||
|
||||
## 0.0.27
|
||||
|
||||
- An expired Cline sign-in now produces one actionable error instead of two dead ends. A turn that fails before the runtime takes the prompt was reported twice — the hub's detail-less `run.failed` appended a bubble, then the send RPC resolved with the real error and overwrote the error banner underneath it — so you got a vague bubble stacked on a detailed banner. The failure now renders exactly once, upgrading in place when the detailed report arrives. Credential failures also carry a fix button: **Sign in to Cline** for the Cline provider (Settings → API Providers reads a stale token as "Signed in via browser", so there is nothing to fix there), **Open model settings** for others, and local-CLI providers keep pointing at their CLI. Sessions that fail to start over credentials get the same hint and action
|
||||
- The Account page now offers a sign-in prompt when your Cline refresh token is rejected, instead of an error card whose **Retry** failed the same way. A rejected refresh was falling back to the persisted access token, which is dead too, so the account request 401'd; a rejected refresh now reports as signed out. Transient refresh failures still fall back
|
||||
- Signing out of Cline and Cline Pass now sticks. Sign-out removes the provider entry, but the runtime re-imports missing providers from the classic extension's stored credentials on every command, so you appeared signed straight back in — the same root cause as the ChatGPT/Codex fix in 0.0.26, which only cleared Codex secrets. Cline Pass sign-out did nothing at all, because its credentials live under the Cline provider entry rather than its own
|
||||
- The settings sidebar's **Models** section is now **API Providers**, with an icon to match — it is where you configure providers and their credentials, not where you pick a model
|
||||
- A failed voice input now shows a "Speech input failed" toast in chat with the actual error, instead of dropping you into Settings → Voice. Any error that wasn't a browser exception was treated as a configuration problem, even though the microphone is already gated on a configured transcription model — so a connection or transcription failure interrupted the chat and hid the real reason. Your draft and the microphone button stay put so you can retry; microphone-permission guidance is unchanged
|
||||
- OpenCode Go models work again. Requests were missing the `x-opencode-session` header Go requires to route a conversation ("Request is missing x-opencode-session and cannot be routed efficiently"), and every model was sent to `/chat/completions` because catalog normalization discarded the per-model adapter declaration — so Muse Spark, GPT, and Grok models that expect `/responses`, and MiniMax/Qwen models that expect `/messages`, failed with internal server errors. Model-level protocol routing is now preserved end to end, retries reuse the same session identity, and Go Qwen entries that omit the declaration fall back to Messages
|
||||
- Added Crusoe as an OpenAI-compatible provider
|
||||
- Settings switches are lighter in light mode and are now a shared native control — real keyboard, label, and form behavior, a guaranteed 24px hit area, and support for reduced-motion and forced-colors
|
||||
- The welcome screen's eye color matches the dark theme, and its animation no longer triggers layout work each frame
|
||||
|
||||
## 0.0.26
|
||||
|
||||
- The composer now shows the current branch's GitHub pull request — PR number, merge status, changed-line totals, and CI checks. Click through to open it in your browser, or expand CI to inspect individual checks and their logs; status refreshes every 30 seconds while visible, on window focus, and on demand. If the branch has no PR, **Create PR** opens GitHub's comparison form. Requires the GitHub CLI (`gh`) installed and signed in, plus a GitHub.com `origin` remote; the row hides itself on the default branch, detached HEAD, and unsupported repositories. Cline does not push commits or submit the PR for you
|
||||
- The Customize view's Tools, Skills, and Rules tabs now read as one consistent list instead of three different ones, matching the pattern Plugins already used. Tools gets a search bar that filters both sections and per-section Enable all/Disable all that only touches what's visible; Skills gets an in-place enable/disable toggle — previously the desktop app had no concept of a disabled skill, so disabled ones were hidden and unreachable — and the same Copy path / Uninstall menu Plugins use. Tabs are reordered to Tools, Plugins, Skills, Rules, MCP, Hooks and open on Tools, and the sidebar's "New Task" button is now "New Session"
|
||||
- Deleting a queued prompt no longer leaves it in the transcript as a message that never ran. The sidecar inferred a queued prompt had started whenever the pending list shrank and its head changed — which is exactly what deleting the first queued prompt (or discarding the queue) looks like. It now relies only on the runtime's real start event
|
||||
- A session forked from a checkpoint restore no longer comes back stuck on "Thinking...". Sessions that materialize with seeded history never passed their status when persisting, so the row and manifest were written as "running" while the live session was idle; resuming from that manifest then showed a session that was never going to finish
|
||||
- Sending an image with no text no longer fails with "session input requires a prompt string" — the composer asks for a message instead of letting the request through to a backend that rejects it
|
||||
- Image attachments in formats the model pipeline can't read (HEIC, TIFF, SVG, BMP, ICO, and friends) are now rejected at attach time — by picker, paste, or drag-and-drop — instead of failing later in the turn. Files whose type has to be inferred from the extension are classified the same way
|
||||
- Signing out of ChatGPT (Codex) now sticks. Sign-out removes the provider entry, but the runtime re-imports any missing provider from the classic extension's stored credentials on every command, so the next action signed you straight back in. The legacy Codex credentials are now cleared too, and a failed clear reports the sign-out as failed instead of quietly succeeding
|
||||
- Auth failures on providers that log in through a local CLI — Claude Code, Codex CLI, OpenCode — now point you at that CLI instead of Settings → Models, where there is nothing to fix. "OAuth session expired", "Not logged in · Please run /login", and similar messages are also recognized as credential failures now, so they get a hint at all
|
||||
- Model lists now refresh from the live catalog for every provider that uses the shared catalog, not just Cline and Cline Pass. Providers with their own endpoint-owned model lists are unaffected, and the first providers listing is still network-free
|
||||
- Cline Pass and free models now report zero cost instead of the upstream provider's price
|
||||
- Session history rows no longer overlap their own hover metadata when a label is wider than the fixed label column
|
||||
- Scheduled sessions no longer stall out. The poller could stop advancing, and capacity waits were counted as run attempts, so a schedule could burn through its retries without ever running. Execution lifecycle and capacity claims are now fenced atomically. New recurring schedules also default to your local timezone instead of UTC; existing schedules without a timezone keep it that way when edited
|
||||
- Automation event acceptance is now atomic and retryable, so an event can't be half-accepted and lost if delivery fails mid-way
|
||||
- Nested PowerShell commands no longer flood errors and look like a hang. Commands run through an outer PowerShell bootstrap, so a nested `powershell -Command "... $_ ..."` had `$_` interpolated away by the outer parser before the inner shell saw it — a `Where-Object { $_.Name ... }` pipeline then errored once per item over a large tree while still exiting 0. Redundant nested invocations are now unwrapped and run directly, only when that provably preserves semantics (same PowerShell edition, no profile loading, fully quoted command). Cline is also told which edition it's actually on — Windows PowerShell vs. Microsoft PowerShell — and to stop wrapping commands in a shell it's already running in
|
||||
- Prompt telemetry to Cline's tracing backend is now limited to Cline and Cline Pass requests. Turns run against your own provider keys are not traced
|
||||
- Cline Pass now appears in the composer's provider picker alongside Cline. One Cline sign-in configures both — Cline Pass stores its credentials under the Cline account rather than having its own — but the picker only listed providers with their own saved settings entry, and onboarding writes just the Cline one. A new Cline Pass user therefore saw a single row
|
||||
- The composer's provider picker has a **Set up another provider** row at the bottom that opens Settings → Models. The picker only lists what you have already configured, so there was no way to reach the rest of the catalog from the composer
|
||||
- Dropped the "Configured" checkmark from the composer's provider picker (added in 0.0.25). Every row in that picker has a saved settings entry, so for anyone who set their providers up in the app every row carried the same green check and it read as decoration. Settings is still where provider readiness is shown, and it distinguishes a real credential from an entry a legacy migration left behind
|
||||
|
||||
## 0.0.25
|
||||
|
||||
- ChatGPT Subscription (Codex) now lists only the models your plan can actually use. Two separate paths filled the picker from the shared OpenAI catalog, so GPT-4o, GPT-4.1, and `chatgpt-image-latest` showed up alongside the Codex models, and the runtime lost the Codex context caps. The model rules also match what the backend now accepts: `gpt-5.4` and `gpt-5.4-mini` were retired for ChatGPT accounts on 2026-08-31 and are gone, the default moves to `gpt-5.6-terra`, and every Codex model is capped at the real 400K/272K/128K backend budget instead of inheriting the API's 1.05M limits
|
||||
- Windows updates no longer fail with "Error opening file for writing". The compiled sidecar re-executes itself as the detached Cline Hub daemon, which outlives the app by design, and Tauri's NSIS installer only kills the main binary — so the daemon still held `code-sidecar.exe` and the install stopped until you killed the process by hand. The installer now stops it first, matched on the full path so updating one channel does not take down a side-by-side Cline Beta's sessions
|
||||
- Your prompt is no longer lost when a send fails before the turn starts — switching to Codex and having the OAuth refresh throw, for instance. The runtime never took the prompt, so post-send hydration wiped the optimistic bubble and you had to retype it. The text and attachments now come back to the composer, merged with anything you attached while the send was pending, and left alone if you have already started typing something else
|
||||
- Providers that authenticate through a local CLI — Claude Code, Codex CLI — can now start sessions without an API key. They showed as Configured in Settings via their local-auth capability, but session start still refused them with "Missing API key"
|
||||
- OpenCode is now treated as a local CLI provider rather than an OAuth one, so it shows the local CLI notice instead of a browser sign-in button that could not do anything. It authenticates from the credentials the opencode CLI itself stores
|
||||
- Session import from Claude Code, Codex, and opencode has its own page in Settings instead of a row buried in General
|
||||
- The composer's provider picker now marks which providers you have already configured
|
||||
- The model picker distinguishes models that share a name, and Cline Pass subscription models are listed separately from the free fallback tier
|
||||
- Published DMGs use the intended window layout and background again. Tauri skips the Finder AppleScript that applies them whenever `CI` is set, which GitHub Actions always sets, so every DMG since the artwork landed shipped with a stock Finder window even though the artwork was generated and validated
|
||||
- Cline's recommended, free, and subscribed model lists now ship with the app, so they are correct at first launch instead of waiting on a live catalog fetch
|
||||
- Refreshed the model catalog. Adds NaN (nan.builders) and changes the resolved default model for 36 providers — including Bedrock, Vertex, OpenRouter, Kilo, GitHub Copilot, Gemini, Cerebras, Fireworks, Requesty, and Vercel AI Gateway. Several move off Claude Fable 5.1 to GPT-6 Astra, Vertex goes to Gemini 3.8 Flash, and OpenRouter/Kilo to Inception Mercury 2.5. If you use one of those without pinning a model, expect a different default
|
||||
|
||||
## 0.0.24
|
||||
|
||||
- Fixed the live chat stream doubling text and dropping messages mid-turn. The sidecar has two Hub sockets that both receive a session's events — ClineCore's own client and the observer client — and a session that streams without a local send first (a run already in flight when you open the task, a resumed run, a scheduled run) had every delta rendered twice. The observer's copy is now skipped whenever ClineCore is subscribed to the session, asked directly rather than inferred from a timer, so long commands, slow first tokens, and unanswered tool approvals cannot let a duplicate slip through ahead of the core copy. Separately, when the sidecar was replaced under a live webview (crash-respawn, Hub drain-and-replace, stale-sidecar swap) its stream counter restarted at 1 and the webview silently discarded everything until the new process counted past the old run — this dropped your own message bubbles and tool rows, not just assistant text, which is why rows appeared to vanish mid-turn and come back afterwards
|
||||
- Fixed a queued prompt's own message vanishing from the chat. When you queue a prompt behind a running turn, the runtime drains the queue just before it answers the previous send, so the previous turn's completion path replaced the whole transcript from a canonical read that predated your queued message — erasing your bubble and leaving the reply streaming in under no user message. That path now defers to the newer turn instead of treating the transcript as its own. Two symptoms rode on the same bug: the composer no longer drops out of its busy state while the queued reply is still pending, and a finished reasoning row now reads "Thought for Ns" instead of a stuck "Thinking" — live rows are stamped on the webview's clock, so a sidecar whose clock trails it (a remote Hub, the browser-dev setup) no longer produces a negative duration that gets dropped
|
||||
- Cline no longer stops silently mid-task when a model gets stuck repeating itself. The loop detector stops a run after 5 identical tool calls and the mistake tracker after 6 consecutive failures, but the desktop never registered a decision callback, so the run just ended and the composer went idle with no message. You are now asked how to continue — "Try a different approach" or "Stop this run" — and the guidance is steered into the running turn so the model knows why it was paused instead of repeating the same call
|
||||
- The `editor` tool's error message now names the file, says whether `old_text` was null or omitted, and states how to recover. Models that fill optional parameters with null (seen with kimi-k3) hit a terse "old_text is required" and re-sent the identical call until the loop detector stopped the run
|
||||
- Fixed your Cline Pass model selection being replaced when you start a new chat. Catalogs are discovery data, not validation — the bundled catalog can omit live Cline Pass models and refreshes can return partial lists, so a model missing from the catalog was treated as invalid and silently swapped for a default
|
||||
- Cline Desktop now has a custom title bar on Windows, with caption controls that follow the compact title-bar height in narrow windows and stay above overlays. The Windows taskbar icon was also updated
|
||||
- Token counts and costs now fill in for every session you can see. The sessions view only ever hydrated the four most recent rows, so every other row showed "-" and paging never asked for more; the visible page is now hydrated on demand, with reads capped and re-run when a session's status changes underneath them
|
||||
- Sessions imported from Claude Code, Codex, and opencode now say so in the chat, and their foreign history is summarized on the first resumed turn. Imported transcripts keep the source tool's own tool names and schemas, which a model continuing them may try to call — the summary runs once, the original transcript stays intact, and the "Thinking..." indicator reads "Summarizing the imported <tool> history..." while it happens
|
||||
- Fixed session history rendering empty when one session had many subagent or team-task children. Child rows always sort after the root that spawned them, so a single busy session could hide itself and every older session from the sidebar with no way to load more
|
||||
- Checkpoints no longer re-hash every untracked file before each message. Checkpoint creation rebuilt a throwaway git index each turn, so multi-GB untracked data blocked every message for seconds to minutes (~90s in one report on a cloud-synced Windows workspace). One snapshot index is now kept per session, so from the second turn the cost is roughly git process overhead. Snapshot contents are byte-identical to before
|
||||
- Commands that background a child process (`cmd &`, `nohup`, and the same from Git Bash) no longer hang until the timeout. The inherited stdio pipes stay open after the shell exits, so the completion event never arrived even though the command was done; these now settle with the real exit code and a note that background output is no longer captured
|
||||
- Typing an `@` mention from your home directory no longer indexes your entire home folder. That could take memory into the gigabytes and get the process killed; the home directory and filesystem root are now skipped entirely
|
||||
- Web search is now enabled by default outside YOLO mode, and tool settings fail closed if they cannot be loaded
|
||||
- Claude Code no longer asks for an API key it never reads. It authenticates from the local `claude` CLI's own credential store, but was reported as an API-key provider, so a keyless entry was refused and the workaround was to save a dummy key
|
||||
- Pasted credentials with invisible characters no longer persist corrupted. A BOM or zero-width character carried in from a copy-paste produced 401s indistinguishable from a wrong key; credential fields are now stripped of control and format characters on save
|
||||
- Starting a new task no longer flickers through the idle state. The Hub publishes the new session's record as "idle" while the start request is still in flight, so the composer placeholder and the request indicator switched to idle and back for a frame on every new task. A transient idle arriving during a submission is now held back; a real failure or abort still applies immediately
|
||||
- The model picker keeps section headers visible while you search. Cline Pass lists the same model in both the Subscribed and Free tiers, so flattening the sections during search produced two identical-looking rows
|
||||
- `apply_patch` "Add File" now refuses to overwrite an existing file instead of silently replacing it
|
||||
- Fixed session import paths resolving incorrectly on Windows
|
||||
- The desktop backend now starts off the command path, so startup no longer blocks the UI
|
||||
- The SDK can now connect to authenticated remote Hubs
|
||||
|
||||
## 0.0.23
|
||||
|
||||
- Agent Plugins are now discovered and run by the shared Hub. Packages under `~/.agents/plugins` are validated from their `plugin.json`, their valid Agent Skills become available to the agent, and their stdio / Streamable HTTP / SSE MCP servers start automatically. Settings → Customize lists Agent Plugins separately from Cline Plugins, with each plugin's description, badge, and contributed tools, and enable/disable is Hub-managed per plugin. Workspace `.agents/plugins` directories are intentionally ignored
|
||||
- The "Cline Hub was updated" dialog no longer appears on every launch and reconnect. The app no longer prompts about a Hub running the same core version it does — a desktop and CLI release cut from different commits bundle the same core but never share a build fingerprint, so anyone with both installed got a dialog whose "Update and restart" looped on "no app update available". The build-mismatch dialog now also waits until an app update is actually staged, and "Later" sticks across session switches, reloads, and relaunches instead of resurfacing every time. A Hub the app genuinely cannot talk to still warns every time
|
||||
- Signing in now shows the device confirmation code in the app while you wait on the browser, so you can match it against the code the browser asks you to confirm — in onboarding, Account settings, and the provider list
|
||||
- Voice input failures caused by provider setup — missing credentials, transcription config — now take you straight to voice settings instead of a toast you cannot act on. Genuine microphone permission failures still toast, with a clearer message
|
||||
- Fixed the scheduled-task report vanishing when a finished run's step collapsed
|
||||
- Fixed one wedged MCP server blocking the rest from shutting down, leaking their processes
|
||||
|
||||
## 0.0.22
|
||||
|
||||
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
|
||||
|
||||
@@ -10,13 +10,76 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev` - Tauri desktop dev
|
||||
- `bun run build` - build web assets
|
||||
- `bun run build:web` - build production web assets only (includes the shared UI build)
|
||||
- `bun run build` - build web assets and the sidecar binary
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
|
||||
- `bun run build:binary` - build desktop binary
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
### Checking webview changes
|
||||
|
||||
Run `bun run build:web` from this directory when changing webview imports or shared browser APIs. Type checking and Vitest do not check the production browser bundle: a valid TypeScript import can still pull Node-only modules into a client chunk. Use `@cline/shared/browser` for runtime imports in the webview; the bare `@cline/shared` source alias points to the Node entry point.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
The composer shows the current branch's GitHub pull request, merge status,
|
||||
changed-line totals, and CI checks. Click the PR number to open it in your
|
||||
browser, or expand CI to inspect individual checks and their logs. Status
|
||||
refreshes every 30 seconds while visible, when the app regains focus, and
|
||||
when you click refresh.
|
||||
|
||||
This requires GitHub CLI (`gh`) installed and authenticated with `gh auth login`,
|
||||
and a GitHub.com `origin` remote (HTTPS or SSH). The row is hidden for the
|
||||
default branch, detached HEAD, and unsupported repositories. If the branch
|
||||
has no PR, **Create PR** opens GitHub's comparison form; push your commits
|
||||
before submitting the form. The app does not push commits or submit PRs itself.
|
||||
|
||||
Missing or unauthenticated GitHub CLI also hides the row. Availability checks
|
||||
are shared across workspaces and cached for five minutes, so unavailable CLI
|
||||
installs do not spawn a failing process on every poll or window focus. After
|
||||
installing or signing into `gh`, the feature becomes available on the first
|
||||
refresh after the cache expires (or after restarting the desktop backend).
|
||||
Initial lookup failures stay hidden. Errors after a successful status load
|
||||
can be dismissed and remain dismissed through retries until a load succeeds.
|
||||
|
||||
### Pull request telemetry
|
||||
|
||||
These events use the desktop telemetry service and respect telemetry opt-out:
|
||||
|
||||
| Event | Trigger |
|
||||
| --- | --- |
|
||||
| `desktop.pull_request.shown` | First visible PR/create row per mounted workspace and branch |
|
||||
| `desktop.pull_request.open_clicked` | Click the PR link |
|
||||
| `desktop.pull_request.create_clicked` | Click Create PR (intent only, not PR submission) |
|
||||
| `desktop.pull_request.checks_expanded` | Open the CI popover |
|
||||
| `desktop.pull_request.check_clicked` | Click a check's details link |
|
||||
| `desktop.pull_request.refresh_clicked` | Click manual refresh |
|
||||
|
||||
Each event contains only `prState`, `ciState`, and `mergeTone` categories.
|
||||
The sidecar validates these values and strips extra fields. Repository/branch
|
||||
names, paths, PR numbers/titles, check names, and URLs are not included.
|
||||
Automatic polling does not emit additional impressions. Telemetry delivery
|
||||
does not block interactions, and failures do not interrupt the feature.
|
||||
|
||||
## App Icons
|
||||
|
||||
`src-tauri/app-icon.png` (1024x1024, edge-to-edge) is the source for
|
||||
`bun tauri icon`, which generates the Windows `.ico` and Linux PNGs in
|
||||
`src-tauri/icons/`. macOS is the exception: Dock icons are expected to have a
|
||||
transparent margin, with the artwork filling 824 of the 1024 canvas, so the
|
||||
committed `icons/icon.icns` is built from a padded copy of the source, and the
|
||||
selectable runtime icons in `icons/app/macos/` are padded copies of the
|
||||
Windows ones in `icons/app/`. To regenerate the macOS icon after changing the
|
||||
artwork:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
magick app-icon.png -resize 824x824 -background none -gravity center -extent 1024x1024 /tmp/app-icon-macos.png
|
||||
bun tauri icon /tmp/app-icon-macos.png -o /tmp/icons-macos && cp /tmp/icons-macos/icon.icns icons/icon.icns
|
||||
```
|
||||
|
||||
## Customizing the macOS Install Window
|
||||
|
||||
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
|
||||
@@ -61,7 +124,64 @@ 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).
|
||||
[`core shell-path.ts`](../../../sdk/packages/core/src/remote/shell-path.ts).
|
||||
|
||||
## SSH Remote Environments
|
||||
|
||||
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
|
||||
profile does not activate it. From the welcome chat, open the environment
|
||||
selector beside the workspace picker and choose the saved host; that selection
|
||||
starts the SSH connection at the remote user's home directory. Choose **Add
|
||||
project…** from the normal workspace selector to browse that machine and select
|
||||
a project, or choose **Local** in the environment selector to disconnect. Recent
|
||||
and last-used workspaces are remembered separately for each SSH host and for
|
||||
the local machine.
|
||||
|
||||
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
|
||||
configuration (including its configured port), or enter a port to override it.
|
||||
The desktop keeps its webview and native integration local; only the
|
||||
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
|
||||
workspace discovery, Git metadata, and session persistence therefore run on the
|
||||
SSH host, while approvals and live session events return to the desktop.
|
||||
|
||||
The shared `@cline/core` `RemoteEnvironmentService` owns this feature; other
|
||||
clients can use the same service and `ClineCore` remote backend (see `sdk/DOC.md`).
|
||||
Desktop owns the settings UI and packaged helper resource lookup.
|
||||
|
||||
The service stores host metadata at
|
||||
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
|
||||
identity-file path, never private-key contents. On first connect it uploads a
|
||||
content-addressed, branch-matched, self-contained Hub helper under
|
||||
`~/.cline/remote/`, binds the Hub to remote loopback, and forwards it to a
|
||||
random local loopback port. Linux x64 and arm64 helpers are bundled by
|
||||
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
|
||||
supported. macOS SSH targets need a locally built helper passed through
|
||||
`CLINE_REMOTE_HELPER_BINARY` until the bundled helpers are codesigned for
|
||||
notarization. The helper includes its own runtime. It is copied once per matching desktop build and cached, with no
|
||||
`apt`, `npm`, root access,
|
||||
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
|
||||
remote Hub but leaves the helper cached for a faster reconnect. The helper
|
||||
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
|
||||
MCP executables remain visible.
|
||||
|
||||
Each service instance uses its own discovery record, so an existing Cline CLI/Hub on the
|
||||
same account is neither replaced nor stopped. Both Hub processes can coexist
|
||||
while the desktop is connected; this isolation keeps the remote helper separate from the default CLI Hub.
|
||||
|
||||
The desktop currently leaves file attachments and opening a remote file in a local
|
||||
editor disabled. Text, images, file mentions/search, Git branch operations,
|
||||
session history, and remote agent tools are supported. The current desktop
|
||||
provider access/API token is sent through the authenticated tunnel for the
|
||||
session; reusable OAuth refresh credentials are not copied into remote provider
|
||||
settings.
|
||||
|
||||
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
|
||||
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
|
||||
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
|
||||
connection at the SSH user's home, starts an agent session in the test
|
||||
workspace with the selected desktop provider, asks the agent to read
|
||||
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
|
||||
that its messages can be read back.
|
||||
|
||||
## Web Visual System
|
||||
|
||||
@@ -226,3 +346,5 @@ credentials, request headers, recorded audio, or transcript contents.
|
||||
The sidecar mints a short-lived transcription token; the long-lived gateway
|
||||
credential is never sent to the webview. Batch models such as
|
||||
`openai/whisper-1` continue to transcribe after recording stops.
|
||||
|
||||
SSH requires an already-trusted host key. Before first connection, verify the server fingerprint through a trusted channel and enroll it with your SSH client. Unknown or changed keys are rejected.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { $ } from "bun";
|
||||
|
||||
const main = async () => {
|
||||
await $`next build`.cwd("webview");
|
||||
await $`bun run build:sidecar:bin`;
|
||||
await $`next build`.cwd("webview");
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.22",
|
||||
"version": "0.0.32",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -10,6 +10,8 @@
|
||||
"predev:headless": "bun run build:ui",
|
||||
"dev:headless": "bun run scripts/dev-headless.ts",
|
||||
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"prebuild:web": "bun run build:ui",
|
||||
"build:web": "next build webview",
|
||||
"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",
|
||||
@@ -27,6 +29,10 @@
|
||||
"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 webview/components/views/chat/messages --config vitest.config.ts",
|
||||
"pretest:settings-ui": "bun run build:ui",
|
||||
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts --config vitest.config.ts",
|
||||
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -60,7 +66,6 @@
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { $ } from "bun";
|
||||
import { telemetryDefineArgs } from "./telemetry-define-args";
|
||||
|
||||
@@ -38,22 +39,63 @@ const sidecarOutfile = (targetTriple: string): string => {
|
||||
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
};
|
||||
|
||||
const buildSidecar = async (targetTriple: string): Promise<string> => {
|
||||
const outfile = sidecarOutfile(targetTriple);
|
||||
const buildSidecar = async (
|
||||
targetTriple: string,
|
||||
outfile = sidecarOutfile(targetTriple),
|
||||
entrypoint = "./sidecar/index.ts",
|
||||
minify = false,
|
||||
): Promise<string> => {
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
// Telemetry config must be inlined into the compiled binary: a packaged
|
||||
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
|
||||
// without this the sidecar silently ships with telemetry disabled.
|
||||
// Verify with `<binary> --telemetry-selfcheck` after building.
|
||||
const defines = telemetryDefineArgs();
|
||||
const optimizationArgs = minify ? ["--minify"] : [];
|
||||
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
|
||||
// launch directory before our entrypoint runs. Remote helpers are launched
|
||||
// from an SSH user's home directory, so that behavior can both make the
|
||||
// helper fail on an unrelated dotenv file and leak workspace credentials
|
||||
// into the Hub process. Packaged binaries must depend only on their explicit
|
||||
// process environment and compiled configuration.
|
||||
const runtimeIsolationArgs = [
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
];
|
||||
if (bunTarget) {
|
||||
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
|
||||
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
|
||||
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
}
|
||||
return outfile;
|
||||
};
|
||||
|
||||
// SSH environments run the same Hub build as the desktop in a dedicated
|
||||
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
|
||||
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
|
||||
// macOS helpers are deliberately not bundled: they are Mach-O files under
|
||||
// Contents/Resources, which Tauri does not codesign, and any unsigned Mach-O
|
||||
// in the bundle fails notarization. Shipping them needs a signing step first.
|
||||
//
|
||||
// On a Windows host, Bun fails to extract the downloaded Linux runtime these
|
||||
// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'").
|
||||
// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun-<target>-v<version>`
|
||||
// already exists, so seed those two files from the @oven/bun-<target> npm
|
||||
// packages first; desktop-publish.yml does exactly that in its Windows job.
|
||||
const buildRemoteHelpers = async (): Promise<void> => {
|
||||
for (const targetTriple of [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
]) {
|
||||
await buildSidecar(
|
||||
targetTriple,
|
||||
`./src-tauri/bin/remote-helpers/cline-remote-helper-${targetTriple}`,
|
||||
"../../../sdk/packages/core/dist/remote/remote-helper-entry.js",
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
|
||||
// but expects sidecars (externalBin) to already be fat binaries named
|
||||
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
|
||||
@@ -67,13 +109,18 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
// All compiled helpers and the sidecar depend on fresh SDK package exports.
|
||||
await $`bun run build:sdk`.cwd(
|
||||
fileURLToPath(new URL("../../../../", import.meta.url)),
|
||||
);
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
await $`mkdir -p src-tauri/bin`;
|
||||
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
|
||||
if (targetTriple === "universal-apple-darwin") {
|
||||
await buildUniversalMacSidecar();
|
||||
return;
|
||||
} else {
|
||||
await buildSidecar(targetTriple);
|
||||
}
|
||||
await buildSidecar(targetTriple);
|
||||
await buildRemoteHelpers();
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Exercise the actual compiled entrypoint: source-only tests miss mixed SDK
|
||||
// build identities between the desktop client and its embedded Hub daemon.
|
||||
test("compiled desktop backend publishes its endpoint with its own Hub", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-desktop-startup-"));
|
||||
const discoveryPath = join(root, "hub.json");
|
||||
const stdoutPath = join(root, "stdout.log");
|
||||
const stderrPath = join(root, "stderr.log");
|
||||
const stdout = openSync(stdoutPath, "w");
|
||||
const stderr = openSync(stderrPath, "w");
|
||||
let child: ReturnType<typeof Bun.spawn> | undefined;
|
||||
let hubPid: number | undefined;
|
||||
try {
|
||||
const binary = process.env.CLINE_TEST_SIDECAR_BIN
|
||||
? resolve(process.env.CLINE_TEST_SIDECAR_BIN)
|
||||
: join(root, process.platform === "win32" ? "sidecar.exe" : "sidecar");
|
||||
if (!process.env.CLINE_TEST_SIDECAR_BIN) {
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"build",
|
||||
fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)),
|
||||
"--compile",
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
"--outfile",
|
||||
binary,
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", timeout: 60_000 },
|
||||
);
|
||||
expect(build.status, build.stderr || String(build.error)).toBe(0);
|
||||
}
|
||||
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key]) => !/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_)/.test(key),
|
||||
),
|
||||
);
|
||||
Object.assign(env, {
|
||||
CLINE_DIR: root,
|
||||
CLINE_DATA_DIR: join(root, "data"),
|
||||
CLINE_HUB_DISCOVERY_PATH: discoveryPath,
|
||||
});
|
||||
// Bootstrap on an OS-assigned port using the same executable. Pin the
|
||||
// desktop to that port so this test cannot touch a developer's real Hub,
|
||||
// even if a regression makes it reject its own discovery record.
|
||||
const bootstrap = spawnSync(
|
||||
binary,
|
||||
["--remote-hub-ensure", "--discovery-path", discoveryPath, "--cwd", root],
|
||||
{ cwd: root, env, encoding: "utf8", timeout: 20_000 },
|
||||
);
|
||||
expect(bootstrap.status, bootstrap.stderr || String(bootstrap.error)).toBe(
|
||||
0,
|
||||
);
|
||||
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
|
||||
hubPid = hub.pid;
|
||||
env.CLINE_HUB_PORT = String(hub.port);
|
||||
child = Bun.spawn([binary], {
|
||||
cwd: root,
|
||||
env,
|
||||
stdin: "ignore",
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
let endpoint: string | undefined;
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.type === "ready") endpoint = message.endpoint;
|
||||
} catch {
|
||||
/* Ignore other output and incomplete lines. */
|
||||
}
|
||||
}
|
||||
if (endpoint || child.exitCode !== null) break;
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
expect(
|
||||
endpoint,
|
||||
readFileSync(stderrPath, "utf8") || "Backend never became ready",
|
||||
).toBeTruthy();
|
||||
const health = await fetch(`${endpoint}/health`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
expect(health.ok).toBe(true);
|
||||
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
|
||||
} finally {
|
||||
if (child && child.exitCode === null) {
|
||||
child.kill();
|
||||
await Promise.race([child.exited, Bun.sleep(6_000)]);
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
await child.exited;
|
||||
}
|
||||
// A failed bootstrap may still have published a discovery record.
|
||||
try {
|
||||
hubPid ??= JSON.parse(readFileSync(discoveryPath, "utf8")).pid;
|
||||
if (hubPid) process.kill(hubPid, "SIGTERM");
|
||||
} catch {
|
||||
/* The isolated Hub may already have exited. */
|
||||
}
|
||||
closeSync(stdout);
|
||||
closeSync(stderr);
|
||||
rmSync(root, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
}, 100_000);
|
||||
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
|
||||
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
|
||||
});
|
||||
|
||||
it("inlines the cloud-agents toggle only when set at build time", () => {
|
||||
const withoutToggle = defineMap(telemetryDefineArgs({}));
|
||||
expect(withoutToggle).not.toHaveProperty(
|
||||
"process.env.CLINE_CODE_CLOUD_AGENTS",
|
||||
);
|
||||
|
||||
const withToggle = defineMap(
|
||||
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
|
||||
);
|
||||
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
|
||||
});
|
||||
|
||||
it("JSON-escapes values so headers with quotes survive the define", () => {
|
||||
const defines = defineMap(
|
||||
telemetryDefineArgs({
|
||||
|
||||
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
] as const;
|
||||
|
||||
/** Optional build-time overrides for packaged dogfood builds. */
|
||||
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
|
||||
|
||||
/**
|
||||
* Every env var `getTelemetryBuildTimeConfig` reads
|
||||
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
|
||||
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
|
||||
const define = (name: string, value: string) => {
|
||||
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
|
||||
};
|
||||
for (const name of OPTIONAL_SECRET_ENV_VARS) {
|
||||
for (const name of [
|
||||
...OPTIONAL_SECRET_ENV_VARS,
|
||||
...OPTIONAL_FEATURE_ENV_VARS,
|
||||
]) {
|
||||
const value = env[name];
|
||||
if (value) {
|
||||
define(name, value);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ClineCore,
|
||||
ProviderSettingsManager,
|
||||
RemoteEnvironmentService,
|
||||
RuntimeOAuthTokenManager,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
SessionSource,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-"));
|
||||
const service = new RemoteEnvironmentService({
|
||||
profilesPath: join(temporaryDirectory, "remote-environments.json"),
|
||||
helperBinaryPath: required("CLINE_SSH_TEST_HELPER"),
|
||||
knownHostsPath: process.env.CLINE_SSH_TEST_KNOWN_HOSTS?.trim() || undefined,
|
||||
commandTimeoutMs: 60_000,
|
||||
uploadTimeoutMs: 5 * 60_000,
|
||||
});
|
||||
let core: ClineCore | undefined;
|
||||
|
||||
try {
|
||||
const helperPath = required("CLINE_SSH_TEST_HELPER");
|
||||
const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE");
|
||||
const profile = await service.upsert({
|
||||
name: "SSH proof host",
|
||||
host: required("CLINE_SSH_TEST_HOST"),
|
||||
user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined,
|
||||
identityFile: required("CLINE_SSH_TEST_KEY"),
|
||||
});
|
||||
const connection = await service.connect(profile.id);
|
||||
const marker = await service.run(profile.id, {
|
||||
command: "sed",
|
||||
args: ["-n", "1p", "REMOTE_MARKER.txt"],
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
|
||||
const providerSettings = new ProviderSettingsManager();
|
||||
const stored = providerSettings.read();
|
||||
const providerId = stored.lastUsedProvider;
|
||||
if (!providerId)
|
||||
throw new Error("No configured desktop provider is available");
|
||||
const settings = providerSettings.getProviderSettings(providerId);
|
||||
if (!settings)
|
||||
throw new Error(`No settings found for provider ${providerId}`);
|
||||
const modelId = settings.model || "meta/muse-spark-1.2";
|
||||
const oauth = await new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager: providerSettings,
|
||||
}).resolveProviderApiKey({ providerId });
|
||||
const apiKey =
|
||||
oauth?.apiKey ||
|
||||
resolveProviderApiKeyFromSettings(providerSettings, providerId);
|
||||
if (!apiKey)
|
||||
throw new Error(`No credential found for provider ${providerId}`);
|
||||
|
||||
const providerConfig = {
|
||||
...toProviderConfig(
|
||||
{
|
||||
...(providerSettings.getProviderSettings(providerId) ?? settings),
|
||||
model: modelId,
|
||||
},
|
||||
{ includeKnownModels: false },
|
||||
),
|
||||
};
|
||||
delete providerConfig.refreshToken;
|
||||
providerConfig.apiKey = apiKey;
|
||||
providerConfig.accessToken = apiKey;
|
||||
|
||||
core = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
remote: {
|
||||
endpoint: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
},
|
||||
});
|
||||
const eventNames: string[] = [];
|
||||
const unsubscribe = core.subscribe((event) => {
|
||||
eventNames.push(event.type);
|
||||
});
|
||||
const started = await core.start({
|
||||
config: {
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
providerConfig,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
systemPrompt: "",
|
||||
mode: "act",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
},
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
});
|
||||
const result = await core.send({
|
||||
sessionId: started.sessionId,
|
||||
prompt:
|
||||
"Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.",
|
||||
});
|
||||
const sessions = await core.list(20, { hydrate: false });
|
||||
const messages = await core.readMessages(started.sessionId);
|
||||
unsubscribe();
|
||||
await core.dispose("desktop_ssh_proof_reconnect");
|
||||
core = undefined;
|
||||
await service.disconnect(profile.id);
|
||||
|
||||
const reconnected = await service.connect(profile.id);
|
||||
core = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
remote: {
|
||||
endpoint: reconnected.endpoint,
|
||||
authToken: reconnected.authToken,
|
||||
workspaceRoot: reconnected.workspaceRoot,
|
||||
cwd: reconnected.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
},
|
||||
});
|
||||
const sessionsAfterReconnect = await core.list(20, { hydrate: false });
|
||||
const messagesAfterReconnect = await core.readMessages(started.sessionId);
|
||||
|
||||
const resultText = result?.text ?? "";
|
||||
const report = {
|
||||
connected: true,
|
||||
remote: `${connection.platform}/${connection.arch}`,
|
||||
connectionRoot: connection.workspaceRoot,
|
||||
workspaceRoot: started.manifest.workspace_root,
|
||||
sessionId: started.sessionId,
|
||||
listContainsSession: sessions.some(
|
||||
(session) => session.sessionId === started.sessionId,
|
||||
),
|
||||
messageCount: messages.length,
|
||||
reconnected: true,
|
||||
reconnectListContainsSession: sessionsAfterReconnect.some(
|
||||
(session) => session.sessionId === started.sessionId,
|
||||
),
|
||||
reconnectMessageCount: messagesAfterReconnect.length,
|
||||
helperBytes: (await stat(helperPath)).size,
|
||||
sshMarker: marker.stdout.trim(),
|
||||
agentText: resultText,
|
||||
agentObservedMarker: resultText.includes("remote workspace proof"),
|
||||
eventNames: [...new Set(eventNames)],
|
||||
};
|
||||
if (
|
||||
report.sshMarker !== "remote workspace proof" ||
|
||||
!report.agentObservedMarker ||
|
||||
!report.listContainsSession ||
|
||||
!report.reconnectListContainsSession ||
|
||||
report.messageCount < 2 ||
|
||||
report.reconnectMessageCount < 2 ||
|
||||
!report.eventNames.includes("agent_event")
|
||||
) {
|
||||
throw new Error(`SSH proof failed: ${JSON.stringify(report)}`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
} finally {
|
||||
await core?.dispose("desktop_ssh_proof_complete");
|
||||
await service.dispose();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -16,6 +16,7 @@ sidecar/
|
||||
├── index.ts # Entry point: starts HTTP+WS server
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── client-context.ts # Desktop client/account identity for shared telemetry
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
@@ -81,6 +82,15 @@ 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.
|
||||
|
||||
Every create, restart, fork, and restore also attaches the serializable Desktop
|
||||
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
|
||||
that context across the Hub transport and scopes the daemon-owned telemetry
|
||||
service to the originating surface. This keeps lifecycle events centralized in
|
||||
Core while reporting Desktop dimensions (`cline_type: "desktop"`, `platform:
|
||||
"Cline Desktop"`, and the Desktop app version) and the current account and
|
||||
organization. The shared Hub telemetry singleton is never mutated per session,
|
||||
so concurrent CLI and Desktop tasks retain their own attribution.
|
||||
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
@@ -134,6 +144,17 @@ The frontend `desktop-client.ts` connects directly to the sidecar WebSocket:
|
||||
|
||||
## Command Map
|
||||
|
||||
The model picker first uses `list_provider_catalog`, which reads the bundled and
|
||||
registered models without network access. It then calls `list_provider_models`
|
||||
for the active provider, both on mount and when the provider changes. All built-in
|
||||
providers backed by the shared catalog refresh from the live feed (including
|
||||
OpenCode); concurrent requests share one fetch and reuse its ten-minute cache.
|
||||
Endpoint-owned lists such as Baseten, Hicap, Poolside, LiteLLM, Ollama, and LM Studio use their existing
|
||||
discovery endpoints instead. Catalog and public endpoint requests time out after
|
||||
five seconds, and the initial picker remains usable while a refresh is pending.
|
||||
The sidecar omits bundled `knownModels` from the discovery config so they cannot
|
||||
override live metadata; explicitly registered model overrides retain precedence.
|
||||
|
||||
Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as os from "node:os";
|
||||
import { resolveCoreDistinctId } from "@cline/core";
|
||||
import type {
|
||||
ClientContext,
|
||||
ExtensionContext,
|
||||
TelemetryMetadata,
|
||||
UserContext,
|
||||
} from "@cline/shared";
|
||||
import { version } from "../package.json";
|
||||
|
||||
/** Shared identity for request headers, Hub attribution, and telemetry. */
|
||||
export const DESKTOP_CLIENT_CONTEXT = {
|
||||
name: "cline-desktop",
|
||||
version,
|
||||
platform: "Cline Desktop",
|
||||
platformVersion: version,
|
||||
isMultiRoot: false,
|
||||
} as const satisfies ClientContext;
|
||||
|
||||
export const DESKTOP_TELEMETRY_METADATA = {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: DESKTOP_CLIENT_CONTEXT.platform,
|
||||
platform_version: DESKTOP_CLIENT_CONTEXT.platformVersion,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
} satisfies TelemetryMetadata;
|
||||
|
||||
export function resolveDesktopTelemetryUser(input?: {
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
organizationId?: string;
|
||||
}): UserContext {
|
||||
const accountId = input?.accountId?.trim();
|
||||
return accountId
|
||||
? {
|
||||
distinctId: accountId,
|
||||
accountId,
|
||||
email: input?.email,
|
||||
organizationId: input?.organizationId,
|
||||
}
|
||||
: {
|
||||
distinctId: resolveCoreDistinctId(),
|
||||
accountId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serializable context attached to every Desktop session sent to the Hub. */
|
||||
export function createDesktopExtensionContext(
|
||||
user?: UserContext,
|
||||
): ExtensionContext {
|
||||
return {
|
||||
client: DESKTOP_CLIENT_CONTEXT,
|
||||
...(user ? { user: { ...user } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
captureAuthRefreshSoftFailure,
|
||||
getProviderAuthHandler,
|
||||
OAuthReauthRequiredError,
|
||||
type ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "@cline/core";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
// Share the refresh-aware manager so single-use refresh tokens stay single-flight.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
export async function resolveFreshClineAuthToken(
|
||||
manager: ProviderSettingsManager,
|
||||
ctx?: SidecarContext,
|
||||
): Promise<string | undefined> {
|
||||
let refreshError: Error | undefined;
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
providerId: "cline",
|
||||
});
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch (error) {
|
||||
// A persisted token may still let the account request surface the failure.
|
||||
refreshError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
// Apply canonical OAuth-token formatting while preserving raw API keys.
|
||||
const persisted = getProviderAuthHandler("cline")?.getApiKey(
|
||||
manager.getProviderSettings("cline"),
|
||||
);
|
||||
if (!persisted && refreshError && ctx) {
|
||||
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
|
||||
error: refreshError,
|
||||
});
|
||||
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
|
||||
errorName: refreshError.name,
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
});
|
||||
}
|
||||
// A rejected refresh token means the persisted access token is dead too.
|
||||
// Handing it out would turn the signed-out state into an opaque request
|
||||
// failure (an error card whose Retry fails the same way) instead of the
|
||||
// sign-in prompt. Transient refresh failures still fall back to it.
|
||||
if (refreshError instanceof OAuthReauthRequiredError) {
|
||||
return undefined;
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reconcileBufferedCloudEvents } from "./cloud-session-snapshots";
|
||||
|
||||
describe("reconcileBufferedCloudEvents", () => {
|
||||
const event = (
|
||||
name: HubEventEnvelope["event"],
|
||||
id: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
): HubEventEnvelope => ({
|
||||
version: "v1",
|
||||
event: name,
|
||||
eventId: id,
|
||||
timestamp: Date.now(),
|
||||
sessionId: "inner-1",
|
||||
payload,
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Continue",
|
||||
"",
|
||||
])("preserves submitted lifecycle while marking only newly reflected %j prompts", (prompt) => {
|
||||
const submitted = (id: string) =>
|
||||
event("session.pending_prompt_submitted", id, {
|
||||
prompt: {
|
||||
id,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
...(prompt
|
||||
? {}
|
||||
: {
|
||||
userImages: ["data:image/png;base64,AA=="],
|
||||
attachmentCount: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const first = submitted("q-1");
|
||||
const later = submitted("q-2");
|
||||
const baseline = [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt || [
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "AA==" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// An older identical user message must not consume a new submission.
|
||||
expect(
|
||||
reconcileBufferedCloudEvents([first], baseline, {
|
||||
baselineMessages: baseline,
|
||||
}),
|
||||
).toEqual([first]);
|
||||
const toolResult = {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "call-1", content: "done" },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
reconcileBufferedCloudEvents([first], [...baseline, toolResult], {
|
||||
baselineMessages: baseline,
|
||||
}),
|
||||
).toEqual([first]);
|
||||
const snapshot = [
|
||||
...baseline,
|
||||
toolResult,
|
||||
{
|
||||
...baseline[0],
|
||||
...(prompt
|
||||
? {
|
||||
content: [
|
||||
...toolResult.content,
|
||||
{ type: "text", text: `<user_input>${prompt}</user_input>` },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
const completed = event("run.completed", "done");
|
||||
const running = event("run.started", "next");
|
||||
// Keep the next turn's start between lifecycle events; only its bubble is reflected.
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
[completed, first, running, later],
|
||||
snapshot,
|
||||
{
|
||||
baselineMessages: baseline,
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
completed,
|
||||
{ ...first, payload: { ...first.payload, transcriptReflected: true } },
|
||||
running,
|
||||
later,
|
||||
]);
|
||||
// A submission received after the transcript reply cannot be in it.
|
||||
expect(
|
||||
reconcileBufferedCloudEvents([later], snapshot, {
|
||||
baselineMessages: baseline,
|
||||
messagesSnapshotEventCutoff: 0,
|
||||
}),
|
||||
).toEqual([later]);
|
||||
});
|
||||
|
||||
it("replays content the snapshot does NOT contain", () => {
|
||||
const buffered = [
|
||||
event("assistant.delta", "a-1", { text: "unpersisted reply" }),
|
||||
event("run.completed", "done-1"),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [
|
||||
{ role: "assistant", content: "a completely different answer" },
|
||||
]).map((item) => item.event),
|
||||
).toEqual(["assistant.delta", "run.completed"]);
|
||||
});
|
||||
|
||||
it("does not let an interior substring claim another run's snapshot", () => {
|
||||
const buffered = [
|
||||
event("assistant.finished", "a-1", { text: "foo" }),
|
||||
event("run.completed", "done-1"),
|
||||
event("assistant.finished", "a-2", { text: "The answer is foobar" }),
|
||||
event("run.completed", "done-2"),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [
|
||||
{ role: "assistant", content: "The answer is foobar" },
|
||||
]).map((item) => item.eventId),
|
||||
).toEqual(["a-1", "done-1", "done-2"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bar", "foobar", false],
|
||||
["bar", "foobar", true],
|
||||
["Done", "Done", false],
|
||||
] as const)("preserves aborted %j beside saved %j (reverse: %s)", (partial, saved, reverse) => {
|
||||
const aborted = [
|
||||
event("assistant.delta", "partial", { text: partial }),
|
||||
event("run.aborted", "aborted"),
|
||||
];
|
||||
const completed = [
|
||||
event("assistant.delta", "saved-delta", { text: saved }),
|
||||
event("assistant.finished", "saved-finished", { text: saved }),
|
||||
event("run.completed", "completed"),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
reverse ? [...completed, ...aborted] : [...aborted, ...completed],
|
||||
[{ role: "assistant", content: saved }],
|
||||
).map((item) => item.eventId),
|
||||
).toEqual(
|
||||
reverse
|
||||
? ["completed", "partial", "aborted"]
|
||||
: ["partial", "aborted", "completed"],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"assistant",
|
||||
"reasoning",
|
||||
] as const)("does not let finished %s hide the other aborted content", (finished) => {
|
||||
const partial = finished === "assistant" ? "reasoning" : "assistant";
|
||||
const buffered = [
|
||||
event(`${finished}.finished`, "saved", {
|
||||
text: "foobar",
|
||||
reasoning: "foobar",
|
||||
}),
|
||||
event(`${partial}.delta`, "partial", { text: "bar" }),
|
||||
event("run.aborted", "aborted"),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "foobar" },
|
||||
{ type: "thinking", thinking: "foobar" },
|
||||
],
|
||||
},
|
||||
]).map((item) => item.eventId),
|
||||
).toEqual(["partial", "aborted"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["assistant", false],
|
||||
["reasoning", false],
|
||||
["reasoning", true],
|
||||
] as const)("preserves unfinished %s after saved output (redacted: %s)", (kind, redacted) => {
|
||||
const saved = [
|
||||
event(`${kind}.delta`, "saved-delta", {
|
||||
text: redacted ? "" : "saved",
|
||||
redacted,
|
||||
}),
|
||||
event(`${kind}.finished`, "saved-finished", {
|
||||
[kind === "assistant" ? "text" : "reasoning"]: redacted
|
||||
? undefined
|
||||
: "saved",
|
||||
}),
|
||||
];
|
||||
const partial = event(`${kind}.delta`, "partial", { text: "unfinished" });
|
||||
const snapshot = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
redacted
|
||||
? { type: "redacted_thinking", data: "opaque" }
|
||||
: kind === "assistant"
|
||||
? { type: "text", text: "saved" }
|
||||
: { type: "thinking", thinking: "saved" },
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const terminal of ["run.aborted", "run.failed"] as const) {
|
||||
const end = event(terminal, "end");
|
||||
const buffered = [...saved, partial, end];
|
||||
expect(reconcileBufferedCloudEvents(buffered, snapshot)).toEqual([
|
||||
partial,
|
||||
end,
|
||||
]);
|
||||
const earlier = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
kind === "assistant"
|
||||
? { type: "text", text: "earlier" }
|
||||
: { type: "thinking", thinking: "earlier" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
[
|
||||
event(`${kind}.finished`, "earlier", {
|
||||
[kind === "assistant" ? "text" : "reasoning"]: "earlier",
|
||||
}),
|
||||
...buffered,
|
||||
],
|
||||
[...earlier, ...snapshot],
|
||||
{ baselineMessages: earlier },
|
||||
),
|
||||
).toEqual([partial, end]);
|
||||
expect(reconcileBufferedCloudEvents(buffered, [])).toEqual(buffered);
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, snapshot, {
|
||||
baselineMessages: snapshot,
|
||||
}),
|
||||
).toEqual(buffered);
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, snapshot, {
|
||||
messagesSnapshotEventCutoff: 1,
|
||||
}),
|
||||
).toEqual(
|
||||
terminal === "run.aborted" ? buffered : [saved[1], partial, end],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"assistant",
|
||||
"reasoning",
|
||||
] as const)("preserves %s output completed after the snapshot", (kind) => {
|
||||
const key = kind === "assistant" ? "text" : "reasoning";
|
||||
const saved = event(`${kind}.finished`, "saved", { [key]: "saved" });
|
||||
const partial = event(`${kind}.delta`, "partial", { text: "new reply" });
|
||||
const finished = event(`${kind}.finished`, "finished", {
|
||||
[key]: "new reply",
|
||||
});
|
||||
const end = event("run.completed", "end");
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
[saved, partial, finished, end],
|
||||
[
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: kind === "assistant" ? "text" : "thinking",
|
||||
[kind === "assistant" ? "text" : "thinking"]: "saved",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ messagesSnapshotEventCutoff: 2 },
|
||||
),
|
||||
).toEqual([partial, finished, end]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"run.completed",
|
||||
"run.aborted",
|
||||
"run.failed",
|
||||
] as const)("ignores empty finishes after saved content in %s", (terminal) => {
|
||||
const end = event(terminal, "end");
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
[
|
||||
event("assistant.finished", "saved", { text: "saved" }),
|
||||
event("assistant.finished", "empty", { text: "" }),
|
||||
event("assistant.finished", "missing"),
|
||||
end,
|
||||
],
|
||||
[{ role: "assistant", content: "saved" }],
|
||||
),
|
||||
).toEqual([end]);
|
||||
});
|
||||
|
||||
it("supersedes despite trailing whitespace in the streamed text", () => {
|
||||
const buffered = [
|
||||
event("assistant.finished", "f-1", { text: "the answer \n" }),
|
||||
event("run.completed", "done-1"),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [
|
||||
{ role: "assistant", content: "prefix the answer" },
|
||||
]).map((item) => item.event),
|
||||
).toEqual(["run.completed"]);
|
||||
});
|
||||
|
||||
it("drops buffered queue snapshots when a fresh queue snapshot was applied", () => {
|
||||
const buffered = [
|
||||
event("session.pending_prompts", "q-1", { prompts: [] }),
|
||||
event("assistant.delta", "a-1", { text: "live tail" }),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, []).map((item) => item.event),
|
||||
).toEqual(["assistant.delta"]);
|
||||
});
|
||||
|
||||
it("replays the newest buffered queue snapshot when the queue fetch failed", () => {
|
||||
const buffered = [
|
||||
event("session.pending_prompts", "q-1", { prompts: [] }),
|
||||
event("session.pending_prompts", "q-2", {
|
||||
prompts: [{ id: "p-1", prompt: "queued work" }],
|
||||
}),
|
||||
event("assistant.delta", "a-1", { text: "live tail" }),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [], {
|
||||
queueSnapshotApplied: false,
|
||||
}).map((item) => item.eventId),
|
||||
).toEqual(["q-2", "a-1"]);
|
||||
});
|
||||
|
||||
it("replays a queue snapshot received after the queue fetch", () => {
|
||||
const buffered = [
|
||||
event("session.pending_prompts", "q-old", { prompts: [] }),
|
||||
event("assistant.delta", "a-1", { text: "live tail" }),
|
||||
event("session.pending_prompts", "q-new", {
|
||||
prompts: [{ id: "p-1", prompt: "queued work" }],
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [], {
|
||||
queueSnapshotEventCutoff: 1,
|
||||
}).map((item) => item.eventId),
|
||||
).toEqual(["a-1", "q-new"]);
|
||||
});
|
||||
|
||||
it("supersedes content independently across two terminal run segments", () => {
|
||||
const buffered = [
|
||||
event("assistant.delta", "a-1", { text: "first tail" }),
|
||||
event("run.completed", "done-1"),
|
||||
event("assistant.delta", "a-2", { text: "second tail" }),
|
||||
event("run.completed", "done-2"),
|
||||
];
|
||||
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [
|
||||
{ role: "assistant", content: "prefix first tail" },
|
||||
{ role: "assistant", content: "prefix second tail" },
|
||||
]).map((item) => item.event),
|
||||
).toEqual(["run.completed", "run.completed"]);
|
||||
});
|
||||
|
||||
it("does not let an older identical reply supersede a new buffered turn", () => {
|
||||
const buffered = [
|
||||
event("assistant.delta", "a-2", { text: "Done" }),
|
||||
event("run.completed", "done-2"),
|
||||
];
|
||||
const baseline = [{ role: "assistant", content: "Done" }];
|
||||
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, baseline, {
|
||||
baselineMessages: baseline,
|
||||
}).map((item) => item.event),
|
||||
).toEqual(["assistant.delta", "run.completed"]);
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
buffered,
|
||||
[...baseline, { role: "assistant", content: "Done" }],
|
||||
{ baselineMessages: baseline },
|
||||
).map((item) => item.event),
|
||||
).toEqual(["run.completed"]);
|
||||
});
|
||||
|
||||
it("keeps run.failed while suppressing reflected content and dedupes tools by id", () => {
|
||||
const buffered = [
|
||||
event("assistant.delta", "a-1", { text: "partial failure" }),
|
||||
event("tool.started", "tool-1", { toolCallId: "call-1" }),
|
||||
event("run.failed", "failed-1", { error: "boom" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, [
|
||||
{ role: "assistant", content: "saved partial failure" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call-1", name: "read_file" }],
|
||||
},
|
||||
]).map((item) => item.event),
|
||||
).toEqual(["run.failed"]);
|
||||
});
|
||||
|
||||
it("only suppresses tool phases present in the transcript before its cutoff", () => {
|
||||
const started = event("tool.started", "start", { toolCallId: "call-1" });
|
||||
const finished = event("tool.finished", "finish", {
|
||||
toolCallId: "call-1",
|
||||
output: "done",
|
||||
});
|
||||
const snapshot = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call-1", name: "read_file" }],
|
||||
},
|
||||
];
|
||||
expect(reconcileBufferedCloudEvents([started, finished], snapshot)).toEqual(
|
||||
[finished],
|
||||
);
|
||||
const completedSnapshot = [
|
||||
...snapshot,
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "call-1", content: "done" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
reconcileBufferedCloudEvents([started, finished], completedSnapshot),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
reconcileBufferedCloudEvents([started, finished], completedSnapshot, {
|
||||
messagesSnapshotEventCutoff: 1,
|
||||
}),
|
||||
).toEqual([finished]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["run.failed", false],
|
||||
["run.aborted", false],
|
||||
["run.aborted", true],
|
||||
] as const)("does not replay persisted thinking for %s (redacted: %s)", (terminal, redacted) => {
|
||||
const buffered = [
|
||||
event("reasoning.delta", "thinking", {
|
||||
text: redacted ? "" : "Checking",
|
||||
redacted,
|
||||
}),
|
||||
event("reasoning.finished", "thought", {
|
||||
reasoning: redacted ? undefined : "Checking",
|
||||
}),
|
||||
event(terminal, "end"),
|
||||
];
|
||||
const snapshot = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
redacted
|
||||
? { type: "redacted_thinking", data: "opaque" }
|
||||
: { type: "thinking", thinking: "Checking" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(reconcileBufferedCloudEvents(buffered, snapshot)).toEqual([
|
||||
buffered[2],
|
||||
]);
|
||||
expect(reconcileBufferedCloudEvents(buffered, [])).toEqual(buffered);
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, snapshot, {
|
||||
baselineMessages: snapshot,
|
||||
}),
|
||||
).toEqual(buffered);
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(buffered, snapshot, {
|
||||
messagesSnapshotEventCutoff: 0,
|
||||
}),
|
||||
).toEqual(buffered);
|
||||
});
|
||||
|
||||
it("does not treat persisted assistant text as proof that thinking was saved", () => {
|
||||
const thinking = event("reasoning.delta", "thinking", { text: "Checking" });
|
||||
const done = event("run.completed", "done");
|
||||
expect(
|
||||
reconcileBufferedCloudEvents(
|
||||
[
|
||||
thinking,
|
||||
event("assistant.finished", "answer", { text: "Done" }),
|
||||
done,
|
||||
],
|
||||
[{ role: "assistant", content: "Done" }],
|
||||
),
|
||||
).toEqual([thinking, done]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { isUserRunMessage } from "@cline/core";
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import type { JsonRecord, PromptInQueue } from "./types";
|
||||
|
||||
export function readSessionRows(
|
||||
payload: Record<string, unknown> | undefined,
|
||||
): JsonRecord[] {
|
||||
return Array.isArray(payload?.sessions)
|
||||
? payload.sessions.filter(
|
||||
(item): item is JsonRecord =>
|
||||
Boolean(item) && typeof item === "object" && !Array.isArray(item),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function updatedAt(record: JsonRecord): number {
|
||||
const value = record.updatedAt;
|
||||
return typeof value === "number"
|
||||
? value
|
||||
: Date.parse(String(value ?? "")) || 0;
|
||||
}
|
||||
|
||||
export function sessionRowModelId(record: JsonRecord | undefined): string {
|
||||
const metadata =
|
||||
record?.metadata && typeof record.metadata === "object"
|
||||
? (record.metadata as JsonRecord)
|
||||
: undefined;
|
||||
return String(metadata?.model ?? record?.model ?? "").trim();
|
||||
}
|
||||
|
||||
export function isRootSessionRow(record: JsonRecord): boolean {
|
||||
const metadata =
|
||||
record.metadata && typeof record.metadata === "object"
|
||||
? (record.metadata as JsonRecord)
|
||||
: undefined;
|
||||
return !String(
|
||||
metadata?.parentSessionId ?? record.parentSessionId ?? "",
|
||||
).trim();
|
||||
}
|
||||
|
||||
function messageText(
|
||||
message: unknown,
|
||||
kind: "text" | "thinking" = "text",
|
||||
): string {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return "";
|
||||
}
|
||||
const content = (message as JsonRecord).content;
|
||||
if (typeof content === "string") {
|
||||
return kind === "text" ? content.trim() : "";
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.map((part) =>
|
||||
part && typeof part === "object" && !Array.isArray(part)
|
||||
? kind === "thinking" &&
|
||||
(part as JsonRecord).type === "redacted_thinking"
|
||||
? "[redacted]"
|
||||
: String((part as JsonRecord)[kind] ?? "")
|
||||
: "",
|
||||
)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeUserPrompt(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const match = trimmed.match(/^<user_input\b[^>]*>([\s\S]*)<\/user_input>$/);
|
||||
return (match ? match[1] : trimmed).trim();
|
||||
}
|
||||
|
||||
export function countPromptOccurrences(
|
||||
messages: unknown[],
|
||||
prompts: PromptInQueue[],
|
||||
prompt: string,
|
||||
): number {
|
||||
const expected = normalizeUserPrompt(prompt);
|
||||
return (
|
||||
messages.filter(
|
||||
(message) =>
|
||||
Boolean(message) &&
|
||||
typeof message === "object" &&
|
||||
!Array.isArray(message) &&
|
||||
String((message as JsonRecord).role ?? "").toLowerCase() === "user" &&
|
||||
(expected !== "" || isUserRunMessage(message as JsonRecord)) &&
|
||||
normalizeUserPrompt(messageText(message)) === expected,
|
||||
).length +
|
||||
prompts.filter((item) => normalizeUserPrompt(item.prompt) === expected)
|
||||
.length
|
||||
);
|
||||
}
|
||||
|
||||
export function submittedPromptsFromEvents(
|
||||
events: HubEventEnvelope[],
|
||||
): PromptInQueue[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.event !== "session.pending_prompt_submitted") return [];
|
||||
const prompt =
|
||||
event.payload?.prompt &&
|
||||
typeof event.payload.prompt === "object" &&
|
||||
!Array.isArray(event.payload.prompt)
|
||||
? (event.payload.prompt as JsonRecord)
|
||||
: undefined;
|
||||
const id = String(prompt?.id ?? "").trim();
|
||||
if (!id) return [];
|
||||
return [
|
||||
{
|
||||
id,
|
||||
prompt: String(prompt?.prompt ?? ""),
|
||||
steer: prompt?.delivery === "steer",
|
||||
attachmentCount:
|
||||
typeof prompt?.attachmentCount === "number"
|
||||
? prompt.attachmentCount
|
||||
: 0,
|
||||
userImages: Array.isArray(prompt?.userImages)
|
||||
? prompt.userImages.filter(
|
||||
(image): image is string => typeof image === "string",
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_EVENTS = new Set([
|
||||
"run.completed",
|
||||
"run.aborted",
|
||||
"run.failed",
|
||||
]);
|
||||
const SUPERSEDABLE_CONTENT_EVENTS = new Set([
|
||||
"assistant.delta",
|
||||
"assistant.finished",
|
||||
"reasoning.delta",
|
||||
"reasoning.finished",
|
||||
]);
|
||||
|
||||
function assistantTexts(
|
||||
messages: unknown[],
|
||||
kind: "text" | "thinking",
|
||||
): string[] {
|
||||
return messages
|
||||
.filter(
|
||||
(message): message is JsonRecord =>
|
||||
Boolean(message) &&
|
||||
typeof message === "object" &&
|
||||
!Array.isArray(message) &&
|
||||
String((message as JsonRecord).role ?? "").toLowerCase() ===
|
||||
"assistant",
|
||||
)
|
||||
.map((message) => messageText(message, kind))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function newlyPersistedAssistantTexts(
|
||||
snapshotMessages: unknown[],
|
||||
baselineMessages: unknown[],
|
||||
kind: "text" | "thinking" = "text",
|
||||
): string[] {
|
||||
const baselineCounts = new Map<string, number>();
|
||||
for (const text of assistantTexts(baselineMessages, kind)) {
|
||||
baselineCounts.set(text, (baselineCounts.get(text) ?? 0) + 1);
|
||||
}
|
||||
return assistantTexts(snapshotMessages, kind).filter((text) => {
|
||||
const count = baselineCounts.get(text) ?? 0;
|
||||
if (count === 0) return true;
|
||||
if (count === 1) baselineCounts.delete(text);
|
||||
else baselineCounts.set(text, count - 1);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function collectToolCallIds(
|
||||
value: unknown,
|
||||
phase: "tool_use" | "tool_result",
|
||||
result = new Set<string>(),
|
||||
): Set<string> {
|
||||
if (!value || typeof value !== "object") return result;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectToolCallIds(item, phase, result);
|
||||
return result;
|
||||
}
|
||||
const record = value as JsonRecord;
|
||||
const id = phase === "tool_use" ? record.id : record.tool_use_id;
|
||||
if (record.type === phase && typeof id === "string") {
|
||||
result.add(id);
|
||||
}
|
||||
for (const child of Object.values(record))
|
||||
collectToolCallIds(child, phase, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function streamedAssistantText(
|
||||
events: HubEventEnvelope[],
|
||||
kind: "assistant" | "reasoning" = "assistant",
|
||||
): string {
|
||||
let finishedText = "";
|
||||
let deltas = "";
|
||||
for (const event of events) {
|
||||
if (event.event === `${kind}.delta`) {
|
||||
deltas +=
|
||||
kind === "reasoning" && event.payload?.redacted && !event.payload?.text
|
||||
? "[redacted]"
|
||||
: typeof event.payload?.text === "string"
|
||||
? event.payload.text
|
||||
: "";
|
||||
} else if (event.event === `${kind}.finished`) {
|
||||
const text = event.payload?.[kind === "assistant" ? "text" : "reasoning"];
|
||||
const completed = typeof text === "string" && text ? text : deltas;
|
||||
if (completed) finishedText = completed;
|
||||
deltas = "";
|
||||
}
|
||||
}
|
||||
return (finishedText || deltas).trim();
|
||||
}
|
||||
|
||||
/** Reconciles each completed run separately; tools dedupe by stable call id. */
|
||||
export function reconcileBufferedCloudEvents(
|
||||
events: HubEventEnvelope[],
|
||||
snapshotMessages: unknown[],
|
||||
options: {
|
||||
/**
|
||||
* Whether a fresh queue snapshot was fetched and applied during
|
||||
* rehydration. When it was, queue events received before its reply are
|
||||
* stale; later events still win. When the fetch failed, the newest
|
||||
* buffered queue event is the best state available.
|
||||
*/
|
||||
queueSnapshotApplied?: boolean;
|
||||
queueSnapshotEventCutoff?: number;
|
||||
/** Events received after the transcript reply cannot be reflected in it. */
|
||||
messagesSnapshotEventCutoff?: number;
|
||||
baselineMessages?: unknown[];
|
||||
} = {},
|
||||
): HubEventEnvelope[] {
|
||||
const queueSnapshotApplied = options.queueSnapshotApplied !== false;
|
||||
const unclaimedAssistantTexts = newlyPersistedAssistantTexts(
|
||||
snapshotMessages,
|
||||
options.baselineMessages ?? [],
|
||||
);
|
||||
const unclaimedThinking = newlyPersistedAssistantTexts(
|
||||
snapshotMessages,
|
||||
options.baselineMessages ?? [],
|
||||
"thinking",
|
||||
);
|
||||
const snapshotToolCallIds = collectToolCallIds(snapshotMessages, "tool_use");
|
||||
const snapshotToolResultIds = collectToolCallIds(
|
||||
snapshotMessages,
|
||||
"tool_result",
|
||||
);
|
||||
const beforeTranscript = new Set(
|
||||
events.slice(0, options.messagesSnapshotEventCutoff ?? events.length),
|
||||
);
|
||||
const reflectedSubmissions = new Set<HubEventEnvelope>();
|
||||
const unclaimedUserCounts = new Map<string, number>();
|
||||
for (const event of events.slice(
|
||||
0,
|
||||
options.messagesSnapshotEventCutoff ?? events.length,
|
||||
)) {
|
||||
const submitted = submittedPromptsFromEvents([event])[0];
|
||||
if (!submitted) continue;
|
||||
const prompt = normalizeUserPrompt(submitted.prompt);
|
||||
const count =
|
||||
unclaimedUserCounts.get(prompt) ??
|
||||
Math.max(
|
||||
0,
|
||||
countPromptOccurrences(snapshotMessages, [], prompt) -
|
||||
countPromptOccurrences(options.baselineMessages ?? [], [], prompt),
|
||||
);
|
||||
unclaimedUserCounts.set(prompt, Math.max(0, count - 1));
|
||||
if (count > 0) reflectedSubmissions.add(event);
|
||||
}
|
||||
// Queue events are full snapshots, so only the newest one matters.
|
||||
const queueEvents = queueSnapshotApplied
|
||||
? events.slice(options.queueSnapshotEventCutoff ?? events.length)
|
||||
: events;
|
||||
const lastQueueEvent = queueEvents.findLast(
|
||||
(event) => event.event === "session.pending_prompts",
|
||||
);
|
||||
const reconciled: HubEventEnvelope[] = [];
|
||||
let segment: HubEventEnvelope[] = [];
|
||||
|
||||
const flush = (terminal: boolean) => {
|
||||
if (segment.length === 0) return;
|
||||
const snapshotSegment = segment.filter((event) =>
|
||||
beforeTranscript.has(event),
|
||||
);
|
||||
// A buffered run can contain saved replies followed by unsaved output.
|
||||
const contentEnd = (kind: "assistant" | "reasoning") => {
|
||||
if (!terminal) return -1;
|
||||
const finished = snapshotSegment.findLastIndex(
|
||||
(event) => event.event === `${kind}.finished`,
|
||||
);
|
||||
return finished >= 0 || segment.at(-1)?.event === "run.aborted"
|
||||
? finished
|
||||
: snapshotSegment.length - 1;
|
||||
};
|
||||
const assistantEnd = contentEnd("assistant");
|
||||
const reasoningEnd = contentEnd("reasoning");
|
||||
const streamed = streamedAssistantText(
|
||||
snapshotSegment.slice(0, assistantEnd + 1),
|
||||
);
|
||||
const persistedIndex = streamed
|
||||
? unclaimedAssistantTexts.findIndex((text) => text.endsWith(streamed))
|
||||
: -1;
|
||||
const contentPersisted = persistedIndex >= 0;
|
||||
if (contentPersisted) unclaimedAssistantTexts.splice(persistedIndex, 1);
|
||||
const thinking = streamedAssistantText(
|
||||
snapshotSegment.slice(0, reasoningEnd + 1),
|
||||
"reasoning",
|
||||
);
|
||||
const thinkingIndex = thinking
|
||||
? unclaimedThinking.findIndex((text) => text.endsWith(thinking))
|
||||
: -1;
|
||||
if (thinkingIndex >= 0) unclaimedThinking.splice(thinkingIndex, 1);
|
||||
for (const [index, event] of segment.entries()) {
|
||||
// Preserve the turn-start lifecycle; the UI must only skip its user bubble.
|
||||
if (reflectedSubmissions.has(event)) {
|
||||
reconciled.push({
|
||||
...event,
|
||||
payload: { ...event.payload, transcriptReflected: true },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
beforeTranscript.has(event) &&
|
||||
SUPERSEDABLE_CONTENT_EVENTS.has(event.event) &&
|
||||
(event.event.startsWith("reasoning.")
|
||||
? thinkingIndex >= 0 && index <= reasoningEnd
|
||||
: contentPersisted && index <= assistantEnd)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
event.event === "session.pending_prompts" &&
|
||||
event !== lastQueueEvent
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Keep terminal events: run.failed may carry the only error detail.
|
||||
if (beforeTranscript.has(event) && event.event.startsWith("tool.")) {
|
||||
const toolCallId = String(event.payload?.toolCallId ?? "").trim();
|
||||
if (
|
||||
snapshotToolResultIds.has(toolCallId) ||
|
||||
(event.event === "tool.started" &&
|
||||
snapshotToolCallIds.has(toolCallId))
|
||||
)
|
||||
continue;
|
||||
}
|
||||
reconciled.push(event);
|
||||
}
|
||||
segment = [];
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
segment.push(event);
|
||||
if (TERMINAL_RUN_EVENTS.has(event.event)) flush(true);
|
||||
}
|
||||
// Never supersede an unterminated tail.
|
||||
flush(false);
|
||||
return reconciled;
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CloudSessionApi,
|
||||
CloudSessionError,
|
||||
type CloudSessionRecord,
|
||||
} from "./cloud-sessions";
|
||||
|
||||
const REMOTE_SESSION: CloudSessionRecord = {
|
||||
id: "ses-outer",
|
||||
status: "ready",
|
||||
sandboxUrl: "https://pod.example/hub",
|
||||
repoContext: { repoUrl: "https://github.com/cline/test" },
|
||||
metadata: { modelId: "anthropic/claude-sonnet-5" },
|
||||
createdAt: "2026-08-05T10:00:00.000Z",
|
||||
updatedAt: "2026-08-05T10:01:00.000Z",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function jwtFor(subject: string, nonce: string): string {
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return (
|
||||
"workos:" +
|
||||
encode({ alg: "none" }) +
|
||||
"." +
|
||||
encode({ sub: subject, nonce }) +
|
||||
".sig"
|
||||
);
|
||||
}
|
||||
|
||||
describe("CloudSessionApi", () => {
|
||||
it("resolves a fresh bearer token for every REST request", async () => {
|
||||
const tokens = ["workos:first", "workos:second"];
|
||||
const authorizations: string[] = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example/",
|
||||
appBaseUrl: "https://app.example/",
|
||||
getAuthToken: async () => tokens.shift(),
|
||||
fetch: async (_input, init) => {
|
||||
authorizations.push(
|
||||
new Headers(init?.headers).get("Authorization") ?? "",
|
||||
);
|
||||
return jsonResponse({ success: true, data: [] });
|
||||
},
|
||||
});
|
||||
|
||||
await api.list();
|
||||
await api.list();
|
||||
|
||||
expect(authorizations).toEqual([
|
||||
"Bearer workos:first",
|
||||
"Bearer workos:second",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the dashboard create body and includes branch only when requested", async () => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async (_input, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return jsonResponse(
|
||||
{ success: true, data: { sessionId: "ses-1", sandboxUrl: "pod" } },
|
||||
201,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await api.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
branch: "feature/login-fix",
|
||||
});
|
||||
await api.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
branch: "feature/login-fix",
|
||||
title: expect.stringMatching(/^__cline_create_request__:/),
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
title: expect.stringMatching(/^__cline_create_request__:/),
|
||||
});
|
||||
expect(bodies[1]).not.toHaveProperty("branch");
|
||||
});
|
||||
|
||||
it("treats a missing history snapshot (404) as null, not an empty archive", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async () => new Response("not found", { status: 404 }),
|
||||
});
|
||||
|
||||
expect(await api.history("ses-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts v1 history and rejects malformed snapshots instead of returning empty history", async () => {
|
||||
const messages = [{ role: "user", content: "Hello" }];
|
||||
let snapshot: unknown = { version: 1, messages };
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async () => jsonResponse(snapshot),
|
||||
});
|
||||
|
||||
expect(await api.history("ses-1")).toEqual(messages);
|
||||
snapshot = { version: 1, messages: [] };
|
||||
expect(await api.history("ses-1")).toEqual([]);
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ version: 1 },
|
||||
{ version: 2, messages: [] },
|
||||
]) {
|
||||
snapshot = invalid;
|
||||
await expect(api.history("ses-1")).rejects.toMatchObject({
|
||||
code: "request_failed",
|
||||
detail: "Invalid archived session history",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the real id before polling readiness and reports provisioning phases", async () => {
|
||||
vi.useFakeTimers();
|
||||
const tokens = ["workos:create", "workos:create", "workos:new-account"];
|
||||
const authorizations: string[] = [];
|
||||
let statusCalls = 0;
|
||||
const phases: Array<string | undefined> = [];
|
||||
try {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => tokens.shift(),
|
||||
fetch: async (input, init) => {
|
||||
authorizations.push(
|
||||
new Headers(init?.headers).get("Authorization") ?? "",
|
||||
);
|
||||
const url = new URL(String(input));
|
||||
if (init?.method === "POST") {
|
||||
return jsonResponse(
|
||||
{
|
||||
success: true,
|
||||
data: { sessionId: "ses-1", status: "provisioning" },
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
expect(url.pathname).toBe("/api/v1/session/ses-1/status");
|
||||
statusCalls += 1;
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: "ses-1",
|
||||
status: statusCalls === 1 ? "provisioning" : "ready",
|
||||
phase: statusCalls === 1 ? "cloning_repo" : "ready",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const created = await api.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
sessionId: "ses-1",
|
||||
status: "provisioning",
|
||||
});
|
||||
expect(statusCalls).toBe(0);
|
||||
const ready = api.waitUntilReady(
|
||||
created.sessionId,
|
||||
new AbortController().signal,
|
||||
({ phase }) => phases.push(phase),
|
||||
);
|
||||
await vi.waitFor(() => expect(statusCalls).toBe(1));
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
await expect(ready).resolves.toBeUndefined();
|
||||
expect(statusCalls).toBe(2);
|
||||
expect(phases).toEqual(["cloning_repo", "ready"]);
|
||||
expect(authorizations).toEqual([
|
||||
"Bearer workos:create",
|
||||
"Bearer workos:create",
|
||||
"Bearer workos:create",
|
||||
]);
|
||||
expect(tokens).toEqual(["workos:new-account"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes an expired provisioning token without switching accounts", async () => {
|
||||
const original = jwtFor("user-1", "original");
|
||||
const refreshed = jwtFor("user-1", "refreshed");
|
||||
const tokens = [original, refreshed];
|
||||
const authorizations: string[] = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => tokens.shift(),
|
||||
fetch: async (_input, init) => {
|
||||
const authorization =
|
||||
new Headers(init?.headers).get("Authorization") ?? "";
|
||||
authorizations.push(authorization);
|
||||
if (authorization === `Bearer ${original}`) {
|
||||
return jsonResponse(
|
||||
{ success: false, error: "authentication required" },
|
||||
401,
|
||||
);
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { sessionId: "ses-1", status: "ready" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.waitUntilReady("ses-1", new AbortController().signal),
|
||||
).resolves.toBeUndefined();
|
||||
expect(authorizations).toEqual([
|
||||
`Bearer ${original}`,
|
||||
`Bearer ${refreshed}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not switch accounts while refreshing provisioning auth", async () => {
|
||||
const original = jwtFor("user-1", "original");
|
||||
const otherAccount = jwtFor("user-2", "refreshed");
|
||||
const tokens = [original, otherAccount];
|
||||
let statusCalls = 0;
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => tokens.shift(),
|
||||
fetch: async () => {
|
||||
statusCalls += 1;
|
||||
return jsonResponse(
|
||||
{ success: false, error: "authentication required" },
|
||||
401,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.waitUntilReady("ses-1", new AbortController().signal),
|
||||
).rejects.toMatchObject({ code: "authentication_required" });
|
||||
expect(statusCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("returns a recovered real id without waiting for provisioning", async () => {
|
||||
const requests: string[] = [];
|
||||
let recoveryTitle = "";
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
fetch: async (input, init) => {
|
||||
requests.push(
|
||||
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
|
||||
);
|
||||
if (init?.method === "POST") {
|
||||
recoveryTitle = String(JSON.parse(String(init.body)).title);
|
||||
return jsonResponse({ success: false, error: "gateway" }, 500);
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
...REMOTE_SESSION,
|
||||
id: "ses-recovered",
|
||||
title: recoveryTitle,
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: "ses-recovered",
|
||||
status: "provisioning",
|
||||
});
|
||||
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
|
||||
});
|
||||
|
||||
it("recovers the real id after the create request times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const requests: string[] = [];
|
||||
let recoveryTitle = "";
|
||||
try {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
createTimeoutMs: 100,
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
fetch: async (input, init) => {
|
||||
requests.push(
|
||||
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
|
||||
);
|
||||
if (init?.method === "POST") {
|
||||
recoveryTitle = String(JSON.parse(String(init.body)).title);
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
...REMOTE_SESSION,
|
||||
id: "ses-recovered",
|
||||
title: recoveryTitle,
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const creating = api.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await expect(creating).resolves.toMatchObject({
|
||||
sessionId: "ses-recovered",
|
||||
});
|
||||
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a failed recovered session without hiding its real id", async () => {
|
||||
let recoveryTitle = "";
|
||||
const requests: string[] = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
fetch: async (input, init) => {
|
||||
requests.push(
|
||||
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
|
||||
);
|
||||
if (init?.method === "POST") {
|
||||
recoveryTitle = String(JSON.parse(String(init.body)).title);
|
||||
return jsonResponse({ success: false, error: "gateway" }, 500);
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
...REMOTE_SESSION,
|
||||
title: recoveryTitle,
|
||||
status: "failed",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.create({
|
||||
modelId: REMOTE_SESSION.metadata.modelId ?? "",
|
||||
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
|
||||
}),
|
||||
).resolves.toMatchObject({ sessionId: "ses-outer", status: "failed" });
|
||||
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
|
||||
});
|
||||
|
||||
it("recovers a create accepted before a raw network failure", async () => {
|
||||
let recoveryTitle = "";
|
||||
let listCalls = 0;
|
||||
const now = new Date().toISOString();
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
recoveryTitle = String(JSON.parse(String(init.body)).title);
|
||||
throw new TypeError("fetch failed");
|
||||
}
|
||||
listCalls += 1;
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
...REMOTE_SESSION,
|
||||
id: "ses-recovered",
|
||||
title: recoveryTitle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.create({
|
||||
requestId: "request-a",
|
||||
modelId: REMOTE_SESSION.metadata.modelId ?? "",
|
||||
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
|
||||
}),
|
||||
).resolves.toMatchObject({ sessionId: "ses-recovered" });
|
||||
expect(listCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not recover another process's identical session", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async (_input, init) =>
|
||||
init?.method === "POST"
|
||||
? jsonResponse({ success: false, error: "gateway timeout" }, 500)
|
||||
: jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
...REMOTE_SESSION,
|
||||
id: "ses-other-process",
|
||||
title: "__cline_create_request__:other-request",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.create({
|
||||
requestId: "this-request",
|
||||
modelId: REMOTE_SESSION.metadata.modelId ?? "",
|
||||
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "request_failed" });
|
||||
});
|
||||
|
||||
it("hides temporary create request titles from session lists", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async () =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
...REMOTE_SESSION,
|
||||
title: "__cline_create_request__:request-a",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(api.list()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "ses-outer",
|
||||
title: undefined,
|
||||
metadata: expect.objectContaining({
|
||||
createRequestTitle: "__cline_create_request__:request-a",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a stable, environment-aware GitHub connection error", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://staging-app.example/",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async () =>
|
||||
jsonResponse({ success: false, error: "GitHub is not connected" }, 412),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.create({ modelId: "model", repoUrl: "https://github.com/cline/test" })
|
||||
.catch((caught) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(CloudSessionError);
|
||||
expect(error.code).toBe("github_not_connected");
|
||||
expect(error.message).toBe(
|
||||
'CLOUD_SESSION_ERROR:{"code":"github_not_connected","message":"GitHub is not connected","connectUrl":"https://staging-app.example/dashboard/integrations"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("routes organization GitHub setup to organization integrations", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://staging-app.example/",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async () =>
|
||||
jsonResponse({ success: false, error: "GitHub is not connected" }, 412),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.create({
|
||||
modelId: "model",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
organizationId: "org-cline-bot",
|
||||
})
|
||||
.catch((caught) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(CloudSessionError);
|
||||
expect(error.connectUrl).toBe(
|
||||
"https://staging-app.example/dashboard/organization/integrations",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists connected GitHub repositories and their branches", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
requestedPaths.push(path);
|
||||
if (path.endsWith("/branches")) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: [{ name: "main" }, { name: "feature/cloud" }],
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: 42,
|
||||
name: "cline",
|
||||
full_name: "cline/cline",
|
||||
html_url: "https://github.com/cline/cline",
|
||||
clone_url: "https://github.com/cline/cline.git",
|
||||
default_branch: "main",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(await api.listRepositories()).toEqual({
|
||||
connected: true,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [
|
||||
{
|
||||
id: 42,
|
||||
name: "cline",
|
||||
fullName: "cline/cline",
|
||||
url: "https://github.com/cline/cline",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await api.listBranches(42)).toEqual({
|
||||
available: true,
|
||||
branches: ["main", "feature/cloud"],
|
||||
nextToken: "",
|
||||
});
|
||||
expect(requestedPaths).toEqual([
|
||||
"/api/v1/integrations/github/repositories",
|
||||
"/api/v1/integrations/github/repositories/42/branches",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads paginated branch responses and forwards search cursors", async () => {
|
||||
let requestedUrl = "";
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async (input) => {
|
||||
requestedUrl = String(input);
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ name: "feature/cloud" }],
|
||||
nextToken: "next/page",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await api.listBranches(42, undefined, {
|
||||
cursor: "search cursor",
|
||||
query: "feature/cloud",
|
||||
}),
|
||||
).toEqual({
|
||||
available: true,
|
||||
branches: ["feature/cloud"],
|
||||
nextToken: "next/page",
|
||||
});
|
||||
const url = new URL(requestedUrl);
|
||||
expect(url.pathname).toBe(
|
||||
"/api/v1/integrations/github/repositories/42/branches",
|
||||
);
|
||||
expect(url.searchParams.get("query")).toBe("feature/cloud");
|
||||
expect(url.searchParams.get("cursor")).toBe("search cursor");
|
||||
});
|
||||
|
||||
it("filters legacy branch responses while backends roll out", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async () =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: [{ name: "main" }, { name: "feature/cloud" }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await api.listBranches(42, undefined, { query: "FEATURE" })).toEqual(
|
||||
{
|
||||
available: true,
|
||||
branches: ["feature/cloud"],
|
||||
nextToken: "",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the repository default when the branch API is unavailable", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async () =>
|
||||
jsonResponse({ success: false, error: "route not found" }, 404),
|
||||
});
|
||||
|
||||
expect(await api.listBranches(42)).toEqual({
|
||||
available: false,
|
||||
branches: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses organization-scoped repository and branch endpoints", async () => {
|
||||
const requestedPaths: string[] = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
requestedPaths.push(path);
|
||||
return jsonResponse({ success: true, data: [] });
|
||||
},
|
||||
});
|
||||
|
||||
expect(await api.listRepositories("org-cline-bot")).toMatchObject({
|
||||
connected: true,
|
||||
connectUrl: "https://app.example/dashboard/organization/integrations",
|
||||
});
|
||||
await api.listBranches(42, "org-cline-bot");
|
||||
expect(requestedPaths).toEqual([
|
||||
"/api/v1/organizations/org-cline-bot/integrations/github/repositories",
|
||||
"/api/v1/organizations/org-cline-bot/integrations/github/repositories/42/branches",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses ambiguous recovery for overlapping identical create requests", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const record = (id: string, createdAt: string) => ({
|
||||
id,
|
||||
title: "__cline_create_request__:same-request",
|
||||
status: "running",
|
||||
sandboxUrl: `pod-${id}`,
|
||||
repoContext: { repoUrl: "https://github.com/cline/test" },
|
||||
metadata: { modelId: "anthropic/claude-sonnet-5" },
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async (_input, init) =>
|
||||
init?.method === "POST"
|
||||
? jsonResponse({ success: false, error: "gateway timeout" }, 500)
|
||||
: jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
record("ses-newer", now),
|
||||
record("ses-older", new Date(Date.now() - 1_000).toISOString()),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const input = {
|
||||
requestId: "same-request",
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
};
|
||||
|
||||
const error = await api.create(input).catch((caught) => caught);
|
||||
expect(error).toMatchObject({ code: "request_failed" });
|
||||
expect(String(error)).toContain("ambiguous result");
|
||||
});
|
||||
|
||||
it("reports provisioning failure without deleting the known session", async () => {
|
||||
const authorizations: string[] = [];
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:create",
|
||||
fetch: async (input, init) => {
|
||||
authorizations.push(
|
||||
new Headers(init?.headers).get("Authorization") ?? "",
|
||||
);
|
||||
expect(new URL(String(input)).pathname).toBe(
|
||||
"/api/v1/session/ses-failed/status",
|
||||
);
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: "ses-failed",
|
||||
status: "failed",
|
||||
statusReason: "clone failed",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.waitUntilReady("ses-failed", new AbortController().signal),
|
||||
).rejects.toMatchObject({ code: "session_failed", detail: "clone failed" });
|
||||
expect(authorizations).toEqual(["Bearer workos:create"]);
|
||||
});
|
||||
|
||||
it("turns a generic forbidden response into actionable account guidance", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async () =>
|
||||
jsonResponse({ success: false, error: "forbidden" }, 403),
|
||||
});
|
||||
|
||||
const error = await api
|
||||
.create({ modelId: "model", repoUrl: "https://github.com/cline/test" })
|
||||
.catch((caught) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(CloudSessionError);
|
||||
expect(error.code).toBe("request_failed");
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toContain(
|
||||
"Switch to Personal or another organization in Settings → Account",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not run list recovery after a fast client-side rejection", async () => {
|
||||
let listRequests = 0;
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example",
|
||||
getAuthToken: async () => "sk_test",
|
||||
fetch: async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
return jsonResponse({ success: false, error: "invalid branch" }, 422);
|
||||
}
|
||||
listRequests += 1;
|
||||
return jsonResponse({ success: true, data: [] });
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
}),
|
||||
).rejects.toThrow(/invalid branch/);
|
||||
expect(listRequests).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the GitHub connection action when no integration exists", async () => {
|
||||
const api = new CloudSessionApi({
|
||||
apiBaseUrl: "https://api.example",
|
||||
appBaseUrl: "https://app.example/",
|
||||
getAuthToken: async () => "workos:test",
|
||||
fetch: async () =>
|
||||
jsonResponse({ success: false, error: "not connected" }, 404),
|
||||
});
|
||||
|
||||
expect(await api.listRepositories()).toEqual({
|
||||
connected: false,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type CloudSessionApi,
|
||||
CloudSessionError,
|
||||
CloudSessionManager,
|
||||
type CloudSessionRecord,
|
||||
cloudSessionToDiscoveryRecord,
|
||||
} from "./cloud-sessions";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const REMOTE_SESSION: CloudSessionRecord = {
|
||||
id: "ses-outer",
|
||||
status: "ready",
|
||||
sandboxUrl: "https://pod.example/hub",
|
||||
repoContext: { repoUrl: "https://github.com/cline/test" },
|
||||
metadata: { modelId: "anthropic/claude-sonnet-5" },
|
||||
createdAt: "2026-08-05T10:00:00.000Z",
|
||||
updatedAt: "2026-08-05T10:01:00.000Z",
|
||||
};
|
||||
|
||||
function createContext(): { ctx: SidecarContext } {
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
coreStreamActivity: new Map(),
|
||||
bootId: "cloud-test-boot",
|
||||
wsClients: new Set([
|
||||
{
|
||||
data: { canApproveTools: true },
|
||||
send() {},
|
||||
},
|
||||
]),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
runtimeBindings: new Map(),
|
||||
sessionEnvironmentIds: new Map(),
|
||||
activeEnvironmentId: "local",
|
||||
remoteEnvironments: null,
|
||||
localWorkspaceRoot: "/local/workspace",
|
||||
cloudSessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot: "/local/workspace",
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
} as SidecarContext;
|
||||
return { ctx };
|
||||
}
|
||||
|
||||
describe("CloudSessionManager lifecycle", () => {
|
||||
it.each([
|
||||
"upstream request failed",
|
||||
"couldn't authenticate with GitHub; try reconnecting the integration",
|
||||
])("surfaces create failure without retrying: %s", async (message) => {
|
||||
const { ctx } = createContext();
|
||||
const create = vi.fn(async () => {
|
||||
throw new CloudSessionError("request_failed", message, undefined, 502);
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: { create } as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.create({
|
||||
modelId: "model",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
}),
|
||||
).rejects.toThrow(message);
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("projects the outer remote-session id as the desktop session id", () => {
|
||||
expect(
|
||||
cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
repoContext: {
|
||||
...REMOTE_SESSION.repoContext,
|
||||
branch: "feature/cloud",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
sessionId: "ses-outer",
|
||||
origin: "cloud",
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
workspaceRoot: "/workspace",
|
||||
branch: "feature/cloud",
|
||||
metadata: {
|
||||
git: {
|
||||
url: "https://github.com/cline/test",
|
||||
branch: "feature/cloud",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a live session's future expiredAt as a TTL, not an end time", () => {
|
||||
const alive = cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
expiredAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(alive.endedAt).toBeUndefined();
|
||||
|
||||
const expired = cloudSessionToDiscoveryRecord({
|
||||
...REMOTE_SESSION,
|
||||
expiredAt: "2026-08-01T00:00:00.000Z",
|
||||
});
|
||||
expect(expired.endedAt).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("overlays live status and prompt-derived title on refreshed REST rows", async () => {
|
||||
const { ctx } = createContext();
|
||||
ctx.liveSessions.set("ses-outer", {
|
||||
config: { executionTarget: "cloud" },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
prompt: "Fix reconnect behavior\nwith a regression test",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
|
||||
const [session] = await manager.listForDiscovery();
|
||||
|
||||
expect(session).toMatchObject({
|
||||
sessionId: "ses-outer",
|
||||
origin: "cloud",
|
||||
status: "running",
|
||||
prompt: "Fix reconnect behavior\nwith a regression test",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
metadata: {
|
||||
title: "Fix reconnect behavior",
|
||||
origin: "cloud",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"expired",
|
||||
"failed",
|
||||
] as const)("reconciles a %s session without a Hub connection", async (status) => {
|
||||
const { ctx } = createContext();
|
||||
const remote: CloudSessionRecord = { ...REMOTE_SESSION };
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
create: async () => ({
|
||||
sessionId: remote.id,
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
}),
|
||||
list: async () => [remote],
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
await manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
expect((await manager.listForDiscovery())[0].status).toBe("ready");
|
||||
|
||||
const endedAt = new Date(Date.now() - 1_000).toISOString();
|
||||
if (status === "expired") {
|
||||
remote.expiredAt = endedAt;
|
||||
} else {
|
||||
remote.status = "failed";
|
||||
remote.lastActivityAt = endedAt;
|
||||
}
|
||||
expect((await manager.listForDiscovery())[0]).toMatchObject({
|
||||
status,
|
||||
endedAt,
|
||||
});
|
||||
expect(ctx.liveSessions.get(remote.id)).toMatchObject({
|
||||
status,
|
||||
busy: false,
|
||||
endedAt: Date.parse(endedAt),
|
||||
});
|
||||
if (status === "failed") {
|
||||
remote.title = "Renamed after failure";
|
||||
remote.updatedAt = new Date().toISOString();
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(endedAt);
|
||||
const live = ctx.liveSessions.get(remote.id)!;
|
||||
ctx.liveSessions.clear();
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(endedAt);
|
||||
|
||||
const hubEndedAt = Date.parse(endedAt) + 500;
|
||||
live.endedAt = hubEndedAt;
|
||||
ctx.liveSessions.set(remote.id, live);
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(
|
||||
new Date(hubEndedAt).toISOString(),
|
||||
);
|
||||
ctx.liveSessions.clear();
|
||||
delete remote.lastActivityAt;
|
||||
expect((await manager.listForDiscovery())[0].endedAt).toBe(
|
||||
remote.createdAt,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("single-flights repeated starts for the same client request", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createCalls = 0;
|
||||
let finishCreate:
|
||||
| ((value: {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
sandboxUrl: string;
|
||||
}) => void)
|
||||
| undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: () => {
|
||||
createCalls += 1;
|
||||
return new Promise((resolve) => {
|
||||
finishCreate = resolve;
|
||||
});
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
const input = {
|
||||
requestId: "client-start-1",
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
};
|
||||
|
||||
const first = manager.create(input);
|
||||
const second = manager.create(input);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(createCalls).toBe(1);
|
||||
expect(await manager.listForDiscovery()).toEqual([]);
|
||||
|
||||
finishCreate?.({
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
});
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
expect.objectContaining({ sessionId: "ses-created" }),
|
||||
expect.objectContaining({ sessionId: "ses-created" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps identical starts from separate chats independent", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createCalls = 0;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async () => {
|
||||
createCalls += 1;
|
||||
return {
|
||||
sessionId: `ses-created-${createCalls}`,
|
||||
status: "provisioning",
|
||||
sandboxUrl: "pod",
|
||||
};
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
const input = {
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
manager.create({ ...input, requestId: "chat-a" }),
|
||||
manager.create({ ...input, requestId: "chat-b" }),
|
||||
]);
|
||||
|
||||
expect(createCalls).toBe(2);
|
||||
expect(first.sessionId).not.toBe(second.sessionId);
|
||||
});
|
||||
|
||||
it("returns cached cloud discovery promptly while a refresh is slow", async () => {
|
||||
const { ctx } = createContext();
|
||||
let listCalls = 0;
|
||||
let finishRefresh: ((value: CloudSessionRecord[]) => void) | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => {
|
||||
listCalls += 1;
|
||||
if (listCalls === 1) return [REMOTE_SESSION];
|
||||
return await new Promise((resolve) => {
|
||||
finishRefresh = resolve;
|
||||
});
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
});
|
||||
await manager.listForDiscovery();
|
||||
|
||||
const cached = await manager.listForDiscovery({ timeoutMs: 1 });
|
||||
|
||||
expect(cached).toEqual([
|
||||
expect.objectContaining({ sessionId: "ses-outer", origin: "cloud" }),
|
||||
]);
|
||||
finishRefresh?.([]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it("uses only the active organization for billing and session listing", async () => {
|
||||
const { ctx } = createContext();
|
||||
const listCalls: Array<string | undefined> = [];
|
||||
const repositoryScopes: Array<string | undefined> = [];
|
||||
const branchScopes: Array<string | undefined> = [];
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const orgSession = { ...REMOTE_SESSION, id: "ses-org", title: undefined };
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async (organizationId?: string) => {
|
||||
listCalls.push(organizationId);
|
||||
return organizationId
|
||||
? [orgSession]
|
||||
: [{ ...REMOTE_SESSION, title: undefined }];
|
||||
},
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createInput = input;
|
||||
return {
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
};
|
||||
},
|
||||
listRepositories: async (organizationId?: string) => {
|
||||
repositoryScopes.push(organizationId);
|
||||
return { connected: true, connectUrl: "", repositories: [] };
|
||||
},
|
||||
listBranches: async (
|
||||
_repositoryId: number,
|
||||
organizationId?: string,
|
||||
) => {
|
||||
branchScopes.push(organizationId);
|
||||
return { available: true, branches: [] };
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
getActiveOrganizationId: async () => "org-cline-bot",
|
||||
});
|
||||
const scoped = await manager.list();
|
||||
expect(listCalls).toEqual(["org-cline-bot"]);
|
||||
expect(scoped.map((session) => session.id)).toEqual(["ses-org"]);
|
||||
await manager.listRepositories();
|
||||
await manager.listBranches(42);
|
||||
expect(repositoryScopes).toEqual(["org-cline-bot"]);
|
||||
expect(branchScopes).toEqual(["org-cline-bot"]);
|
||||
|
||||
await manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
expect(createInput).toMatchObject({ organizationId: "org-cline-bot" });
|
||||
});
|
||||
|
||||
it("refreshes the active organization before creating a session", async () => {
|
||||
const { ctx } = createContext();
|
||||
let serverScope = "org-a";
|
||||
let cachedScope = serverScope;
|
||||
const lookupOptions: Array<{ fresh?: boolean } | undefined> = [];
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createInput = input;
|
||||
return {
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
};
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
getActiveOrganizationId: async (options) => {
|
||||
lookupOptions.push(options);
|
||||
if (options?.fresh) cachedScope = serverScope;
|
||||
return cachedScope;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.list();
|
||||
serverScope = "org-b";
|
||||
await manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
});
|
||||
|
||||
expect(lookupOptions).toEqual([undefined, { fresh: true }]);
|
||||
expect(createInput).toMatchObject({ organizationId: "org-b" });
|
||||
});
|
||||
|
||||
it("does not silently bill personal credits when account scope lookup fails", async () => {
|
||||
const { ctx } = createContext();
|
||||
let createInput: Record<string, unknown> | undefined;
|
||||
const manager = new CloudSessionManager(ctx, {
|
||||
api: {
|
||||
list: async () => [],
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
createInput = input;
|
||||
return {
|
||||
sessionId: "ses-created",
|
||||
status: "provisioning",
|
||||
sandboxUrl: "",
|
||||
};
|
||||
},
|
||||
} as unknown as CloudSessionApi,
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "workos:fresh",
|
||||
getActiveOrganizationId: async () => {
|
||||
throw new Error("account endpoint down");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
manager.create({
|
||||
modelId: "anthropic/claude-sonnet-5",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
}),
|
||||
).rejects.toThrow("account endpoint down");
|
||||
expect(createInput?.organizationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import { HubCommandError, HubTransportError } from "@cline/core";
|
||||
import type { HubEventEnvelope } from "@cline/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CloudSessionApi,
|
||||
CloudSessionManager,
|
||||
type CloudSessionRecord,
|
||||
} from "./cloud-sessions";
|
||||
import { createSidecarContext } from "./context";
|
||||
import type { LiveSession } from "./types";
|
||||
|
||||
async function createFixture() {
|
||||
const ctx = createSidecarContext("/workspace");
|
||||
const live: LiveSession = {
|
||||
config: { executionTarget: "cloud", model: "test-model" },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
status: "idle",
|
||||
startedAt: 1,
|
||||
};
|
||||
ctx.liveSessions.set("ses-outer", live);
|
||||
const remote: CloudSessionRecord = {
|
||||
id: "ses-outer",
|
||||
status: "ready",
|
||||
sandboxUrl: "https://pod.example/hub",
|
||||
repoContext: { repoUrl: "https://github.com/cline/test" },
|
||||
metadata: { modelId: "test-model" },
|
||||
createdAt: "2026-08-05T10:00:00.000Z",
|
||||
updatedAt: "2026-08-05T10:01:00.000Z",
|
||||
};
|
||||
const options = {
|
||||
apiBaseUrl: "https://api.example",
|
||||
getAuthToken: async () => "token",
|
||||
};
|
||||
const api = new CloudSessionApi({
|
||||
...options,
|
||||
appBaseUrl: "https://app.example",
|
||||
});
|
||||
vi.spyOn(api, "list").mockResolvedValue([remote]);
|
||||
const updateTitle = vi.spyOn(api, "updateTitle").mockResolvedValue(remote);
|
||||
const manager = new CloudSessionManager(ctx, { ...options, api });
|
||||
const replies: Record<string, Record<string, unknown>> = {
|
||||
"session.get": { session: { status: "idle" } },
|
||||
"session.messages": { messages: [] },
|
||||
"session.pending_prompts": { prompts: [] },
|
||||
};
|
||||
const reply = (name: string) => ({
|
||||
version: "v1" as const,
|
||||
ok: true as const,
|
||||
payload: replies[name] ?? {},
|
||||
});
|
||||
const command = vi.fn(
|
||||
async (
|
||||
name: string,
|
||||
_payload?: unknown,
|
||||
_sessionId?: string,
|
||||
_options?: unknown,
|
||||
) => reply(name),
|
||||
);
|
||||
const connection: Awaited<
|
||||
ReturnType<CloudSessionManager["ensureConnection"]>
|
||||
> = {
|
||||
remote,
|
||||
innerSessionId: "inner-1",
|
||||
client: {
|
||||
command,
|
||||
connect: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
getClientId: () => "test-client",
|
||||
subscribe: () => vi.fn(),
|
||||
},
|
||||
bufferedEvents: [],
|
||||
bufferedEventsDropped: 0,
|
||||
transcriptKnown: false,
|
||||
seenEventIds: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
};
|
||||
const ensureAttached = vi.fn(async () => {});
|
||||
const forwardEvent = vi.fn();
|
||||
// Stub transport ownership; exercise the real interaction logic.
|
||||
Object.assign(manager, {
|
||||
ensureConnection: async () => connection,
|
||||
ensureAttached,
|
||||
forwardEvent,
|
||||
applySessionModel: vi.fn(),
|
||||
refreshKnownSession: async () => undefined,
|
||||
loadArchivedMessages: async () => null,
|
||||
});
|
||||
await manager.list();
|
||||
return {
|
||||
ctx,
|
||||
manager,
|
||||
live,
|
||||
connection,
|
||||
command,
|
||||
replies,
|
||||
reply,
|
||||
ensureAttached,
|
||||
forwardEvent,
|
||||
updateTitle,
|
||||
};
|
||||
}
|
||||
|
||||
const transportError = () =>
|
||||
new HubTransportError("hub_connection_closed", "socket closed");
|
||||
|
||||
describe("CloudSessionManager state", () => {
|
||||
it("keeps a pending send cancelled when deletion clears its abort token", async () => {
|
||||
const { manager, connection, ensureAttached, command } =
|
||||
await createFixture();
|
||||
manager["connections"].set("ses-outer", connection);
|
||||
vi.spyOn(manager["options"].api, "delete").mockResolvedValue(undefined);
|
||||
let releaseAttach!: () => void;
|
||||
ensureAttached.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseAttach = resolve;
|
||||
}),
|
||||
);
|
||||
const result = manager
|
||||
.send("ses-outer", "cancel this")
|
||||
.catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(releaseAttach).toBeDefined());
|
||||
await manager.abort("ses-outer");
|
||||
await manager.delete("ses-outer");
|
||||
releaseAttach();
|
||||
expect(await result).toMatchObject({
|
||||
message: "Cloud session prompt cancelled",
|
||||
});
|
||||
expect(connection.disposed).toBe(true);
|
||||
expect(connection.client.dispose).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
command.mock.calls.some(([name]) => name === "session.send_input"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not dispatch a pending send after abort and allows a later send", async () => {
|
||||
const { manager, ensureAttached, command } = await createFixture();
|
||||
let releaseAttach!: () => void;
|
||||
ensureAttached.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseAttach = resolve;
|
||||
}),
|
||||
);
|
||||
const sending = manager.send("ses-outer", "cancel this");
|
||||
const result = sending.catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(releaseAttach).toBeDefined());
|
||||
await manager.abort("ses-outer");
|
||||
releaseAttach();
|
||||
expect(await result).toBeInstanceOf(Error);
|
||||
expect(
|
||||
command.mock.calls.some(([name]) => name === "session.send_input"),
|
||||
).toBe(false);
|
||||
await manager.send("ses-outer", "send this instead");
|
||||
expect(
|
||||
command.mock.calls.filter(([name]) => name === "session.send_input"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refreshes the completion time for a later turn completed while disconnected", async () => {
|
||||
const { manager, live, replies } = await createFixture();
|
||||
live.status = "running";
|
||||
live.endedAt = 1;
|
||||
replies["session.get"] = { session: { status: "completed" } };
|
||||
await manager.readMessages("ses-outer");
|
||||
expect(live.endedAt).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("rejects malformed queue command replies instead of clearing the queue", async () => {
|
||||
const { manager, live, replies } = await createFixture();
|
||||
replies["session.pending_prompts"] = {
|
||||
prompts: [
|
||||
{
|
||||
id: "q-1",
|
||||
prompt: "queued",
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
},
|
||||
],
|
||||
};
|
||||
await manager.pendingPrompts("ses-outer");
|
||||
expect(live.promptsInQueue).toMatchObject([
|
||||
{ id: "q-1", userImages: ["data:image/png;base64,AQID"] },
|
||||
]);
|
||||
const previous = live.promptsInQueue;
|
||||
replies["session.pending_prompts"] = {};
|
||||
await expect(manager.pendingPrompts("ses-outer")).rejects.toThrow(
|
||||
"invalid pending-prompts snapshot",
|
||||
);
|
||||
expect(live.promptsInQueue).toBe(previous);
|
||||
});
|
||||
|
||||
it("rejects a queue snapshot returned after its connection was disposed", async () => {
|
||||
const { manager, connection, command, reply, replies, live } =
|
||||
await createFixture();
|
||||
manager["connections"].set("ses-outer", connection);
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const unregister = Promise.withResolvers<void>();
|
||||
vi.mocked(connection.client.dispose).mockReturnValue(unregister.promise);
|
||||
command.mockImplementationOnce(async (name) => {
|
||||
reached.resolve();
|
||||
await blocked.promise;
|
||||
return reply(name);
|
||||
});
|
||||
replies["session.pending_prompts"] = {
|
||||
prompts: [{ id: "q-1", prompt: "stale queued prompt" }],
|
||||
};
|
||||
const result = manager.pendingPrompts("ses-outer").catch((error) => error);
|
||||
await reached.promise;
|
||||
const disposing = manager["disposeConnection"]("ses-outer");
|
||||
blocked.resolve();
|
||||
const snapshot = await result;
|
||||
unregister.resolve();
|
||||
await disposing;
|
||||
expect(snapshot).toMatchObject({
|
||||
message: "Cloud session connection was disposed",
|
||||
});
|
||||
expect(live.promptsInQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it("queues one rerun when a second sync overlaps the active snapshot", async () => {
|
||||
const { manager, command, reply } = await createFixture();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
let reads = 0;
|
||||
command.mockImplementation(async (name) => {
|
||||
if (name === "session.messages" && ++reads === 1) {
|
||||
reached.resolve();
|
||||
await blocked.promise;
|
||||
}
|
||||
return reply(name);
|
||||
});
|
||||
const first = manager.readMessages("ses-outer");
|
||||
await reached.promise;
|
||||
const second = manager.readMessages("ses-outer");
|
||||
blocked.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(reads).toBe(2);
|
||||
});
|
||||
|
||||
it("retains the prior transcript and releases buffered events when a snapshot fails", async () => {
|
||||
const {
|
||||
ctx,
|
||||
manager,
|
||||
live,
|
||||
connection,
|
||||
command,
|
||||
reply,
|
||||
replies,
|
||||
forwardEvent,
|
||||
} = await createFixture();
|
||||
const send = vi.fn();
|
||||
ctx.wsClients.add({ send });
|
||||
const previous = [{ role: "assistant" as const, content: "keep me" }];
|
||||
live.messages = previous;
|
||||
const event: HubEventEnvelope = {
|
||||
version: "v1",
|
||||
event: "assistant.delta",
|
||||
sessionId: "inner-1",
|
||||
payload: { text: "still live" },
|
||||
};
|
||||
replies["session.messages"] = { messages: "invalid" };
|
||||
command.mockImplementation(async (name) => {
|
||||
if (name === "session.messages") connection.bufferedEvents.push(event);
|
||||
return reply(name);
|
||||
});
|
||||
await expect(manager.readMessages("ses-outer")).rejects.toThrow(
|
||||
/invalid transcript snapshot/i,
|
||||
);
|
||||
expect(live.messages).toBe(previous);
|
||||
expect(forwardEvent).toHaveBeenCalledExactlyOnceWith(
|
||||
"ses-outer",
|
||||
connection,
|
||||
event,
|
||||
);
|
||||
expect(connection.bufferingEvents).toBe(false);
|
||||
expect(
|
||||
send.mock.calls.map(([message]) => JSON.parse(message).event.name),
|
||||
).toContain("cloud_session_sync_failed");
|
||||
});
|
||||
|
||||
it("queues an implicit send when a cold session is already running", async () => {
|
||||
const { manager, command, replies } = await createFixture();
|
||||
replies["session.get"] = { session: { status: "running" } };
|
||||
await expect(
|
||||
manager.send("ses-outer", "Run this next"),
|
||||
).resolves.toMatchObject({ ok: true, queued: true });
|
||||
expect(command).toHaveBeenCalledWith(
|
||||
"session.send_input",
|
||||
{ prompt: "Run this next", delivery: "queue" },
|
||||
"inner-1",
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a runtime-queued idle send and preserves an accepted message", async () => {
|
||||
const { manager, connection, command, live, reply } = await createFixture();
|
||||
connection.transcriptKnown = true;
|
||||
const accepted = { role: "user" as const, content: "Run this next" };
|
||||
command.mockImplementation(async (name) => {
|
||||
if (name === "session.send_input") {
|
||||
live.messages = [...live.messages, accepted];
|
||||
live.busy = false;
|
||||
live.status = "completed";
|
||||
}
|
||||
return reply(name);
|
||||
});
|
||||
|
||||
const result = await manager.send("ses-outer", "Run this next");
|
||||
|
||||
expect(result).toMatchObject({ ok: true, queued: true });
|
||||
expect(command).toHaveBeenCalledWith(
|
||||
"session.send_input",
|
||||
expect.objectContaining({ prompt: "Run this next", delivery: undefined }),
|
||||
"inner-1",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(live.messages).toEqual([accepted]);
|
||||
expect(live).toMatchObject({ busy: false, status: "completed" });
|
||||
});
|
||||
|
||||
it("does not mark a send queued when runtime returns a normal result", async () => {
|
||||
const { manager, connection, replies } = await createFixture();
|
||||
connection.transcriptKnown = true;
|
||||
replies["session.send_input"] = { result: { text: "done" } };
|
||||
|
||||
const result = await manager.send("ses-outer", "Run now");
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.queued).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an explicit steer reply distinct from a queued successor", async () => {
|
||||
const { manager, connection } = await createFixture();
|
||||
connection.transcriptKnown = true;
|
||||
const result = await manager.send("ses-outer", "Steer now", "steer");
|
||||
expect(result.queued).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("does not let automatic title writing overwrite an explicit rename (auto fails: %s)", async (autoFails) => {
|
||||
const { manager, connection, updateTitle } = await createFixture();
|
||||
const started = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let serverTitle = "";
|
||||
let calls = 0;
|
||||
updateTitle.mockImplementation(async (_sessionId, title) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
if (autoFails) throw new Error("automatic title failed");
|
||||
}
|
||||
serverTitle = title;
|
||||
return connection.remote;
|
||||
});
|
||||
|
||||
const sending = manager.send("ses-outer", "Derived title");
|
||||
await started.promise;
|
||||
const renaming = manager.updateTitle("ses-outer", "Renamed");
|
||||
expect(updateTitle).toHaveBeenCalledTimes(1);
|
||||
release.resolve();
|
||||
await Promise.all([sending, renaming]);
|
||||
|
||||
expect(serverTitle).toBe("Renamed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("skips automatic naming while a manual rename is pending (manual fails: %s)", async (manualFails) => {
|
||||
const { manager, connection, live, updateTitle } = await createFixture();
|
||||
const started = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let calls = 0;
|
||||
let serverTitle = "";
|
||||
updateTitle.mockImplementation(async (_sessionId, title) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
if (manualFails) throw new Error("manual title failed");
|
||||
}
|
||||
serverTitle = title;
|
||||
return connection.remote;
|
||||
});
|
||||
|
||||
const manual = manager
|
||||
.updateTitle("ses-outer", "Renamed")
|
||||
.catch((error: unknown) => error);
|
||||
await started.promise;
|
||||
await manager.send("ses-outer", "Derived title");
|
||||
expect(updateTitle).toHaveBeenCalledTimes(1);
|
||||
expect(live.title).toBeUndefined();
|
||||
release.resolve();
|
||||
const manualOutcome = await manual;
|
||||
if (manualFails) {
|
||||
expect(manualOutcome).toBeInstanceOf(Error);
|
||||
} else {
|
||||
expect(manualOutcome).toBeUndefined();
|
||||
}
|
||||
if (manualFails) {
|
||||
await manager.updateTitle("ses-outer", "Renamed again");
|
||||
}
|
||||
|
||||
expect(serverTitle).toBe(manualFails ? "Renamed again" : "Renamed");
|
||||
expect(updateTitle).toHaveBeenCalledTimes(manualFails ? 2 : 1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("orders overlapping manual renames (first fails: %s)", async (firstFails) => {
|
||||
const { manager, connection, updateTitle } = await createFixture();
|
||||
const started = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const savedTitles: string[] = [];
|
||||
updateTitle.mockImplementation(async (_sessionId, title) => {
|
||||
if (title === "First") {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
if (firstFails) throw new Error("first rename failed");
|
||||
}
|
||||
savedTitles.push(title);
|
||||
return connection.remote;
|
||||
});
|
||||
|
||||
const first = manager
|
||||
.updateTitle("ses-outer", "First")
|
||||
.catch((error: unknown) => error);
|
||||
await started.promise;
|
||||
const second = manager.updateTitle("ses-outer", "Second");
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(updateTitle).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release.resolve();
|
||||
}
|
||||
const [firstOutcome] = await Promise.all([first, second]);
|
||||
if (firstFails) expect(firstOutcome).toBeInstanceOf(Error);
|
||||
else expect(firstOutcome).toBeUndefined();
|
||||
expect(savedTitles).toEqual(firstFails ? ["Second"] : ["First", "Second"]);
|
||||
});
|
||||
|
||||
it("does not confirm a lost duplicate prompt against an earlier delivery", async () => {
|
||||
const { manager, command, replies, live } = await createFixture();
|
||||
replies["session.send_input"] = { result: {} };
|
||||
await manager.send("ses-outer", "yes");
|
||||
live.busy = false;
|
||||
replies["session.messages"] = {
|
||||
messages: [
|
||||
{ role: "user", content: '<user_input mode="act">yes</user_input>' },
|
||||
],
|
||||
};
|
||||
command.mockRejectedValueOnce(transportError());
|
||||
await expect(manager.send("ses-outer", "yes")).rejects.toThrow(
|
||||
/could not confirm.*before resending/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not confirm two queued sends from one matching recovered prompt", async () => {
|
||||
const { manager, command, replies, reply } = await createFixture();
|
||||
await manager.readMessages("ses-outer");
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
let sends = 0;
|
||||
command.mockImplementation(async (name) => {
|
||||
if (name === "session.send_input") {
|
||||
if (++sends === 2) reached.resolve();
|
||||
await blocked.promise;
|
||||
throw transportError();
|
||||
}
|
||||
return reply(name);
|
||||
});
|
||||
const results = Promise.allSettled([
|
||||
manager.send("ses-outer", "same prompt", "queue"),
|
||||
manager.send("ses-outer", "same prompt", "queue"),
|
||||
]);
|
||||
await reached.promise;
|
||||
// Only one command was accepted by the Hub; neither reply reached this client.
|
||||
replies["session.pending_prompts"] = {
|
||||
prompts: [{ id: "accepted", prompt: "same prompt", delivery: "queue" }],
|
||||
};
|
||||
blocked.resolve();
|
||||
for (const result of await results) {
|
||||
expect(result.status).toBe("rejected");
|
||||
if (result.status === "rejected") {
|
||||
expect(result.reason.message).toMatch(
|
||||
/could not confirm.*before resending/,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(sends).toBe(2);
|
||||
});
|
||||
|
||||
it("reattaches after a transport failure without retrying the prompt", async () => {
|
||||
const { manager, command, replies, ensureAttached } = await createFixture();
|
||||
await manager.readMessages("ses-outer");
|
||||
command.mockClear();
|
||||
ensureAttached.mockClear();
|
||||
replies["session.messages"] = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="act">Do this once</user_input>',
|
||||
},
|
||||
],
|
||||
};
|
||||
replies["session.get"] = { session: { status: "running" } };
|
||||
command.mockRejectedValueOnce(transportError());
|
||||
await expect(
|
||||
manager.send("ses-outer", "Do this once"),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
recoveredAfterDisconnect: true,
|
||||
status: "running",
|
||||
});
|
||||
expect(command.mock.calls.map(([name]) => name)).toEqual([
|
||||
"session.send_input",
|
||||
"session.get",
|
||||
"session.messages",
|
||||
"session.pending_prompts",
|
||||
]);
|
||||
expect(ensureAttached).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a direct send uncertain when transport recovery cannot find the prompt", async () => {
|
||||
const { manager, command } = await createFixture();
|
||||
await manager.readMessages("ses-outer");
|
||||
command.mockRejectedValueOnce(transportError());
|
||||
await expect(manager.send("ses-outer", "Lost prompt")).rejects.toThrow(
|
||||
/could not confirm.*before resending/,
|
||||
);
|
||||
expect(
|
||||
command.mock.calls.filter(([name]) => name === "session.send_input"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a lost steer reply uncertain even with matching recovery events", async () => {
|
||||
const { manager, command, connection, reply } = await createFixture();
|
||||
await manager.readMessages("ses-outer");
|
||||
command
|
||||
.mockImplementation(async (name) => {
|
||||
if (name === "session.messages")
|
||||
connection.bufferedEvents.push({
|
||||
version: "v1",
|
||||
event: "session.pending_prompt_submitted",
|
||||
sessionId: "inner-1",
|
||||
payload: {
|
||||
prompt: {
|
||||
id: "steer-1",
|
||||
prompt: "Steer accepted",
|
||||
delivery: "steer",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
return reply(name);
|
||||
})
|
||||
.mockRejectedValueOnce(transportError());
|
||||
await expect(
|
||||
manager.send("ses-outer", "Steer accepted", "steer"),
|
||||
).rejects.toThrow(/could not confirm.*before resending/);
|
||||
expect(
|
||||
command.mock.calls.filter(([name]) => name === "session.send_input"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"transport",
|
||||
"timeout",
|
||||
])("keeps a queued %s failure uncertain despite a matching snapshot", async (failure) => {
|
||||
const { manager, command, replies } = await createFixture();
|
||||
await manager.readMessages("ses-outer");
|
||||
replies["session.pending_prompts"] = {
|
||||
prompts: [
|
||||
{
|
||||
id: "q-1",
|
||||
prompt: "Queued during disconnect",
|
||||
delivery: "queue",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
command.mockRejectedValueOnce(
|
||||
failure === "timeout"
|
||||
? new HubCommandError(
|
||||
"session.send_input",
|
||||
"hub_command_timeout",
|
||||
"timed out",
|
||||
)
|
||||
: transportError(),
|
||||
);
|
||||
await expect(
|
||||
manager.send("ses-outer", "Queued during disconnect", "queue"),
|
||||
).rejects.toThrow(/could not confirm.*before resending/);
|
||||
expect(
|
||||
command.mock.calls.filter(([name]) => name === "session.send_input"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("names the session from the first prompt and supports rename", async () => {
|
||||
const { manager, live, updateTitle } = await createFixture();
|
||||
await manager.send(
|
||||
"ses-outer",
|
||||
"Fix the login bug\nwith more detail below",
|
||||
);
|
||||
expect(updateTitle).toHaveBeenCalledExactlyOnceWith(
|
||||
"ses-outer",
|
||||
"Fix the login bug",
|
||||
);
|
||||
expect(live.title).toBe("Fix the login bug");
|
||||
await manager.send("ses-outer", "another prompt");
|
||||
expect(updateTitle).toHaveBeenCalledTimes(1);
|
||||
await manager.updateTitle("ses-outer", "Renamed");
|
||||
expect(updateTitle).toHaveBeenLastCalledWith("ses-outer", "Renamed");
|
||||
expect(live.title).toBe("Renamed");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,13 @@ const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
const clearLegacyProviderCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./legacy-provider-credentials", () => ({
|
||||
clearLegacyProviderCredentials: clearLegacyProviderCredentialsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
@@ -21,6 +27,7 @@ vi.mock("@cline/core", async () => {
|
||||
executeClineAccountAction: executeClineAccountActionMock,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
saveProviderSettings = persistProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
@@ -31,11 +38,13 @@ vi.mock("@cline/core", async () => {
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const setDistinctId = vi.fn();
|
||||
const updateCommonProperties = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
telemetry: { capture, setDistinctId, updateCommonProperties },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
return { ctx, capture, setDistinctId, updateCommonProperties };
|
||||
}
|
||||
|
||||
const FETCH_ME_ARGS = {
|
||||
@@ -53,12 +62,15 @@ beforeEach(() => {
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
persistProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
clearLegacyProviderCredentialsMock.mockReset();
|
||||
});
|
||||
|
||||
describe("cline_account command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
it("returns a typed not-authenticated result and restores anonymous telemetry when signed out", async () => {
|
||||
const { ctx, capture, setDistinctId, updateCommonProperties } =
|
||||
createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
@@ -72,6 +84,14 @@ describe("cline_account command auth states", () => {
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(setDistinctId).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the account action unchanged when a fresh token resolves", async () => {
|
||||
@@ -114,11 +134,30 @@ describe("cline_account command auth states", () => {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe(
|
||||
"persisted-token",
|
||||
"workos:persisted-token",
|
||||
);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports signed out when the refresh token is rejected even though a stale access token is persisted", async () => {
|
||||
// The stale token would only fail the account request with a 401,
|
||||
// which rendered an error card whose Retry failed the same way.
|
||||
const { ctx } = createContext();
|
||||
const { OAuthReauthRequiredError } =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
resolveProviderApiKeyMock.mockRejectedValue(
|
||||
new OAuthReauthRequiredError("cline"),
|
||||
);
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
auth: { accessToken: "persisted-token" },
|
||||
});
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports one auth refresh soft-failure event when the refresh fails and no fallback token exists", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
const refreshError = new Error(
|
||||
@@ -171,7 +210,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
@@ -182,6 +221,62 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
expect(setDistinctId).toHaveBeenCalledWith("acct-1");
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_id: "acct-1", account_id: "acct-1" }),
|
||||
);
|
||||
expect(ctx.telemetryUser).toEqual({
|
||||
distinctId: "acct-1",
|
||||
accountId: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies and persists the active organization for task telemetry", async () => {
|
||||
const { ctx, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: { accountId: "acct-1", accessToken: "token" },
|
||||
});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: "acct-1",
|
||||
organization_id: "org-1",
|
||||
}),
|
||||
);
|
||||
expect(persistProviderSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
memberId: "member-1",
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
expect(ctx.telemetryUser).toEqual({
|
||||
distinctId: "acct-1",
|
||||
accountId: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
@@ -219,7 +314,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -233,10 +328,28 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
expect(ctx.telemetryUser).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: null,
|
||||
distinctId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(setDistinctId).toHaveBeenLastCalledWith(
|
||||
ctx.telemetryUser?.distinctId,
|
||||
);
|
||||
expect(ctx.telemetryUser?.distinctId).not.toBe("acct-1");
|
||||
expect(updateCommonProperties).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
account_email: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the identity when sign-out blanks the cline auth settings", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -259,6 +372,47 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
expect(ctx.telemetryUser).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: null,
|
||||
distinctId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(setDistinctId).toHaveBeenLastCalledWith(
|
||||
ctx.telemetryUser?.distinctId,
|
||||
);
|
||||
expect(updateCommonProperties).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("signs out of the shared cline entry and legacy secrets when cline-pass is disabled", async () => {
|
||||
const { ctx } = createContext();
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
saveProviderSettingsMock.mockImplementation(
|
||||
(_manager: unknown, request: { providerId: string }) => ({
|
||||
providerId: request.providerId,
|
||||
enabled: false,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
}),
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "cline-pass",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
// Cline Pass stores its credentials under "cline", so both entries go,
|
||||
// and the legacy secrets are cleared for the storage provider.
|
||||
expect(saveProviderSettingsMock.mock.calls.map(([, r]) => r)).toEqual([
|
||||
expect.objectContaining({ providerId: "cline-pass", enabled: false }),
|
||||
{ providerId: "cline", enabled: false },
|
||||
]);
|
||||
expect(clearLegacyProviderCredentialsMock).toHaveBeenCalledWith("cline");
|
||||
});
|
||||
|
||||
it("ignores settings writes for other providers", async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@cline/core", async () => {
|
||||
|
||||
function createContext(): SidecarContext {
|
||||
return {
|
||||
workspaceRoot: "/workspace",
|
||||
localWorkspaceRoot: "/workspace",
|
||||
wsClients: new Set(),
|
||||
hubBuildMismatch: {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
expect(hubClient).toBe(ctx.hubClient);
|
||||
expect(hubClient).toBeDefined();
|
||||
|
||||
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
|
||||
strategy: "require-hub",
|
||||
@@ -230,8 +230,14 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
];
|
||||
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
|
||||
const list = vi.fn(async () => []);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
ctx.runtimeBindings.set("local", {
|
||||
environmentId: "local",
|
||||
kind: "local",
|
||||
workspaceRoot: "/workspace/project",
|
||||
hubClient: { command } as never,
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -269,8 +275,14 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
metadata: { title: oversizedPrompt },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
ctx.runtimeBindings.set("local", {
|
||||
environmentId: "local",
|
||||
kind: "local",
|
||||
workspaceRoot: "/workspace/project",
|
||||
hubClient: { command } as never,
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -304,8 +316,14 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
metadata: { title: "generate an image of a puppy" },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
ctx.runtimeBindings.set("local", {
|
||||
environmentId: "local",
|
||||
kind: "local",
|
||||
workspaceRoot: "/workspace/project",
|
||||
hubClient: { command } as never,
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
|
||||
const results = (await handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -339,8 +357,14 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
metadata: { title: "generate an image of a puppy" },
|
||||
},
|
||||
]);
|
||||
ctx.hubClient = { command } as never;
|
||||
ctx.sessionManager = { list } as never;
|
||||
ctx.runtimeBindings.set("local", {
|
||||
environmentId: "local",
|
||||
kind: "local",
|
||||
workspaceRoot: "/workspace/project",
|
||||
hubClient: { command } as never,
|
||||
sessionManager: { list } as never,
|
||||
unsubscribeSessionEvents: () => {},
|
||||
});
|
||||
|
||||
const pending = handleCommand(ctx, "search_sessions", {
|
||||
query: "generate",
|
||||
@@ -453,6 +477,24 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the reflected transcript marker on queued lifecycle chunks", async () => {
|
||||
const { serializeQueuedPromptStart } = await import("./context");
|
||||
expect(
|
||||
JSON.parse(
|
||||
serializeQueuedPromptStart({
|
||||
promptId: "q-1",
|
||||
prompt: "Continue",
|
||||
transcriptReflected: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
promptId: "q-1",
|
||||
prompt: "Continue",
|
||||
attachmentCount: 0,
|
||||
transcriptReflected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("announces a queued prompt start once when drain emits both queue events", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -531,6 +573,66 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not announce a queued prompt start when the head is deleted from the queue", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
let onEvent: ((event: unknown) => void) | undefined;
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn((handler: (event: unknown) => void) => {
|
||||
onEvent = handler;
|
||||
return () => {};
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
await initializeSessionManager(ctx);
|
||||
ctx.liveSessions.set("session-1", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [
|
||||
{ id: "prompt-1", prompt: "first", steer: false, attachmentCount: 0 },
|
||||
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
|
||||
],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
// Removing the head only produces a shrunken snapshot — no
|
||||
// pending_prompt_submitted — so nothing must reach the transcript.
|
||||
onEvent?.({
|
||||
type: "pending_prompts",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
prompts: [{ id: "prompt-2", prompt: "second", delivery: "queue" }],
|
||||
},
|
||||
});
|
||||
|
||||
const events = readEvents(ctx);
|
||||
expect(
|
||||
events.filter(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_queued_prompt_start",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
events.find((message) => message.event.name === "prompts_in_queue_state")
|
||||
?.event.payload,
|
||||
).toEqual({
|
||||
environmentId: "local",
|
||||
sessionId: "session-1",
|
||||
items: [
|
||||
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("relays generated media for attach-only Hub sessions", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
@@ -689,7 +791,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
name: "ask_question_answered",
|
||||
payload: { requestId },
|
||||
payload: { requestId, environmentId: "local" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1186,6 +1288,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
event: {
|
||||
name: "task.created",
|
||||
payload: {
|
||||
environmentId: "local",
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
@@ -1214,6 +1317,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
event: {
|
||||
name: "settings.changed",
|
||||
payload: {
|
||||
environmentId: "local",
|
||||
types: ["plugins", "skills", "mcp"],
|
||||
},
|
||||
},
|
||||
@@ -1271,3 +1375,217 @@ describe("disposeSidecarContext attachment cleanup", () => {
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat chunk pipe selection", () => {
|
||||
async function createStreamingContext(
|
||||
sessionId: string,
|
||||
coreSubscriptions: Set<string> = new Set(),
|
||||
) {
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
ctx.liveSessions.set(sessionId, {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
ctx.runtimeBindings.set("local", {
|
||||
sessionManager: {
|
||||
hasSessionSubscription: (id: string) => coreSubscriptions.has(id),
|
||||
},
|
||||
} as never);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function coreTextEvent(sessionId: string, text: string) {
|
||||
return {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId,
|
||||
event: { type: "content_start", contentType: "text", text },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function eventsFor(ctx: SidecarContext, name: string) {
|
||||
return readEvents(ctx)
|
||||
.filter((message) => message.event.name === name)
|
||||
.map((message) => message.event.payload);
|
||||
}
|
||||
|
||||
function chunksFor(ctx: SidecarContext, stream: string): string[] {
|
||||
return eventsFor(ctx, "chat_event")
|
||||
.filter((payload) => (payload as { stream?: string }).stream === stream)
|
||||
.map((payload) => String((payload as { chunk?: string }).chunk));
|
||||
}
|
||||
|
||||
it("emits one copy when both pipes carry the same delta", async () => {
|
||||
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
// Opening a session arms both pipes: ClineCore subscribes to the session
|
||||
// and `attach` enables the observer projection, so the hub publishes each
|
||||
// delta to both sockets.
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "Pack " },
|
||||
});
|
||||
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "Pack "));
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "my box" },
|
||||
});
|
||||
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "my box"));
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["Pack ", "my box"]);
|
||||
});
|
||||
|
||||
it("still streams sessions only the observer delivers", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext("session-1");
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "remote " },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "run" },
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["remote ", "run"]);
|
||||
});
|
||||
|
||||
it("mutes the whole observer projection, not just text", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "tool.started",
|
||||
sessionId: "session-1",
|
||||
payload: { toolCallId: "call-1", toolName: "run_commands" },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "run.completed",
|
||||
sessionId: "session-1",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_tool_call_start")).toEqual([]);
|
||||
expect(eventsFor(ctx, "chat_session_ended")).toEqual([]);
|
||||
expect(ctx.liveSessions.get("session-1")?.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the subscription as it comes and goes", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const coreSubscriptions = new Set<string>();
|
||||
const ctx = await createStreamingContext("session-1", coreSubscriptions);
|
||||
const delta = (text: string) =>
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
delta("observer first");
|
||||
// A send (or pending-prompt list) subscribes ClineCore.
|
||||
coreSubscriptions.add("session-1");
|
||||
delta("muted");
|
||||
// `stop` drops the subscription; a run another client starts on the
|
||||
// same session is the observer's to render again.
|
||||
coreSubscriptions.delete("session-1");
|
||||
delta("observer again");
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual([
|
||||
"observer first",
|
||||
"observer again",
|
||||
]);
|
||||
});
|
||||
|
||||
it("decides per session", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
ctx.liveSessions.set("session-2", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "one" },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-2",
|
||||
payload: { text: "two" },
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("never drops chunks the sidecar produces itself", async () => {
|
||||
const { broadcastChunk } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
broadcastChunk(ctx, "session-1", "chat_queued_prompt_start", "{}");
|
||||
|
||||
expect(chunksFor(ctx, "chat_queued_prompt_start")).toEqual(["{}"]);
|
||||
});
|
||||
|
||||
it("stamps chunks with a stable per-process boot id", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const first = await createStreamingContext("session-1");
|
||||
handleHubLiveEvent(first, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "a" },
|
||||
});
|
||||
handleHubLiveEvent(first, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "b" },
|
||||
});
|
||||
|
||||
const boots = readEvents(first)
|
||||
.filter((message) => message.event.name === "chat_event")
|
||||
.map((message) => (message.event.payload as { boot?: string }).boot);
|
||||
expect(boots).toHaveLength(2);
|
||||
expect(boots[0]).toBeTruthy();
|
||||
expect(boots[1]).toBe(boots[0]);
|
||||
|
||||
// A replacement sidecar restarts `index` at 1, so it must be
|
||||
// distinguishable by boot id.
|
||||
const second = createSidecarContext("/workspace/project");
|
||||
expect(second.bootId).not.toBe(first.bootId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,9 +36,11 @@ import type {
|
||||
PendingAskQuestion,
|
||||
PendingToolApproval,
|
||||
PromptInQueue,
|
||||
SessionRuntimeBinding,
|
||||
SidecarContext,
|
||||
SidecarWebSocketClient,
|
||||
} from "./types";
|
||||
import { LOCAL_ENVIRONMENT_ID } from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
@@ -51,6 +53,73 @@ const approvalReadinessUpdates = new WeakMap<SidecarContext, Promise<void>>();
|
||||
// Helpers — WebSocket broadcast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Session state belongs to a runtime environment, not to a globally unique ID.
|
||||
const environmentContexts = new WeakMap<
|
||||
SidecarContext,
|
||||
Map<string, SidecarContext>
|
||||
>();
|
||||
const contextOwners = new WeakMap<SidecarContext, SidecarContext>();
|
||||
|
||||
export function getEnvironmentContext(
|
||||
ctx: SidecarContext,
|
||||
environmentId: string,
|
||||
): SidecarContext {
|
||||
const owner = contextOwners.get(ctx) ?? ctx;
|
||||
let contexts = environmentContexts.get(owner);
|
||||
if (!contexts) {
|
||||
contexts = new Map();
|
||||
environmentContexts.set(owner, contexts);
|
||||
}
|
||||
const existing = contexts.get(environmentId);
|
||||
if (existing) return existing;
|
||||
const local = environmentId === LOCAL_ENVIRONMENT_ID;
|
||||
// Shared services are inherited so later initialization remains visible;
|
||||
// session state and event identity are owned by this environment.
|
||||
const scoped: SidecarContext = Object.assign(Object.create(owner), {
|
||||
activeEnvironmentId: environmentId,
|
||||
liveSessions: local ? owner.liveSessions : new Map(),
|
||||
streamIndices: local ? owner.streamIndices : new Map(),
|
||||
sessionEnvironmentIds: local ? owner.sessionEnvironmentIds : new Map(),
|
||||
restoringWorkspacePaths: local ? owner.restoringWorkspacePaths : new Set(),
|
||||
pendingApprovals: local ? owner.pendingApprovals : new Map(),
|
||||
pendingQuestions: local ? owner.pendingQuestions : new Map(),
|
||||
});
|
||||
contextOwners.set(scoped, owner);
|
||||
contexts.set(environmentId, scoped);
|
||||
return scoped;
|
||||
}
|
||||
|
||||
export function getEnvironmentContexts(ctx: SidecarContext): SidecarContext[] {
|
||||
const owner = contextOwners.get(ctx) ?? ctx;
|
||||
getEnvironmentContext(owner, LOCAL_ENVIRONMENT_ID);
|
||||
return [...(environmentContexts.get(owner)?.values() ?? [])];
|
||||
}
|
||||
function clearEnvironmentSessions(ctx: SidecarContext, reason: string): void {
|
||||
for (const [id, session] of ctx.liveSessions)
|
||||
discardAllTrackedAttachments(id, session);
|
||||
ctx.liveSessions.clear();
|
||||
ctx.streamIndices.clear();
|
||||
ctx.sessionEnvironmentIds.clear();
|
||||
ctx.restoringWorkspacePaths.clear();
|
||||
for (const pending of ctx.pendingApprovals.values())
|
||||
pending.resolve({ approved: false, reason });
|
||||
ctx.pendingApprovals.clear();
|
||||
for (const pending of ctx.pendingQuestions.values()) {
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
ctx.pendingQuestions.clear();
|
||||
}
|
||||
|
||||
function sessionEventPayload(ctx: SidecarContext, payload: unknown): unknown {
|
||||
return payload && typeof payload === "object"
|
||||
? {
|
||||
environmentId: ctx.activeEnvironmentId ?? LOCAL_ENVIRONMENT_ID,
|
||||
...payload,
|
||||
}
|
||||
: payload;
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
@@ -63,7 +132,7 @@ export function encodeSidecarEvent(name: string, payload: unknown): string {
|
||||
}
|
||||
|
||||
function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
const encoded = encodeSidecarEvent(name, payload);
|
||||
const encoded = encodeSidecarEvent(name, sessionEventPayload(ctx, payload));
|
||||
for (const client of ctx.wsClients) {
|
||||
try {
|
||||
client.send(encoded);
|
||||
@@ -84,7 +153,7 @@ export function sendEventToClient(
|
||||
payload: unknown,
|
||||
): boolean {
|
||||
try {
|
||||
client.send(encodeSidecarEvent(name, payload));
|
||||
client.send(encodeSidecarEvent(name, sessionEventPayload(ctx, payload)));
|
||||
return true;
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
@@ -100,13 +169,15 @@ export function cancelSidecarToolApprovalsForOwner(
|
||||
ctx: SidecarContext,
|
||||
owner: SidecarWebSocketClient,
|
||||
): void {
|
||||
for (const [requestId, pending] of ctx.pendingApprovals) {
|
||||
if (pending.owner !== owner) continue;
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
pending.resolve({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
for (const scoped of getEnvironmentContexts(ctx)) {
|
||||
for (const [requestId, pending] of scoped.pendingApprovals) {
|
||||
if (pending.owner !== owner) continue;
|
||||
scoped.pendingApprovals.delete(requestId);
|
||||
pending.resolve({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,21 +188,21 @@ export function syncSidecarApprovalReadiness(
|
||||
const update = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const hubClient = ctx.hubClient;
|
||||
if (!hubClient) return;
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
for (const { hubClient } of ctx.runtimeBindings.values()) {
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
});
|
||||
approvalReadinessUpdates.set(ctx, update);
|
||||
return update.finally(() => {
|
||||
@@ -178,7 +249,8 @@ function emitChunk(
|
||||
chunk: string,
|
||||
): void {
|
||||
const ts = nowMs();
|
||||
appendSessionChunk(sessionId, stream, chunk, ts);
|
||||
if (ctx.activeEnvironmentId === LOCAL_ENVIRONMENT_ID)
|
||||
appendSessionChunk(sessionId, stream, chunk, ts);
|
||||
const nextIndex = (ctx.streamIndices.get(sessionId) ?? 0) + 1;
|
||||
ctx.streamIndices.set(sessionId, nextIndex);
|
||||
sendEvent(ctx, "chat_event", {
|
||||
@@ -187,6 +259,7 @@ function emitChunk(
|
||||
chunk,
|
||||
ts,
|
||||
index: nextIndex,
|
||||
boot: ctx.bootId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,16 +307,18 @@ export function serializeQueuedPromptStart(input: {
|
||||
prompt: string;
|
||||
attachmentCount?: number;
|
||||
userImages?: string[];
|
||||
transcriptReflected?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
promptId: input.promptId,
|
||||
prompt: input.prompt,
|
||||
attachmentCount: input.attachmentCount ?? 0,
|
||||
userImages: input.userImages,
|
||||
...(input.transcriptReflected ? { transcriptReflected: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(
|
||||
export function sendPromptsInQueueSnapshot(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
): void {
|
||||
@@ -344,6 +419,7 @@ function handleAgentEvent(
|
||||
message: event.message,
|
||||
noticeType: event.noticeType,
|
||||
reason: event.reason,
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -367,6 +443,7 @@ function handleAgentEvent(
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Run ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -401,7 +478,19 @@ function handleAgentEvent(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "iteration_start":
|
||||
case "iteration_start": {
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
// Iterations restart at one for each user run. Keep the previous
|
||||
// answer only within the run in which it was supplied.
|
||||
if (event.iteration === 1 || !session.mistakeRecovery) {
|
||||
session.mistakeRecovery = { latestIteration: event.iteration };
|
||||
} else {
|
||||
session.mistakeRecovery.latestIteration = event.iteration;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "iteration_end":
|
||||
break;
|
||||
}
|
||||
@@ -411,10 +500,8 @@ function handleAgentEvent(
|
||||
// CoreSessionEvent routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The runtime's queue drain emits a pending_prompts snapshot (head removed)
|
||||
// and a pending_prompt_submitted event for the same prompt back-to-back, and
|
||||
// both are translated here into chat_queued_prompt_start — dedupe by prompt
|
||||
// id or the UI renders the user message twice.
|
||||
// Dedupe by prompt id so a repeated pending_prompt_submitted for the same
|
||||
// prompt cannot render the user message twice.
|
||||
function emitQueuedPromptStart(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
@@ -424,6 +511,7 @@ function emitQueuedPromptStart(
|
||||
prompt: string;
|
||||
attachmentCount: number;
|
||||
userImages?: string[];
|
||||
transcriptReflected?: boolean;
|
||||
},
|
||||
): void {
|
||||
if (session) {
|
||||
@@ -475,20 +563,10 @@ export function handleCoreSessionEvent(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
const previous = session.promptsInQueue;
|
||||
// A shrinking snapshot is not evidence that the head started
|
||||
// running: the user may have deleted it or the queue may have been
|
||||
// discarded. Only pending_prompt_submitted announces a start.
|
||||
session.promptsInQueue = mapped;
|
||||
if (
|
||||
previous.length > mapped.length &&
|
||||
previous[0] &&
|
||||
previous[0].id !== mapped[0]?.id
|
||||
) {
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
});
|
||||
}
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
break;
|
||||
@@ -520,6 +598,7 @@ export function handleCoreSessionEvent(
|
||||
}
|
||||
case "ended": {
|
||||
const { sessionId, reason } = event.payload;
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -570,21 +649,26 @@ export function createSidecarContext(
|
||||
observability: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
telemetryUser?: SidecarContext["telemetryUser"];
|
||||
} = {},
|
||||
): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
bootId: randomUUID(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
runtimeBindings: new Map(),
|
||||
sessionEnvironmentIds: new Map(),
|
||||
activeEnvironmentId: LOCAL_ENVIRONMENT_ID,
|
||||
remoteEnvironments: null,
|
||||
localWorkspaceRoot: workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
unsubscribeSessionEvents: null,
|
||||
telemetryUser: observability.telemetryUser,
|
||||
cloudSessionManager: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
}
|
||||
@@ -595,13 +679,8 @@ export async function disposeSidecarContext(
|
||||
): Promise<void> {
|
||||
const cleanup: Array<Promise<unknown>> = [];
|
||||
|
||||
ctx.unsubscribeSessionEvents?.();
|
||||
ctx.unsubscribeSessionEvents = null;
|
||||
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
}
|
||||
ctx.liveSessions.clear();
|
||||
for (const scoped of getEnvironmentContexts(ctx))
|
||||
clearEnvironmentSessions(scoped, reason);
|
||||
|
||||
for (const client of ctx.wsClients) {
|
||||
try {
|
||||
@@ -611,26 +690,16 @@ export async function disposeSidecarContext(
|
||||
}
|
||||
}
|
||||
ctx.wsClients.clear();
|
||||
for (const pending of ctx.pendingApprovals.values()) {
|
||||
pending.resolve({ approved: false, reason });
|
||||
for (const binding of ctx.runtimeBindings.values()) {
|
||||
binding.unsubscribeSessionEvents();
|
||||
cleanup.push(binding.hubClient.dispose());
|
||||
cleanup.push(binding.sessionManager.dispose(reason));
|
||||
}
|
||||
ctx.pendingApprovals.clear();
|
||||
for (const pending of ctx.pendingQuestions.values()) {
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
ctx.pendingQuestions.clear();
|
||||
|
||||
const hubClient = ctx.hubClient;
|
||||
ctx.hubClient = null;
|
||||
if (hubClient) {
|
||||
cleanup.push(hubClient.dispose());
|
||||
}
|
||||
|
||||
const sessionManager = ctx.sessionManager;
|
||||
ctx.sessionManager = null;
|
||||
if (sessionManager) {
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
ctx.runtimeBindings.clear();
|
||||
ctx.sessionEnvironmentIds.clear();
|
||||
if (ctx.remoteEnvironments) {
|
||||
cleanup.push(ctx.remoteEnvironments.dispose());
|
||||
ctx.remoteEnvironments = null;
|
||||
}
|
||||
|
||||
// Shuts down the PostHog client the feature flags service owns, flushing
|
||||
@@ -724,6 +793,28 @@ export function resolveSidecarAskQuestion(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove prompts before their session is stopped or replaced in the UI. */
|
||||
export function cancelSidecarMistakeQuestions(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
): void {
|
||||
for (const pending of ctx.pendingQuestions?.values() ?? []) {
|
||||
if (
|
||||
pending.item.sessionId !== sessionId ||
|
||||
pending.item.context?.agentId !== "desktop-mistake-limit"
|
||||
)
|
||||
continue;
|
||||
ctx.pendingQuestions.delete(pending.item.requestId);
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error(reason));
|
||||
sendEvent(ctx, "ask_question_cancelled", {
|
||||
requestId: pending.item.requestId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createSidecarRuntimeCapabilities(
|
||||
ctx: SidecarContext,
|
||||
): RuntimeCapabilities {
|
||||
@@ -790,6 +881,7 @@ export function handleHubLiveEvent(
|
||||
event: {
|
||||
event: string;
|
||||
sessionId?: string;
|
||||
sequence?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): void {
|
||||
@@ -824,6 +916,35 @@ export function handleHubLiveEvent(
|
||||
if (!session?.attachedViaHub) {
|
||||
return;
|
||||
}
|
||||
const projectsStatus =
|
||||
event.event === "run.started" ||
|
||||
event.event === "session.attached" ||
|
||||
event.event === "session.updated" ||
|
||||
event.event === "run.completed" ||
|
||||
event.event === "run.failed" ||
|
||||
event.event === "run.aborted";
|
||||
if (projectsStatus && typeof event.sequence === "number") {
|
||||
if (
|
||||
session.lastHubStatusSequence !== undefined &&
|
||||
event.sequence < session.lastHubStatusSequence
|
||||
) {
|
||||
return;
|
||||
}
|
||||
session.lastHubStatusSequence = event.sequence;
|
||||
}
|
||||
// The observer client and ClineCore's own hub client are separate sockets
|
||||
// that both receive this session's events. This projection only exists for
|
||||
// sessions ClineCore is not subscribed to (it subscribes as a side effect
|
||||
// of start/send/pending_prompts and unsubscribes on stop); once it is,
|
||||
// `handleCoreSessionEvent` carries everything below and a second copy here
|
||||
// would double every delta, tool row, and status change.
|
||||
if (
|
||||
ctx.runtimeBindings
|
||||
.get(ctx.activeEnvironmentId ?? LOCAL_ENVIRONMENT_ID)
|
||||
?.sessionManager.hasSessionSubscription(sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.event) {
|
||||
case "assistant.delta": {
|
||||
@@ -932,6 +1053,15 @@ export function handleHubLiveEvent(
|
||||
: event.event === "run.started"
|
||||
? "running"
|
||||
: session.status;
|
||||
if (
|
||||
event.event === "session.updated" &&
|
||||
event.sequence === undefined &&
|
||||
status === "running" &&
|
||||
session.endedAt !== undefined &&
|
||||
!session.busy
|
||||
) {
|
||||
return;
|
||||
}
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
@@ -1011,7 +1141,7 @@ async function handleHubApprovalRequest(
|
||||
? (event.payload.policy as ToolApprovalRequest["policy"])
|
||||
: { autoApprove: false },
|
||||
});
|
||||
const client = ctx.hubClient;
|
||||
const client = getSessionRuntimeBinding(ctx, sessionId).hubClient;
|
||||
if (!client)
|
||||
throw new Error("Hub client disconnected before approval response");
|
||||
await client.command(
|
||||
@@ -1032,7 +1162,9 @@ export async function initializeSessionManager(
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
capabilities: createSidecarRuntimeCapabilities(
|
||||
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
|
||||
),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
featureFlags: getDesktopFeatureFlagsService({
|
||||
@@ -1041,8 +1173,8 @@ export async function initializeSessionManager(
|
||||
}),
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
},
|
||||
@@ -1050,27 +1182,193 @@ export async function initializeSessionManager(
|
||||
|
||||
// Subscribe to all session events and relay them to WS clients
|
||||
const unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
handleCoreSessionEvent(
|
||||
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
|
||||
event,
|
||||
);
|
||||
});
|
||||
|
||||
let hubClient: NodeHubClient;
|
||||
try {
|
||||
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
hubClient = await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
|
||||
environmentId: LOCAL_ENVIRONMENT_ID,
|
||||
kind: "local",
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
sessionManager,
|
||||
hubClient,
|
||||
unsubscribeSessionEvents: unsubscribe,
|
||||
});
|
||||
await syncSidecarApprovalReadiness(ctx);
|
||||
}
|
||||
|
||||
export function getRuntimeBinding(
|
||||
ctx: SidecarContext,
|
||||
environmentId = ctx.activeEnvironmentId,
|
||||
): SessionRuntimeBinding {
|
||||
const binding = ctx.runtimeBindings.get(environmentId);
|
||||
if (!binding) {
|
||||
throw new Error(`Environment ${environmentId} is not connected.`);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function getSessionRuntimeBinding(
|
||||
ctx: SidecarContext,
|
||||
sessionId?: string,
|
||||
requestedEnvironmentId?: string,
|
||||
): SessionRuntimeBinding {
|
||||
const environmentId =
|
||||
requestedEnvironmentId?.trim() ||
|
||||
(sessionId ? ctx.liveSessions.get(sessionId)?.environmentId : undefined) ||
|
||||
(sessionId ? ctx.sessionEnvironmentIds.get(sessionId) : undefined) ||
|
||||
ctx.activeEnvironmentId;
|
||||
return getRuntimeBinding(ctx, environmentId);
|
||||
}
|
||||
|
||||
export async function findSessionRuntimeBinding(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
preferredEnvironmentId?: string,
|
||||
): Promise<SessionRuntimeBinding | undefined> {
|
||||
if (preferredEnvironmentId?.trim())
|
||||
return getRuntimeBinding(ctx, preferredEnvironmentId.trim());
|
||||
const matches: SessionRuntimeBinding[] = [];
|
||||
for (const binding of ctx.runtimeBindings.values()) {
|
||||
try {
|
||||
if (await binding.sessionManager.get(sessionId)) matches.push(binding);
|
||||
} catch {
|
||||
// Other connected runtimes remain readable.
|
||||
}
|
||||
}
|
||||
if (matches.length > 1)
|
||||
throw new Error(
|
||||
`Session ${sessionId} exists in multiple environments; environmentId is required.`,
|
||||
);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
async function disposeRuntimeBinding(
|
||||
binding: SessionRuntimeBinding,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
binding.unsubscribeSessionEvents();
|
||||
} catch {
|
||||
// Continue disposing the Hub clients even if an event source has already
|
||||
// torn down its subscription.
|
||||
}
|
||||
await Promise.allSettled([
|
||||
binding.hubClient.dispose(),
|
||||
binding.sessionManager.dispose(reason),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function connectRemoteSessionRuntime(
|
||||
ctx: SidecarContext,
|
||||
connection: NonNullable<SessionRuntimeBinding["remote"]>,
|
||||
): Promise<SessionRuntimeBinding> {
|
||||
const environmentId = connection.profile.id;
|
||||
const existing = ctx.runtimeBindings.get(environmentId);
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
capabilities: createSidecarRuntimeCapabilities(
|
||||
getEnvironmentContext(ctx, environmentId),
|
||||
),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
remote: {
|
||||
endpoint: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
displayName: `Code App (${connection.profile.name})`,
|
||||
},
|
||||
});
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let hubClient: NodeHubClient | undefined;
|
||||
try {
|
||||
unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
|
||||
handleCoreSessionEvent(getEnvironmentContext(ctx, environmentId), event);
|
||||
});
|
||||
hubClient = new NodeHubClient({
|
||||
url: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
clientType: "code-sidecar-ssh-observer",
|
||||
displayName: `Code App observer (${connection.profile.name})`,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) =>
|
||||
handleHubLiveEvent(getEnvironmentContext(ctx, environmentId), event),
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch {
|
||||
// Best effort; the failed runtime still needs to be disposed below.
|
||||
}
|
||||
const disposals: Promise<unknown>[] = [
|
||||
sessionManager.dispose("code_sidecar_remote_initialization_failed"),
|
||||
];
|
||||
if (hubClient) disposals.push(hubClient.dispose());
|
||||
await Promise.allSettled(disposals);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const binding: SessionRuntimeBinding = {
|
||||
environmentId,
|
||||
kind: "ssh",
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
sessionManager,
|
||||
hubClient,
|
||||
unsubscribeSessionEvents: unsubscribe,
|
||||
remote: connection,
|
||||
};
|
||||
ctx.runtimeBindings.set(environmentId, binding);
|
||||
await syncSidecarApprovalReadiness(ctx);
|
||||
ctx.activeEnvironmentId = environmentId;
|
||||
if (existing) {
|
||||
await disposeRuntimeBinding(existing, "code_sidecar_remote_reconnect");
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export async function disconnectRemoteSessionRuntime(
|
||||
ctx: SidecarContext,
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
const binding = ctx.runtimeBindings.get(environmentId);
|
||||
const owner = contextOwners.get(ctx) ?? ctx;
|
||||
const scoped = environmentContexts.get(owner)?.get(environmentId);
|
||||
if (scoped)
|
||||
clearEnvironmentSessions(scoped, "Remote environment disconnected");
|
||||
environmentContexts.get(owner)?.delete(environmentId);
|
||||
if (binding?.kind === "ssh") {
|
||||
ctx.runtimeBindings.delete(environmentId);
|
||||
await disposeRuntimeBinding(binding, "code_sidecar_remote_disconnect");
|
||||
}
|
||||
if (ctx.activeEnvironmentId === environmentId) {
|
||||
ctx.activeEnvironmentId = LOCAL_ENVIRONMENT_ID;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSharedHubClient(
|
||||
ctx: SidecarContext,
|
||||
preferredUrl?: string,
|
||||
): Promise<NodeHubClient> {
|
||||
if (ctx.hubClient) {
|
||||
return ctx.hubClient;
|
||||
const existing = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const pending = hubClientInitialization.get(ctx);
|
||||
if (pending) {
|
||||
@@ -1082,8 +1380,8 @@ export async function ensureSharedHubClient(
|
||||
preferredUrl?.trim() ||
|
||||
(await ensureCompatibleLocalHubUrl({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
}));
|
||||
if (!url) {
|
||||
throw new Error("Unable to start or connect to the shared Cline Hub.");
|
||||
@@ -1093,16 +1391,17 @@ export async function ensureSharedHubClient(
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
handleHubLiveEvent(
|
||||
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
|
||||
event,
|
||||
);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
await syncSidecarApprovalReadiness(ctx);
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
readDesktopSettings,
|
||||
resolveDesktopSettingsPath,
|
||||
setCloudSessionsEnabled,
|
||||
} from "./desktop-settings";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("desktop settings", () => {
|
||||
it("defaults cloud sessions to off when no settings file exists", () => {
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("persists the cloud sessions opt-in and reads it back", () => {
|
||||
expect(setCloudSessionsEnabled(true)).toEqual({
|
||||
cloudSessionsEnabled: true,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
|
||||
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")),
|
||||
).toMatchObject({ cloudSessionsEnabled: true });
|
||||
expect(setCloudSessionsEnabled(false)).toEqual({
|
||||
cloudSessionsEnabled: false,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("treats malformed files and non-boolean values as off", () => {
|
||||
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
|
||||
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
writeFileSync(
|
||||
resolveDesktopSettingsPath(),
|
||||
JSON.stringify({ cloudSessionsEnabled: "yes" }),
|
||||
"utf8",
|
||||
);
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
/** Desktop-only preferences kept separate from strict shared global settings. */
|
||||
export type DesktopSettings = {
|
||||
/** Opt-in gate for cloud sessions while the feature is in preview. */
|
||||
cloudSessionsEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: DesktopSettings = {
|
||||
cloudSessionsEnabled: false,
|
||||
};
|
||||
|
||||
export function resolveDesktopSettingsPath(): string {
|
||||
return join(resolveClineDataDir(), "settings", "code-settings.json");
|
||||
}
|
||||
|
||||
export function readDesktopSettings(): DesktopSettings {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDesktopSettings(settings: DesktopSettings): void {
|
||||
const filePath = resolveDesktopSettingsPath();
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
// Avoid leaving torn settings if the process exits mid-write.
|
||||
const tempPath = `${filePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
|
||||
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
|
||||
writeDesktopSettings(next);
|
||||
return next;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
poll: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
setContext: vi.fn(),
|
||||
getBooleanFlagEnabled: vi.fn((_flag: unknown): boolean => false),
|
||||
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
|
||||
}));
|
||||
|
||||
@@ -39,6 +43,7 @@ vi.mock("@cline/core", async () => {
|
||||
poll = mocks.poll;
|
||||
dispose = mocks.dispose;
|
||||
setContext = mocks.setContext;
|
||||
getBooleanFlagEnabled = mocks.getBooleanFlagEnabled;
|
||||
getFlagPayload = mocks.getFlagPayload;
|
||||
},
|
||||
};
|
||||
@@ -61,15 +66,26 @@ import {
|
||||
|
||||
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const originalIsTest = process.env.IS_TEST;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-feature-flags-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
vi.clearAllMocks();
|
||||
mocks.getBooleanFlagEnabled.mockReset().mockReturnValue(false);
|
||||
resetDesktopFeatureFlagsForTesting();
|
||||
delete process.env.IS_TEST;
|
||||
delete process.env.E2E_TEST;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
} else {
|
||||
@@ -239,3 +255,53 @@ describe("disposeDesktopFeatureFlagsService", () => {
|
||||
expect(mocks.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloud agents gate", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.CLINE_CODE_CLOUD_AGENTS;
|
||||
});
|
||||
|
||||
it("is unavailable and disabled while the rollout flag is off", async () => {
|
||||
const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
expect(isCloudAgentsAvailable()).toBe(false);
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not enable a boolean rollout from a truthy variant payload", async () => {
|
||||
const { isCloudAgentsAvailable } = await import("./feature-flags");
|
||||
mocks.getFlagPayload.mockReturnValue("control");
|
||||
expect(isCloudAgentsAvailable()).toBe(false);
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith(
|
||||
"code-cloud-agents",
|
||||
);
|
||||
});
|
||||
|
||||
it("needs both the rollout flag and the user's opt-in to enable", async () => {
|
||||
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
const { setCloudSessionsEnabled } = await import("./desktop-settings");
|
||||
mocks.getBooleanFlagEnabled.mockImplementation(
|
||||
(flag: unknown) => flag === "code-cloud-agents",
|
||||
);
|
||||
expect(isCloudAgentsAvailable()).toBe(true);
|
||||
setCloudSessionsEnabled(false);
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
setCloudSessionsEnabled(true);
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the env override force the gate in both directions", async () => {
|
||||
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
mocks.getFlagPayload.mockReturnValue(undefined);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
|
||||
expect(isCloudAgentsAvailable()).toBe(true);
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { FeatureFlag as SharedFeatureFlag } from "@cline/shared";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
import { readDesktopSettings } from "./desktop-settings";
|
||||
|
||||
const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS;
|
||||
|
||||
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -169,3 +173,33 @@ export function resetDesktopFeatureFlagsForTesting(): void {
|
||||
desktopFeatureFlagsService = undefined;
|
||||
desktopFeatureFlagsContext = { clientName: "cline-code" };
|
||||
}
|
||||
|
||||
export function readCloudAgentsEnvOverride(): boolean | undefined {
|
||||
const override = process.env.CLINE_CODE_CLOUD_AGENTS?.trim().toLowerCase();
|
||||
if (override === "1" || override === "true") return true;
|
||||
if (override === "0" || override === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether the rollout makes cloud sessions available to this install. */
|
||||
export function isCloudAgentsAvailable(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): boolean {
|
||||
const override = readCloudAgentsEnvOverride();
|
||||
if (override !== undefined) return override;
|
||||
return getDesktopFeatureFlagsService(options).getBooleanFlagEnabled(
|
||||
FEATURE_FLAG_CODE_CLOUD_AGENTS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether cloud sessions are both available and enabled by the user. */
|
||||
export function isCloudAgentsEnabled(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): boolean {
|
||||
const override = readCloudAgentsEnvOverride();
|
||||
if (override !== undefined) return override;
|
||||
if (!isCloudAgentsAvailable(options)) return false;
|
||||
return readDesktopSettings().cloudSessionsEnabled;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,21 @@ import { homedir } from "node:os";
|
||||
import {
|
||||
checkManagedHubBuildMismatch,
|
||||
createClineTelemetryServiceConfig,
|
||||
ensureLoginShellPath,
|
||||
readGlobalSettings,
|
||||
setHomeDirIfUnset,
|
||||
setModelToolEnabledGlobally,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper";
|
||||
import {
|
||||
captureSdkError,
|
||||
claimHubDaemonProcess,
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
setClineClientIdentity,
|
||||
} from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
broadcastEvent,
|
||||
@@ -19,7 +27,6 @@ import {
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
@@ -232,12 +239,20 @@ async function runEntrypoint(): Promise<void> {
|
||||
runTelemetrySelfcheck();
|
||||
return;
|
||||
}
|
||||
// Claim rather than read: consuming the sentinel keeps daemon-hosted sessions
|
||||
// from handing it to every process they spawn.
|
||||
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
|
||||
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
// Claim the Hub daemon sentinel here, not in the shared remote helper: its
|
||||
// daemon import resolves to the dist build of @cline/core while this bundle
|
||||
// resolves the source build, and a daemon from the other copy publishes a
|
||||
// different build id, so the sidecar would retire its own Hub on launch.
|
||||
if (claimHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
if (await runRemoteHelperEntrypoint()) {
|
||||
return;
|
||||
}
|
||||
await main();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
|
||||
|
||||
describe("clearLegacyProviderCredentials", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes only the Codex credentials from the legacy secrets file", () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
|
||||
tempDirs.push(dataDir);
|
||||
const secretsPath = path.join(dataDir, "secrets.json");
|
||||
writeFileSync(
|
||||
secretsPath,
|
||||
JSON.stringify({
|
||||
"openai-codex-oauth-credentials": JSON.stringify({
|
||||
access_token: "a",
|
||||
refresh_token: "r",
|
||||
}),
|
||||
openRouterApiKey: "sk-or-keep",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
|
||||
openRouterApiKey: "sk-or-keep",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes both Cline account secrets from the legacy secrets file", () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
|
||||
tempDirs.push(dataDir);
|
||||
const secretsPath = path.join(dataDir, "secrets.json");
|
||||
writeFileSync(
|
||||
secretsPath,
|
||||
JSON.stringify({
|
||||
"cline:clineAccountId": JSON.stringify({ idToken: "t" }),
|
||||
clineApiKey: "cline-key",
|
||||
openRouterApiKey: "sk-or-keep",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(clearLegacyProviderCredentials("cline", dataDir)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
|
||||
openRouterApiKey: "sk-or-keep",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when the file is missing, has no matching credentials, or the provider is unknown", () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
|
||||
tempDirs.push(dataDir);
|
||||
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(false);
|
||||
|
||||
const secretsPath = path.join(dataDir, "secrets.json");
|
||||
writeFileSync(secretsPath, JSON.stringify({ apiKey: "keep" }));
|
||||
expect(clearLegacyProviderCredentials("cline", dataDir)).toBe(false);
|
||||
expect(clearLegacyProviderCredentials("anthropic", dataDir)).toBe(false);
|
||||
expect(readFileSync(secretsPath, "utf8")).toBe(
|
||||
JSON.stringify({ apiKey: "keep" }),
|
||||
);
|
||||
|
||||
writeFileSync(secretsPath, "{not json");
|
||||
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
/**
|
||||
* Legacy VS Code extension secrets.json keys that the legacy import in
|
||||
* ProviderSettingsManager turns back into a providers.json entry.
|
||||
*/
|
||||
const LEGACY_SECRET_KEYS_BY_PROVIDER: Record<string, string[]> = {
|
||||
"openai-codex": ["openai-codex-oauth-credentials"],
|
||||
cline: ["cline:clineAccountId", "clineApiKey"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a provider's credentials from the legacy VS Code extension's
|
||||
* secrets.json. The legacy import in ProviderSettingsManager runs on
|
||||
* construction and re-adds any provider missing from providers.json, so
|
||||
* leaving these credentials on disk would sign the user straight back in
|
||||
* after they sign out in the desktop app. Temporary until the legacy import
|
||||
* is retired.
|
||||
*
|
||||
* A missing or unparseable file is a no-op (the import ignores those too).
|
||||
* A failed write throws so the sign-out is reported as failed instead of
|
||||
* succeeding and then being undone by the next import.
|
||||
*/
|
||||
export function clearLegacyProviderCredentials(
|
||||
providerId: string,
|
||||
dataDir: string = resolveClineDataDir(),
|
||||
): boolean {
|
||||
const keys = LEGACY_SECRET_KEYS_BY_PROVIDER[providerId];
|
||||
const secretsPath = join(dataDir, "secrets.json");
|
||||
if (!keys || !existsSync(secretsPath)) {
|
||||
return false;
|
||||
}
|
||||
let secrets: unknown;
|
||||
try {
|
||||
secrets = JSON.parse(readFileSync(secretsPath, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) {
|
||||
return false;
|
||||
}
|
||||
const present = keys.filter((key) => key in secrets);
|
||||
if (present.length === 0) {
|
||||
return false;
|
||||
}
|
||||
for (const key of present) {
|
||||
delete (secrets as Record<string, unknown>)[key];
|
||||
}
|
||||
writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { handleCommand } from "./commands";
|
||||
import { createSidecarContext } from "./context";
|
||||
import {
|
||||
buildMcpServersResponse,
|
||||
shouldProbeMcpServerAfterUpsert,
|
||||
@@ -10,19 +11,7 @@ import {
|
||||
import type { JsonRecord, SidecarContext } from "./types";
|
||||
|
||||
function createContext(workspaceRoot: string): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
return createSidecarContext(workspaceRoot);
|
||||
}
|
||||
|
||||
describe("desktop MCP settings", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { version } from "../package.json";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureExtensionActivated: vi.fn(),
|
||||
@@ -28,7 +29,14 @@ vi.mock("@cline/core", async () => {
|
||||
identifyAccount: mocks.identifyAccount,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings() {
|
||||
return { auth: { accountId: "account-1" } };
|
||||
return {
|
||||
auth: {
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
setSdkLogger: mocks.setSdkLogger,
|
||||
@@ -57,8 +65,10 @@ describe("desktop observability", () => {
|
||||
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform: "Cline Desktop",
|
||||
platform_version: version,
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
@@ -67,9 +77,18 @@ describe("desktop observability", () => {
|
||||
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
|
||||
id: "account-1",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
});
|
||||
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
|
||||
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
|
||||
expect(observability.telemetryUser).toEqual({
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
email: undefined,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await observability.dispose();
|
||||
await observability.dispose();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
captureExtensionActivated,
|
||||
createClineTelemetryServiceConfig,
|
||||
@@ -8,7 +7,11 @@ import {
|
||||
ProviderSettingsManager,
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import { version } from "../package.json";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
import {
|
||||
DESKTOP_TELEMETRY_METADATA,
|
||||
resolveDesktopTelemetryUser,
|
||||
} from "./client-context";
|
||||
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
export interface DesktopObservability {
|
||||
readonly logger: DesktopLoggerAdapter["core"];
|
||||
readonly telemetry: ITelemetryService;
|
||||
readonly telemetryUser?: UserContext;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,23 +32,23 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
|
||||
const telemetryHandle = createConfiguredTelemetryHandle({
|
||||
...createClineTelemetryServiceConfig({
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
},
|
||||
metadata: DESKTOP_TELEMETRY_METADATA,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const telemetry = telemetryHandle.telemetry;
|
||||
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
|
||||
const telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId: auth?.accountId,
|
||||
organizationId: auth?.organizationId,
|
||||
});
|
||||
if (auth?.accountId) {
|
||||
identifyAccount(telemetry, {
|
||||
id: auth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: auth.organizationId,
|
||||
organizationName: auth.organizationName,
|
||||
memberId: auth.memberId,
|
||||
});
|
||||
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
return {
|
||||
logger,
|
||||
telemetry,
|
||||
telemetryUser,
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ITelemetryService } from "@cline/shared";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { capturePullRequestEvent } from "./pull-request-telemetry";
|
||||
|
||||
const event = {
|
||||
action: "open_clicked",
|
||||
prState: "open",
|
||||
ciState: "success",
|
||||
mergeTone: "success",
|
||||
};
|
||||
function service(enabled = true) {
|
||||
const capture = vi.fn();
|
||||
return {
|
||||
capture,
|
||||
telemetry: {
|
||||
capture,
|
||||
isEnabled: () => enabled,
|
||||
} as unknown as ITelemetryService,
|
||||
};
|
||||
}
|
||||
|
||||
it("captures only allowlisted status categories and strips identifiers", () => {
|
||||
const { capture, telemetry } = service();
|
||||
capturePullRequestEvent(telemetry, {
|
||||
...event,
|
||||
repository: "private/repo",
|
||||
cwd: "/private/path",
|
||||
url: "https://github.com/private/repo",
|
||||
title: "Secret",
|
||||
number: 42,
|
||||
});
|
||||
expect(capture).toHaveBeenCalledExactlyOnceWith({
|
||||
event: "desktop.pull_request.open_clicked",
|
||||
properties: {
|
||||
prState: "open",
|
||||
ciState: "success",
|
||||
mergeTone: "success",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...event, action: "arbitrary.event" },
|
||||
{ ...event, prState: "private/repo" },
|
||||
{ ...event, ciState: "test name" },
|
||||
{ ...event, mergeTone: "secret" },
|
||||
{},
|
||||
null,
|
||||
])("drops invalid payloads", (input) => {
|
||||
const { capture, telemetry } = service();
|
||||
capturePullRequestEvent(telemetry, input);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects telemetry opt-out", () => {
|
||||
const { capture, telemetry } = service(false);
|
||||
capturePullRequestEvent(telemetry, event);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(() => capturePullRequestEvent(undefined, event)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not fail the command when the provider throws", () => {
|
||||
const { capture, telemetry } = service();
|
||||
capture.mockImplementation(() => {
|
||||
throw new Error("Telemetry unavailable");
|
||||
});
|
||||
expect(() => capturePullRequestEvent(telemetry, event)).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ITelemetryService } from "@cline/shared";
|
||||
import { pullRequestTelemetrySchema } from "../webview/lib/pull-request-telemetry-schema";
|
||||
|
||||
export function capturePullRequestEvent(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
input: unknown,
|
||||
): void {
|
||||
const parsed = pullRequestTelemetrySchema.safeParse(input);
|
||||
if (!parsed.success) return;
|
||||
try {
|
||||
if (!telemetry?.isEnabled()) return;
|
||||
const { action, ...properties } = parsed.data;
|
||||
telemetry.capture({ event: `desktop.pull_request.${action}`, properties });
|
||||
} catch {
|
||||
// Product interactions must continue if the telemetry provider fails.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getMergeStatus, summarizeChecks } from "../webview/lib/pull-request";
|
||||
import {
|
||||
createPullRequestStatusReader,
|
||||
GITHUB_AVAILABILITY_CACHE_MS,
|
||||
githubRepository,
|
||||
normalizeCheck,
|
||||
} from "./pull-request";
|
||||
|
||||
const pr = {
|
||||
number: 42,
|
||||
title: "Feature",
|
||||
url: "https://github.com/cline/cline/pull/42",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
mergeable: "MERGEABLE",
|
||||
mergeStateStatus: "CLEAN",
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
headRepositoryOwner: { login: "cline" },
|
||||
headRepository: { name: "cline" },
|
||||
statusCheckRollup: [
|
||||
{
|
||||
__typename: "CheckRun",
|
||||
name: "Test",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SUCCESS",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function runner(prs: unknown[] = [pr], branch = "feature/pr-ui") {
|
||||
return vi.fn(async (file: string, args: string[], _cwd: string) => {
|
||||
if (file === "git")
|
||||
return args[0] === "branch" ? branch : "git@github.com:cline/cline.git";
|
||||
if (args[0] === "pr" && args[1] === "view")
|
||||
return JSON.stringify(
|
||||
prs.find((item) => (item as typeof pr).number === Number(args[2])),
|
||||
);
|
||||
return JSON.stringify(
|
||||
args[0] === "repo" ? { defaultBranchRef: { name: "main" } } : prs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("pull request status", () => {
|
||||
it("reads the active workspace, filters same-named fork branches and prefers an open PR", async () => {
|
||||
const run = runner([
|
||||
{ ...pr, number: 99, headRepositoryOwner: { login: "someone" } },
|
||||
{ ...pr, number: 41, state: "MERGED" },
|
||||
pr,
|
||||
]);
|
||||
const result = await createPullRequestStatusReader({ run })("/worktree");
|
||||
expect(result?.pullRequest?.number).toBe(42);
|
||||
expect(result?.pullRequest?.checks[0].state).toBe("success");
|
||||
expect(result?.createUrl).toBe(
|
||||
"https://github.com/cline/cline/compare/main...feature%2Fpr-ui?expand=1",
|
||||
);
|
||||
expect(run.mock.calls.every((call) => call[2] === "/worktree")).toBe(true);
|
||||
expect(run.mock.calls.find((call) => call[1][0] === "pr")?.[1]).toContain(
|
||||
"feature/pr-ui",
|
||||
);
|
||||
});
|
||||
it("offers creation for a feature branch and hides it for the default branch", async () => {
|
||||
expect(
|
||||
(await createPullRequestStatusReader({ run: runner([]) })("/repo"))
|
||||
?.pullRequest,
|
||||
).toBeNull();
|
||||
expect(
|
||||
await createPullRequestStatusReader({ run: runner([], "main") })("/repo"),
|
||||
).toBeNull();
|
||||
});
|
||||
it("keeps merged and closed PR states", async () => {
|
||||
for (const state of ["MERGED", "CLOSED"] as const) {
|
||||
const result = await createPullRequestStatusReader({
|
||||
run: runner([{ ...pr, state }]),
|
||||
})("/repo");
|
||||
expect(result?.pullRequest?.state).toBe(state);
|
||||
}
|
||||
});
|
||||
it("does not invoke GitHub for detached HEAD or unsupported remotes", async () => {
|
||||
const detached = runner([], "");
|
||||
expect(
|
||||
await createPullRequestStatusReader({ run: detached })("/repo"),
|
||||
).toBeNull();
|
||||
expect(detached).toHaveBeenCalledTimes(1);
|
||||
const local = vi.fn(async () => "local");
|
||||
expect(
|
||||
await createPullRequestStatusReader({ run: local })("/repo"),
|
||||
).toBeNull();
|
||||
expect(local).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([
|
||||
"ENOENT",
|
||||
1,
|
||||
4,
|
||||
])("hides unavailable GitHub CLI (%s), shares the cooldown, and recovers after login", async (code) => {
|
||||
let time = 0;
|
||||
let authenticated = false;
|
||||
const base = runner();
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (file === "gh" && args[0] === "auth" && !authenticated)
|
||||
throw Object.assign(new Error("private stderr"), { code });
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
const read = createPullRequestStatusReader({ run, now: () => time });
|
||||
expect(await read("/repo")).toBeNull();
|
||||
const attempts = run.mock.calls.length;
|
||||
authenticated = true;
|
||||
time = GITHUB_AVAILABILITY_CACHE_MS - 1;
|
||||
expect(await read("/other-workspace")).toBeNull();
|
||||
expect(run).toHaveBeenCalledTimes(attempts);
|
||||
time++;
|
||||
expect((await read("/repo"))?.pullRequest?.number).toBe(42);
|
||||
expect(run.mock.calls.filter((call) => call[1][0] === "auth")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("shares an in-flight availability check across concurrent workspaces", async () => {
|
||||
let finish!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const base = runner();
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (args[0] === "auth") await waiting;
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
const read = createPullRequestStatusReader({ run });
|
||||
const first = read("/first");
|
||||
const second = read("/second");
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
run.mock.calls.filter((call) => call[1][0] === "auth"),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
expect(run.mock.calls.filter((call) => call[1][0] === "auth")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides default-branch authentication failures before any repository query", async () => {
|
||||
const base = runner([], "main");
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (file === "gh")
|
||||
throw Object.assign(new Error("Not logged in"), { code: 1 });
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
expect(await createPullRequestStatusReader({ run })("/repo")).toBeNull();
|
||||
expect(
|
||||
run.mock.calls.filter((call) => call[0] === "gh").map((call) => call[1]),
|
||||
).toEqual([["auth", "status", "--active", "--hostname", "github.com"]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ code: 4 },
|
||||
{ code: 1, stderr: "HTTP 401: Bad credentials" },
|
||||
])("invalidates cached availability if authentication expires during a lookup", async (failure) => {
|
||||
let expired = false;
|
||||
const base = runner();
|
||||
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
|
||||
if (args[0] === "repo" && expired)
|
||||
throw Object.assign(new Error("Failed"), failure);
|
||||
return base(file, args, cwd);
|
||||
});
|
||||
const read = createPullRequestStatusReader({ run });
|
||||
expect((await read("/repo"))?.pullRequest?.number).toBe(42);
|
||||
expired = true;
|
||||
expect(await read("/repo")).toBeNull();
|
||||
const attempts = run.mock.calls.length;
|
||||
expect(await read("/other")).toBeNull();
|
||||
expect(run).toHaveBeenCalledTimes(attempts);
|
||||
});
|
||||
|
||||
it("preserves transient lookup errors after successful authentication", async () => {
|
||||
const base = runner();
|
||||
const run = async (file: string, args: string[], cwd: string) => {
|
||||
if (args[0] === "repo")
|
||||
throw Object.assign(new Error("private stderr"), {
|
||||
code: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
return base(file, args, cwd);
|
||||
};
|
||||
await expect(
|
||||
createPullRequestStatusReader({ run })("/repo"),
|
||||
).rejects.toThrow(
|
||||
"Could not load pull request status. Check your connection and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts GitHub SSH/HTTPS remotes only", () => {
|
||||
for (const remote of [
|
||||
"git@github.com:cline/cline.git",
|
||||
"https://github.com/cline/cline.git",
|
||||
"ssh://git@github.com/cline/cline",
|
||||
])
|
||||
expect(githubRepository(remote)).toBe("cline/cline");
|
||||
expect(
|
||||
githubRepository("https://github.com.evil.test/cline/cline"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("check and merge states", () => {
|
||||
it("handles check runs, legacy statuses, skipped checks and unsafe links", () => {
|
||||
const pending = normalizeCheck({
|
||||
__typename: "CheckRun",
|
||||
status: "IN_PROGRESS",
|
||||
conclusion: "SUCCESS",
|
||||
});
|
||||
const failed = normalizeCheck({
|
||||
__typename: "StatusContext",
|
||||
state: "ERROR",
|
||||
context: "Build",
|
||||
targetUrl: "javascript:alert(1)",
|
||||
});
|
||||
const skipped = normalizeCheck({
|
||||
__typename: "CheckRun",
|
||||
status: "COMPLETED",
|
||||
conclusion: "SKIPPED",
|
||||
});
|
||||
expect(pending.state).toBe("pending");
|
||||
expect(failed).toEqual({ name: "Build", state: "failure", url: undefined });
|
||||
expect(summarizeChecks([pending, failed])).toBe("failure");
|
||||
expect(summarizeChecks([skipped])).toBe("skipped");
|
||||
expect(summarizeChecks([])).toBe("none");
|
||||
});
|
||||
it("never calls an unknown, draft or blocked PR ready to merge", async () => {
|
||||
const result = await createPullRequestStatusReader({ run: runner() })(
|
||||
"/repo",
|
||||
);
|
||||
const value = result!.pullRequest!;
|
||||
expect(
|
||||
getMergeStatus({ ...value, mergeStateStatus: "BLOCKED" }).label,
|
||||
).toBe("Blocked");
|
||||
expect(getMergeStatus({ ...value, isDraft: true }).label).toBe("Draft");
|
||||
expect(
|
||||
getMergeStatus({
|
||||
...value,
|
||||
mergeable: "UNKNOWN",
|
||||
mergeStateStatus: "UNKNOWN",
|
||||
}).label,
|
||||
).toBe("Merge status pending");
|
||||
expect(getMergeStatus({ ...value, mergeable: "CONFLICTING" }).label).toBe(
|
||||
"Conflicts",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
PullRequestCheck,
|
||||
PullRequestStatus,
|
||||
} from "../webview/lib/pull-request";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type RunCommand = (
|
||||
file: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
) => Promise<string>;
|
||||
const runCommand: RunCommand = async (file, args, cwd) => {
|
||||
const { stdout } = await execFileAsync(file, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
|
||||
});
|
||||
return stdout.trim();
|
||||
};
|
||||
|
||||
type GitHubCheck = {
|
||||
__typename: string;
|
||||
name?: string;
|
||||
context?: string;
|
||||
status?: string;
|
||||
conclusion?: string;
|
||||
state?: string;
|
||||
detailsUrl?: string;
|
||||
targetUrl?: string;
|
||||
};
|
||||
|
||||
export function normalizeCheck(check: GitHubCheck): PullRequestCheck {
|
||||
const result =
|
||||
check.__typename === "CheckRun"
|
||||
? check.status === "COMPLETED"
|
||||
? check.conclusion
|
||||
: "PENDING"
|
||||
: check.state;
|
||||
return {
|
||||
name: check.name || check.context || "Check",
|
||||
state:
|
||||
result === "SUCCESS"
|
||||
? "success"
|
||||
: result === "NEUTRAL" || result === "SKIPPED"
|
||||
? "skipped"
|
||||
: [
|
||||
"FAILURE",
|
||||
"ERROR",
|
||||
"CANCELLED",
|
||||
"TIMED_OUT",
|
||||
"ACTION_REQUIRED",
|
||||
"STALE",
|
||||
"STARTUP_FAILURE",
|
||||
].includes(result ?? "")
|
||||
? "failure"
|
||||
: "pending",
|
||||
url: safeHttpUrl(check.detailsUrl || check.targetUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHttpUrl(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function githubRepository(remote: string): string | null {
|
||||
const match = remote.match(
|
||||
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)\/?$/,
|
||||
);
|
||||
return match ? `${match[1]}/${match[2].replace(/\.git$/, "")}` : null;
|
||||
}
|
||||
|
||||
type GitHubPullRequest = Omit<
|
||||
NonNullable<PullRequestStatus["pullRequest"]>,
|
||||
"checks"
|
||||
> & {
|
||||
headRepositoryOwner: { login: string } | null;
|
||||
headRepository: { name: string } | null;
|
||||
statusCheckRollup: GitHubCheck[] | null;
|
||||
};
|
||||
|
||||
// Shared across workspaces: missing credentials are a machine-level capability,
|
||||
// not a repository failure. Retry after installation/login without polling gh.
|
||||
export const GITHUB_AVAILABILITY_CACHE_MS = 5 * 60_000;
|
||||
|
||||
function commandErrorCode(error: unknown): unknown {
|
||||
return error && typeof error === "object" && "code" in error
|
||||
? error.code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isAuthenticationFailure(error: unknown): boolean {
|
||||
if (commandErrorCode(error) === 4) return true;
|
||||
const stderr =
|
||||
error && typeof error === "object" && "stderr" in error
|
||||
? String(error.stderr)
|
||||
: "";
|
||||
return /HTTP 401|Bad credentials/i.test(stderr);
|
||||
}
|
||||
|
||||
export function createPullRequestStatusReader({
|
||||
run = runCommand,
|
||||
now = Date.now,
|
||||
}: {
|
||||
run?: RunCommand;
|
||||
now?: () => number;
|
||||
} = {}) {
|
||||
let available: boolean | undefined;
|
||||
let expiresAt = 0;
|
||||
let probe: Promise<boolean> | undefined;
|
||||
|
||||
function markUnavailable() {
|
||||
available = false;
|
||||
expiresAt = now() + GITHUB_AVAILABILITY_CACHE_MS;
|
||||
}
|
||||
|
||||
async function isAvailable(cwd: string): Promise<boolean> {
|
||||
if (available !== undefined && now() < expiresAt) return available;
|
||||
if (probe) return probe;
|
||||
probe = (async () => {
|
||||
try {
|
||||
await run(
|
||||
"gh",
|
||||
["auth", "status", "--active", "--hostname", "github.com"],
|
||||
cwd,
|
||||
);
|
||||
available = true;
|
||||
expiresAt = now() + GITHUB_AVAILABILITY_CACHE_MS;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// gh auth status documents exit 1 for missing/invalid authentication.
|
||||
const code = commandErrorCode(error);
|
||||
if (code === "ENOENT" || code === 1 || code === 4) {
|
||||
markUnavailable();
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await probe;
|
||||
} finally {
|
||||
probe = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only: creation is reviewed and submitted in GitHub's compare form. */
|
||||
return async function readPullRequestStatus(
|
||||
cwd: string,
|
||||
): Promise<PullRequestStatus | null> {
|
||||
if (available === false && now() < expiresAt) return null;
|
||||
const branch = await run("git", ["branch", "--show-current"], cwd).catch(
|
||||
() => "",
|
||||
);
|
||||
if (!branch) return null;
|
||||
const remote = await run("git", ["remote", "get-url", "origin"], cwd).catch(
|
||||
() => "",
|
||||
);
|
||||
const repository = githubRepository(remote);
|
||||
if (!repository) return null;
|
||||
try {
|
||||
if (!(await isAvailable(cwd))) return null;
|
||||
const repo = JSON.parse(
|
||||
await run(
|
||||
"gh",
|
||||
["repo", "view", repository, "--json", "defaultBranchRef"],
|
||||
cwd,
|
||||
),
|
||||
) as { defaultBranchRef: { name: string } | null };
|
||||
const base = repo.defaultBranchRef?.name;
|
||||
// The default branch can have years-old PRs from earlier branch workflows.
|
||||
// Those do not describe the current work, and it is not a PR source branch.
|
||||
if (branch === base) return null;
|
||||
const prsJson = await run(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
repository,
|
||||
"--head",
|
||||
branch,
|
||||
"--state",
|
||||
"all",
|
||||
"--limit",
|
||||
"100",
|
||||
"--json",
|
||||
"number,state,headRepositoryOwner,headRepository",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
const [owner, name] = repository.split("/");
|
||||
const candidates = (
|
||||
JSON.parse(prsJson) as Pick<
|
||||
GitHubPullRequest,
|
||||
"number" | "state" | "headRepositoryOwner" | "headRepository"
|
||||
>[]
|
||||
).filter(
|
||||
(pr) =>
|
||||
pr.headRepositoryOwner?.login.toLowerCase() === owner.toLowerCase() &&
|
||||
pr.headRepository?.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
const candidate =
|
||||
candidates.find((pr) => pr.state === "OPEN") ?? candidates[0];
|
||||
const pr = candidate
|
||||
? (JSON.parse(
|
||||
await run(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
String(candidate.number),
|
||||
"--repo",
|
||||
repository,
|
||||
"--json",
|
||||
"number,title,url,state,isDraft,mergeable,mergeStateStatus,additions,deletions,statusCheckRollup",
|
||||
],
|
||||
cwd,
|
||||
),
|
||||
) as GitHubPullRequest)
|
||||
: null;
|
||||
return {
|
||||
repository,
|
||||
branch,
|
||||
createUrl:
|
||||
base && branch !== base
|
||||
? `https://github.com/${repository}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`
|
||||
: null,
|
||||
pullRequest: pr
|
||||
? {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
isDraft: pr.isDraft,
|
||||
mergeable: pr.mergeable,
|
||||
mergeStateStatus: pr.mergeStateStatus,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
checks: (pr.statusCheckRollup ?? []).map(normalizeCheck),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// Authentication can expire while a positive availability result is cached.
|
||||
if (
|
||||
commandErrorCode(error) === "ENOENT" ||
|
||||
isAuthenticationFailure(error)
|
||||
) {
|
||||
markUnavailable();
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
"Could not load pull request status. Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getPullRequestStatus = createPullRequestStatusReader();
|
||||
@@ -0,0 +1,950 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
RemoteEnvironmentConnection,
|
||||
RemoteEnvironmentProfile,
|
||||
RemoteEnvironmentService,
|
||||
RemoteEnvironmentStatus,
|
||||
} from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionRuntimeBinding, SidecarContext } from "./types";
|
||||
|
||||
const coreCreateMock = vi.hoisted(() => vi.fn());
|
||||
const hubClientConstructorMock = vi.hoisted(() => vi.fn());
|
||||
const hubConnectMock = vi.hoisted(() => vi.fn());
|
||||
const hubSubscribeMock = vi.hoisted(() => vi.fn());
|
||||
const hubDisposeMock = vi.hoisted(() => vi.fn());
|
||||
const sessionStoreGetMock = vi.hoisted(() => vi.fn());
|
||||
const sessionStoreDeleteMock = vi.hoisted(() => vi.fn());
|
||||
const sessionStoreRunMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ClineCore: {
|
||||
create: coreCreateMock,
|
||||
},
|
||||
SqliteSessionStore: class {
|
||||
public get(sessionId: string): unknown {
|
||||
return sessionStoreGetMock(sessionId);
|
||||
}
|
||||
|
||||
public delete(sessionId: string, cascade?: boolean): boolean {
|
||||
return sessionStoreDeleteMock(sessionId, cascade);
|
||||
}
|
||||
|
||||
public run(sql: string, params?: unknown[]): void {
|
||||
sessionStoreRunMock(sql, params);
|
||||
}
|
||||
},
|
||||
NodeHubClient: class {
|
||||
public async updateCapabilities(): Promise<void> {}
|
||||
public constructor(options: unknown) {
|
||||
hubClientConstructorMock(options);
|
||||
}
|
||||
|
||||
public connect(): Promise<void> {
|
||||
return hubConnectMock();
|
||||
}
|
||||
|
||||
public subscribe(listener: unknown): () => void {
|
||||
return hubSubscribeMock(listener);
|
||||
}
|
||||
|
||||
public dispose(): Promise<void> {
|
||||
return hubDisposeMock();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const profile: RemoteEnvironmentProfile = {
|
||||
id: "remote-1",
|
||||
name: "Build box",
|
||||
host: "build.example.com",
|
||||
user: "alice",
|
||||
port: 2222,
|
||||
createdAt: "2026-08-06T12:00:00.000Z",
|
||||
updatedAt: "2026-08-06T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const connection: RemoteEnvironmentConnection = {
|
||||
profile,
|
||||
profileId: profile.id,
|
||||
state: "connected",
|
||||
endpoint: "ws://127.0.0.1:40123/hub",
|
||||
authToken: "remote-hub-token",
|
||||
workspaceRoot: "/home/alice",
|
||||
homeDir: "/home/alice",
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
remoteHubUrl: "ws://127.0.0.1:25463/hub",
|
||||
localPort: 40123,
|
||||
connectedAt: "2026-08-06T12:01:00.000Z",
|
||||
};
|
||||
|
||||
const secondProfile: RemoteEnvironmentProfile = {
|
||||
...profile,
|
||||
id: "remote-2",
|
||||
name: "Test box",
|
||||
host: "test.example.com",
|
||||
updatedAt: "2026-08-06T12:02:00.000Z",
|
||||
};
|
||||
|
||||
const secondConnection: RemoteEnvironmentConnection = {
|
||||
...connection,
|
||||
profile: secondProfile,
|
||||
profileId: secondProfile.id,
|
||||
endpoint: "ws://127.0.0.1:40124/hub",
|
||||
authToken: "second-remote-hub-token",
|
||||
workspaceRoot: "/home/tester",
|
||||
homeDir: "/home/tester",
|
||||
remoteHubUrl: "ws://127.0.0.1:25464/hub",
|
||||
localPort: 40124,
|
||||
connectedAt: "2026-08-06T12:03:00.000Z",
|
||||
};
|
||||
|
||||
type FakeService = {
|
||||
service: RemoteEnvironmentService;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
test: ReturnType<typeof vi.fn>;
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createFakeService(
|
||||
availableConnections: RemoteEnvironmentConnection[] = [connection],
|
||||
): FakeService {
|
||||
const profiles = availableConnections.map((item) => item.profile);
|
||||
const availableById = new Map(
|
||||
availableConnections.map((item) => [item.profileId, item]),
|
||||
);
|
||||
const connectedById = new Map<string, RemoteEnvironmentConnection>();
|
||||
let activeProfileId: string | undefined;
|
||||
const list = vi.fn(async () => profiles);
|
||||
const upsert = vi.fn(async () => profile);
|
||||
const test = vi.fn(
|
||||
async (): Promise<RemoteEnvironmentStatus> => ({
|
||||
profileId: profile.id,
|
||||
state: "available",
|
||||
updatedAt: "2026-08-06T12:00:30.000Z",
|
||||
message: "SSH connection succeeded",
|
||||
remotePlatform: "linux",
|
||||
remoteArch: "arm64",
|
||||
}),
|
||||
);
|
||||
const connect = vi.fn(async (id: string) => {
|
||||
const next = availableById.get(id);
|
||||
if (!next) throw new Error(`Unknown fake remote environment: ${id}`);
|
||||
connectedById.set(id, next);
|
||||
activeProfileId = id;
|
||||
return next;
|
||||
});
|
||||
const disconnect = vi.fn(async (id?: string) => {
|
||||
const targetId = id ?? activeProfileId;
|
||||
if (!targetId) return false;
|
||||
const deleted = connectedById.delete(targetId);
|
||||
if (activeProfileId === targetId) activeProfileId = undefined;
|
||||
return deleted;
|
||||
});
|
||||
const deleteProfile = vi.fn(async () => true);
|
||||
const run = vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
|
||||
const service = {
|
||||
list,
|
||||
upsert,
|
||||
test,
|
||||
connect,
|
||||
disconnect,
|
||||
delete: deleteProfile,
|
||||
run,
|
||||
getActive: vi.fn(() =>
|
||||
activeProfileId ? connectedById.get(activeProfileId) : undefined,
|
||||
),
|
||||
getConnection: vi.fn((id: string) => connectedById.get(id)),
|
||||
activateConnection: vi.fn((id: string) => {
|
||||
if (!connectedById.has(id)) return false;
|
||||
activeProfileId = id;
|
||||
return true;
|
||||
}),
|
||||
getStatuses: vi.fn(() => []),
|
||||
} as unknown as RemoteEnvironmentService;
|
||||
return {
|
||||
service,
|
||||
list,
|
||||
upsert,
|
||||
test,
|
||||
connect,
|
||||
disconnect,
|
||||
delete: deleteProfile,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
function createManager() {
|
||||
const unsubscribe = vi.fn();
|
||||
const manager = {
|
||||
subscribe: vi.fn(() => unsubscribe),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
};
|
||||
return { manager, unsubscribe };
|
||||
}
|
||||
|
||||
function attachEventRecorder(ctx: SidecarContext): ReturnType<typeof vi.fn> {
|
||||
const send = vi.fn();
|
||||
ctx.wsClients.add({ send });
|
||||
return send;
|
||||
}
|
||||
|
||||
function readEvent(send: ReturnType<typeof vi.fn>, index: number) {
|
||||
return JSON.parse(String(send.mock.calls[index]?.[0]));
|
||||
}
|
||||
|
||||
function createExistingRemoteBinding(
|
||||
environmentId: string,
|
||||
): SessionRuntimeBinding {
|
||||
const sessionManager = {
|
||||
dispose: vi.fn(async () => undefined),
|
||||
} as unknown as SessionRuntimeBinding["sessionManager"] & {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const hubClient = {
|
||||
dispose: vi.fn(async () => undefined),
|
||||
} as unknown as SessionRuntimeBinding["hubClient"] & {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
return {
|
||||
environmentId,
|
||||
kind: "ssh",
|
||||
workspaceRoot: "/old/workspace",
|
||||
sessionManager,
|
||||
hubClient,
|
||||
unsubscribeSessionEvents: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("remote environment command routing", () => {
|
||||
beforeEach(() => {
|
||||
coreCreateMock.mockReset();
|
||||
hubClientConstructorMock.mockReset();
|
||||
hubConnectMock.mockReset();
|
||||
hubSubscribeMock.mockReset();
|
||||
hubDisposeMock.mockReset();
|
||||
sessionStoreGetMock.mockReset();
|
||||
sessionStoreDeleteMock.mockReset();
|
||||
sessionStoreRunMock.mockReset();
|
||||
hubConnectMock.mockResolvedValue(undefined);
|
||||
hubSubscribeMock.mockReturnValue(() => undefined);
|
||||
hubDisposeMock.mockResolvedValue(undefined);
|
||||
sessionStoreGetMock.mockReturnValue(undefined);
|
||||
sessionStoreDeleteMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("routes proceed-while-running exclusively to the requested SSH Hub", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
const remoteCommand = vi.fn(async () => ({
|
||||
ok: true,
|
||||
payload: { detachedCount: 1 },
|
||||
}));
|
||||
const localCommand = vi.fn();
|
||||
ctx.runtimeBindings.set("local", {
|
||||
...createExistingRemoteBinding("local"),
|
||||
kind: "local",
|
||||
hubClient: { command: localCommand },
|
||||
} as unknown as SessionRuntimeBinding);
|
||||
ctx.runtimeBindings.set(profile.id, {
|
||||
...createExistingRemoteBinding(profile.id),
|
||||
hubClient: { command: remoteCommand },
|
||||
} as unknown as SessionRuntimeBinding);
|
||||
await expect(
|
||||
handleCommand(ctx, "proceed_while_running", {
|
||||
environmentId: profile.id,
|
||||
sessionId: "same-id",
|
||||
toolCallId: "tool-1",
|
||||
}),
|
||||
).resolves.toEqual({ detachedCount: 1 });
|
||||
expect(remoteCommand).toHaveBeenCalledWith(
|
||||
"run.proceed_while_running",
|
||||
{ sessionId: "same-id", toolCallId: "tool-1" },
|
||||
"same-id",
|
||||
);
|
||||
expect(localCommand).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
handleCommand(ctx, "proceed_while_running", {
|
||||
environmentId: "disconnected",
|
||||
sessionId: "same-id",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(localCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes list, upsert, and SSH test commands through the configured service", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService();
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
const send = attachEventRecorder(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "list_remote_environments"),
|
||||
).resolves.toEqual({
|
||||
profiles: [profile],
|
||||
activeEnvironmentId: "local",
|
||||
activeProfileId: null,
|
||||
statuses: [],
|
||||
});
|
||||
|
||||
const input = {
|
||||
id: profile.id,
|
||||
name: "Build box renamed",
|
||||
host: profile.host,
|
||||
};
|
||||
await expect(
|
||||
handleCommand(ctx, "upsert_remote_environment", { profile: input }),
|
||||
).resolves.toEqual({ profile });
|
||||
expect(fake.upsert).toHaveBeenCalledWith(input);
|
||||
expect(readEvent(send, 0)).toMatchObject({
|
||||
event: { name: "remote_environment_profiles_changed" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "test_remote_environment", { id: ` ${profile.id} ` }),
|
||||
).resolves.toEqual({
|
||||
profile,
|
||||
status: "passed",
|
||||
message: "SSH connection succeeded",
|
||||
remotePlatform: "linux",
|
||||
remoteArch: "arm64",
|
||||
});
|
||||
expect(fake.test).toHaveBeenCalledWith(profile.id);
|
||||
});
|
||||
|
||||
it("connects an authenticated remote runtime, records its binding, and disconnects it cleanly", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService();
|
||||
const { manager, unsubscribe } = createManager();
|
||||
coreCreateMock.mockResolvedValue(manager);
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
const send = attachEventRecorder(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "connect_remote_environment", {
|
||||
id: profile.id,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
profile,
|
||||
status: "connected",
|
||||
environmentId: profile.id,
|
||||
activeEnvironmentId: profile.id,
|
||||
activeProfileId: profile.id,
|
||||
workspaceRoot: "/home/alice",
|
||||
homeDir: "/home/alice",
|
||||
remotePlatform: "linux",
|
||||
remoteArch: "arm64",
|
||||
});
|
||||
|
||||
expect(fake.connect).toHaveBeenCalledWith(profile.id);
|
||||
expect(coreCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
remote: {
|
||||
endpoint: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
displayName: "Code App (Build box)",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(hubClientConstructorMock).toHaveBeenCalledWith({
|
||||
url: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
clientType: "code-sidecar-ssh-observer",
|
||||
displayName: "Code App observer (Build box)",
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
});
|
||||
expect(ctx.activeEnvironmentId).toBe(profile.id);
|
||||
expect(ctx.runtimeBindings.get(profile.id)).toMatchObject({
|
||||
environmentId: profile.id,
|
||||
kind: "ssh",
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
remote: connection,
|
||||
});
|
||||
expect(readEvent(send, 0)).toEqual({
|
||||
type: "event",
|
||||
event: {
|
||||
name: "remote_environment_changed",
|
||||
payload: {
|
||||
profile,
|
||||
status: "connected",
|
||||
environmentId: profile.id,
|
||||
activeEnvironmentId: profile.id,
|
||||
activeProfileId: profile.id,
|
||||
workspaceRoot: "/home/alice",
|
||||
homeDir: "/home/alice",
|
||||
remotePlatform: "linux",
|
||||
remoteArch: "arm64",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "disconnect_remote_environment"),
|
||||
).resolves.toEqual({
|
||||
status: "disconnected",
|
||||
disconnectedProfileId: profile.id,
|
||||
activeEnvironmentId: "local",
|
||||
activeProfileId: null,
|
||||
});
|
||||
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(manager.dispose).toHaveBeenCalledWith(
|
||||
"code_sidecar_remote_disconnect",
|
||||
);
|
||||
expect(hubDisposeMock).toHaveBeenCalledOnce();
|
||||
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
|
||||
expect(ctx.activeEnvironmentId).toBe("local");
|
||||
expect(readEvent(send, 1)).toEqual({
|
||||
type: "event",
|
||||
event: {
|
||||
name: "remote_environment_changed",
|
||||
payload: {
|
||||
status: "disconnected",
|
||||
activeProfileId: null,
|
||||
activeEnvironmentId: "local",
|
||||
environmentId: "local",
|
||||
workspaceRoot: "/local/project",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the SSH tunnel and partial runtime when observer authentication fails", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService();
|
||||
const { manager, unsubscribe } = createManager();
|
||||
coreCreateMock.mockResolvedValue(manager);
|
||||
hubConnectMock.mockRejectedValue(new Error("remote auth rejected"));
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
const send = attachEventRecorder(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "connect_remote_environment", { id: profile.id }),
|
||||
).rejects.toThrow("remote auth rejected");
|
||||
|
||||
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(manager.dispose).toHaveBeenCalledWith(
|
||||
"code_sidecar_remote_initialization_failed",
|
||||
);
|
||||
expect(hubDisposeMock).toHaveBeenCalledOnce();
|
||||
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
|
||||
expect(ctx.activeEnvironmentId).toBe("local");
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the previous environment when switching hosts fails", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService([connection, secondConnection]);
|
||||
const firstRuntime = createManager();
|
||||
const failedRuntime = createManager();
|
||||
coreCreateMock
|
||||
.mockResolvedValueOnce(firstRuntime.manager)
|
||||
.mockResolvedValueOnce(failedRuntime.manager);
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
const send = attachEventRecorder(ctx);
|
||||
|
||||
await handleCommand(ctx, "connect_remote_environment", { id: profile.id });
|
||||
const firstBinding = ctx.runtimeBindings.get(profile.id);
|
||||
send.mockClear();
|
||||
hubConnectMock.mockRejectedValueOnce(
|
||||
new Error("second host auth rejected"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "connect_remote_environment", {
|
||||
id: secondProfile.id,
|
||||
}),
|
||||
).rejects.toThrow("second host auth rejected");
|
||||
|
||||
expect(ctx.activeEnvironmentId).toBe(profile.id);
|
||||
expect(ctx.runtimeBindings.get(profile.id)).toBe(firstBinding);
|
||||
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(false);
|
||||
expect(fake.service.getActive()?.profileId).toBe(profile.id);
|
||||
expect(fake.disconnect).toHaveBeenCalledWith(secondProfile.id);
|
||||
expect(fake.disconnect).not.toHaveBeenCalledWith(profile.id);
|
||||
expect(firstRuntime.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(firstRuntime.manager.dispose).not.toHaveBeenCalled();
|
||||
expect(failedRuntime.unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(failedRuntime.manager.dispose).toHaveBeenCalledWith(
|
||||
"code_sidecar_remote_initialization_failed",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "list_remote_environments"),
|
||||
).resolves.toMatchObject({
|
||||
activeEnvironmentId: profile.id,
|
||||
activeProfileId: profile.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("retires the previous runtime only after a host switch commits", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService([connection, secondConnection]);
|
||||
const firstRuntime = createManager();
|
||||
const secondRuntime = createManager();
|
||||
coreCreateMock
|
||||
.mockResolvedValueOnce(firstRuntime.manager)
|
||||
.mockResolvedValueOnce(secondRuntime.manager);
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
|
||||
await handleCommand(ctx, "connect_remote_environment", { id: profile.id });
|
||||
await expect(
|
||||
handleCommand(ctx, "connect_remote_environment", {
|
||||
id: secondProfile.id,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
environmentId: secondProfile.id,
|
||||
activeEnvironmentId: secondProfile.id,
|
||||
activeProfileId: secondProfile.id,
|
||||
});
|
||||
|
||||
expect(ctx.activeEnvironmentId).toBe(secondProfile.id);
|
||||
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
|
||||
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true);
|
||||
expect(firstRuntime.unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(firstRuntime.manager.dispose).toHaveBeenCalledWith(
|
||||
"code_sidecar_remote_disconnect",
|
||||
);
|
||||
expect(secondRuntime.manager.dispose).not.toHaveBeenCalled();
|
||||
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
|
||||
expect(fake.service.getActive()?.profileId).toBe(secondProfile.id);
|
||||
});
|
||||
|
||||
it("disconnecting an inactive profile does not switch the active environment", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService([connection, secondConnection]);
|
||||
await fake.service.connect(profile.id);
|
||||
await fake.service.connect(secondProfile.id);
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
ctx.runtimeBindings.set(
|
||||
profile.id,
|
||||
createExistingRemoteBinding(profile.id),
|
||||
);
|
||||
ctx.runtimeBindings.set(
|
||||
secondProfile.id,
|
||||
createExistingRemoteBinding(secondProfile.id),
|
||||
);
|
||||
ctx.activeEnvironmentId = secondProfile.id;
|
||||
const send = attachEventRecorder(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "disconnect_remote_environment", { id: profile.id }),
|
||||
).resolves.toEqual({
|
||||
status: "disconnected",
|
||||
disconnectedProfileId: profile.id,
|
||||
activeEnvironmentId: secondProfile.id,
|
||||
activeProfileId: secondProfile.id,
|
||||
});
|
||||
|
||||
expect(ctx.activeEnvironmentId).toBe(secondProfile.id);
|
||||
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true);
|
||||
expect(fake.service.getActive()?.profileId).toBe(secondProfile.id);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a profile only after removing its runtime binding", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService();
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
const send = attachEventRecorder(ctx);
|
||||
const binding = createExistingRemoteBinding(profile.id);
|
||||
ctx.runtimeBindings.set(profile.id, binding);
|
||||
ctx.activeEnvironmentId = profile.id;
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "delete_remote_environment", { id: profile.id }),
|
||||
).resolves.toEqual({
|
||||
deleted: true,
|
||||
activeEnvironmentId: "local",
|
||||
activeProfileId: null,
|
||||
});
|
||||
|
||||
expect(binding.unsubscribeSessionEvents).toHaveBeenCalledOnce();
|
||||
expect(binding.sessionManager.dispose).toHaveBeenCalledWith(
|
||||
"code_sidecar_remote_disconnect",
|
||||
);
|
||||
expect(binding.hubClient.dispose).toHaveBeenCalledOnce();
|
||||
expect(fake.delete).toHaveBeenCalledWith(profile.id);
|
||||
expect(
|
||||
send.mock.calls.map((call) => JSON.parse(String(call[0])).event.name),
|
||||
).toContain("remote_environment_profiles_changed");
|
||||
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
|
||||
expect(ctx.activeEnvironmentId).toBe("local");
|
||||
expect(readEvent(send, 0)).toEqual({
|
||||
type: "event",
|
||||
event: {
|
||||
name: "remote_environment_changed",
|
||||
payload: {
|
||||
status: "disconnected",
|
||||
activeProfileId: null,
|
||||
activeEnvironmentId: "local",
|
||||
environmentId: "local",
|
||||
workspaceRoot: "/local/project",
|
||||
reason: "profile_deleted",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes remote workspace browsing and operations to the explicitly selected directory", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const fake = createFakeService();
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
ctx.runtimeBindings.set(
|
||||
profile.id,
|
||||
createExistingRemoteBinding(profile.id),
|
||||
);
|
||||
expect(ctx.activeEnvironmentId).toBe("local");
|
||||
|
||||
fake.run.mockImplementation(async (_id, input) => {
|
||||
if (input.command === "pwd") {
|
||||
return { stdout: "/srv/code\n", stderr: "", exitCode: 0 };
|
||||
}
|
||||
if (input.command === "sh" && !input.args[1]?.includes("ls-files")) {
|
||||
return {
|
||||
stdout: "/srv/code/zeta\0/srv/code/project\0",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (input.command === "sh" && input.args[1]?.includes("ls-files")) {
|
||||
return {
|
||||
stdout: "src/remote.ts\nREADME.md\n",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (input.command === "git" && input.args[0] === "branch") {
|
||||
return { stdout: "feature/ssh\n", stderr: "", exitCode: 0 };
|
||||
}
|
||||
return { stdout: "main\nfeature/ssh\n", stderr: "", exitCode: 0 };
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "list_workspace_directories", {
|
||||
environmentId: profile.id,
|
||||
path: "/srv/code",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
environmentId: profile.id,
|
||||
currentPath: "/srv/code",
|
||||
parentPath: "/srv",
|
||||
entries: [
|
||||
{ name: "project", path: "/srv/code/project" },
|
||||
{ name: "zeta", path: "/srv/code/zeta" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
expect(fake.run).toHaveBeenCalledWith(profile.id, {
|
||||
command: "pwd",
|
||||
args: ["-P"],
|
||||
cwd: "/srv/code",
|
||||
});
|
||||
const listInvocation = fake.run.mock.calls.find(
|
||||
([, input]) => input.command === "sh",
|
||||
)?.[1];
|
||||
expect(listInvocation).toMatchObject({
|
||||
command: "sh",
|
||||
args: [
|
||||
"-c",
|
||||
expect.stringContaining("find -L"),
|
||||
"cline-list-workspace-directories",
|
||||
"/srv/code",
|
||||
],
|
||||
});
|
||||
expect(String(listInvocation?.args[1])).not.toContain("/srv/code");
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "validate_workspace_directory", {
|
||||
environmentId: profile.id,
|
||||
path: "/srv/code/project",
|
||||
}),
|
||||
).resolves.toEqual({ environmentId: profile.id, valid: true });
|
||||
expect(fake.run).toHaveBeenCalledWith(profile.id, {
|
||||
command: "test",
|
||||
args: ["-d", "/srv/code/project"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "search_workspace_files", {
|
||||
environmentId: profile.id,
|
||||
workspaceRoot: "/srv/code/project",
|
||||
query: "remote",
|
||||
}),
|
||||
).resolves.toEqual(["src/remote.ts"]);
|
||||
expect(fake.run).toHaveBeenCalledWith(profile.id, {
|
||||
command: "sh",
|
||||
args: ["-c", expect.stringContaining("head -c 262144")],
|
||||
cwd: "/srv/code/project",
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "get_git_branch", {
|
||||
environmentId: profile.id,
|
||||
cwd: "/srv/code/project",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
environmentId: profile.id,
|
||||
branch: "feature/ssh",
|
||||
});
|
||||
expect(fake.run).toHaveBeenCalledWith(profile.id, {
|
||||
command: "git",
|
||||
args: ["branch", "--show-current"],
|
||||
cwd: "/srv/code/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds remote search output before transfer and drops a truncated filename", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const root = mkdtempSync(join(tmpdir(), "remote-search-limit-"));
|
||||
try {
|
||||
for (let i = 0; i < 2800; i++)
|
||||
writeFileSync(join(root, `${i}-${"x".repeat(100)}.ts`), "");
|
||||
const ctx = createSidecarContext(root);
|
||||
const fake = createFakeService();
|
||||
ctx.remoteEnvironments = fake.service;
|
||||
ctx.runtimeBindings.set(
|
||||
profile.id,
|
||||
createExistingRemoteBinding(profile.id),
|
||||
);
|
||||
let transferredBytes = 0;
|
||||
fake.run.mockImplementation(async (_id, input) => {
|
||||
const stdout = execFileSync(input.command, input.args, {
|
||||
cwd: input.cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
transferredBytes = Buffer.byteLength(stdout);
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
});
|
||||
const result = (await handleCommand(ctx, "search_workspace_files", {
|
||||
environmentId: profile.id,
|
||||
workspaceRoot: root,
|
||||
limit: 200,
|
||||
})) as string[];
|
||||
expect(transferredBytes).toBe(262144);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.length).toBeLessThanOrEqual(200);
|
||||
expect(result.every((path) => path.endsWith(".ts"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lists and bounds local workspace directories through the local binding", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-workspaces-"));
|
||||
try {
|
||||
for (let index = 0; index < 201; index += 1) {
|
||||
mkdirSync(
|
||||
join(temporaryRoot, `project-${String(index).padStart(3, "0")}`),
|
||||
);
|
||||
}
|
||||
writeFileSync(join(temporaryRoot, "not-a-directory.txt"), "ignored");
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
ctx.runtimeBindings.set("local", {
|
||||
...createExistingRemoteBinding("local"),
|
||||
kind: "local",
|
||||
workspaceRoot: "/local/project",
|
||||
});
|
||||
|
||||
const currentPath = realpathSync(temporaryRoot);
|
||||
await expect(
|
||||
handleCommand(ctx, "list_workspace_directories", {
|
||||
environmentId: "local",
|
||||
path: temporaryRoot,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
environmentId: "local",
|
||||
currentPath,
|
||||
parentPath: realpathSync(tmpdir()),
|
||||
entries: expect.arrayContaining([
|
||||
{
|
||||
name: "project-000",
|
||||
path: join(currentPath, "project-000"),
|
||||
},
|
||||
]),
|
||||
truncated: true,
|
||||
});
|
||||
const result = (await handleCommand(ctx, "list_workspace_directories", {
|
||||
environmentId: "local",
|
||||
path: temporaryRoot,
|
||||
})) as { entries: unknown[] };
|
||||
expect(result.entries).toHaveLength(200);
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lists duplicate session IDs separately and rejects ambiguous routing", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const {
|
||||
createSidecarContext,
|
||||
getEnvironmentContext,
|
||||
findSessionRuntimeBinding,
|
||||
emitChunk,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
for (const environmentId of [profile.id, secondProfile.id]) {
|
||||
const record = {
|
||||
id: "same-id",
|
||||
sessionId: "same-id",
|
||||
status: "idle",
|
||||
createdAt: "2026-09-14T00:00:00Z",
|
||||
};
|
||||
ctx.runtimeBindings.set(environmentId, {
|
||||
...createExistingRemoteBinding(environmentId),
|
||||
sessionManager: {
|
||||
list: vi.fn(async () => [record]),
|
||||
get: vi.fn(async () => record),
|
||||
} as unknown as SessionRuntimeBinding["sessionManager"],
|
||||
});
|
||||
}
|
||||
const sessions = (await handleCommand(
|
||||
ctx,
|
||||
"list_discovered_sessions",
|
||||
{},
|
||||
)) as Array<{ sessionId: string; environmentId: string }>;
|
||||
expect(
|
||||
sessions
|
||||
.filter((session) => session.sessionId === "same-id")
|
||||
.map((session) => session.environmentId)
|
||||
.sort(),
|
||||
).toEqual([profile.id, secondProfile.id]);
|
||||
await expect(findSessionRuntimeBinding(ctx, "same-id")).rejects.toThrow(
|
||||
"environmentId is required",
|
||||
);
|
||||
await expect(
|
||||
findSessionRuntimeBinding(ctx, "same-id", secondProfile.id),
|
||||
).resolves.toMatchObject({ environmentId: secondProfile.id });
|
||||
const first = getEnvironmentContext(ctx, profile.id);
|
||||
const second = getEnvironmentContext(ctx, secondProfile.id);
|
||||
const send = attachEventRecorder(ctx);
|
||||
emitChunk(first, "same-id", "chat_text", "first host");
|
||||
emitChunk(second, "same-id", "chat_text", "second host");
|
||||
expect(readEvent(send, 0).event.payload).toMatchObject({
|
||||
sessionId: "same-id",
|
||||
environmentId: profile.id,
|
||||
index: 1,
|
||||
chunk: "first host",
|
||||
});
|
||||
expect(readEvent(send, 1).event.payload).toMatchObject({
|
||||
sessionId: "same-id",
|
||||
environmentId: secondProfile.id,
|
||||
index: 1,
|
||||
chunk: "second host",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes session reads, title updates, and deletes to the requested environment", async () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/local/project");
|
||||
const readMessages = vi.fn(async () => [
|
||||
{ role: "user", content: "remote session message" },
|
||||
]);
|
||||
const update = vi.fn(async () => ({ updated: true }));
|
||||
const deleteSession = vi.fn(async () => true);
|
||||
const sessionManager = {
|
||||
readMessages,
|
||||
update,
|
||||
delete: deleteSession,
|
||||
dispose: vi.fn(async () => undefined),
|
||||
} as unknown as SessionRuntimeBinding["sessionManager"];
|
||||
ctx.runtimeBindings.set(profile.id, {
|
||||
...createExistingRemoteBinding(profile.id),
|
||||
sessionManager,
|
||||
});
|
||||
expect(ctx.activeEnvironmentId).toBe("local");
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "read_session_messages", {
|
||||
environmentId: profile.id,
|
||||
sessionId: "remote-session",
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
expect(readMessages).toHaveBeenCalledWith("remote-session");
|
||||
|
||||
ctx.liveSessions.set("same-id", {
|
||||
environmentId: "local",
|
||||
config: {},
|
||||
messages: [{ role: "user", content: "local-only message" }],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
});
|
||||
readMessages.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
handleCommand(ctx, "read_session_messages", {
|
||||
environmentId: profile.id,
|
||||
sessionId: "same-id",
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "update_chat_session_title", {
|
||||
environmentId: profile.id,
|
||||
sessionId: "remote-session",
|
||||
title: "Remote title",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(update).toHaveBeenCalledWith("remote-session", {
|
||||
title: "Remote title",
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "delete_chat_session", {
|
||||
environmentId: profile.id,
|
||||
sessionId: "remote-session",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(deleteSession).toHaveBeenCalledWith("remote-session");
|
||||
expect(sessionStoreDeleteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||