mirror of
https://github.com/cline/cline.git
synced 2026-09-16 21:01:52 +08:00
Compare commits
187
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5dbb4148 | ||
|
|
3f5715ceb6 | ||
|
|
0c8226c9ba | ||
|
|
1bb0833287 | ||
|
|
686bce7451 | ||
|
|
6362b425d1 | ||
|
|
ad90909ccf | ||
|
|
d86283e09b | ||
|
|
1ff3784b39 | ||
|
|
d279a99436 | ||
|
|
2c64c4ce5b | ||
|
|
f3679d52f7 | ||
|
|
b8dc65b4fe | ||
|
|
0b36eb53d0 | ||
|
|
0bdf2e3468 | ||
|
|
98024c4243 | ||
|
|
359e771ab3 | ||
|
|
a21b21de84 | ||
|
|
ca6f6a6c23 | ||
|
|
3e06abc366 | ||
|
|
6518b05b6c | ||
|
|
f9f492319e | ||
|
|
c3ab557194 | ||
|
|
9fa37a9128 | ||
|
|
9cc7796bbe | ||
|
|
439baee17c | ||
|
|
469debdb30 | ||
|
|
79589c8417 | ||
|
|
8e5471de9c | ||
|
|
cd553d2343 | ||
|
|
7b776225b9 | ||
|
|
ae033761ab | ||
|
|
c961ae7730 | ||
|
|
e940b6a335 | ||
|
|
5a0780a6f9 | ||
|
|
847276f4a5 | ||
|
|
8b4d1973b5 | ||
|
|
a5211a3a5c | ||
|
|
fb1333fdc2 | ||
|
|
59113c309c | ||
|
|
48d0c38f52 | ||
|
|
c7ab9ff839 | ||
|
|
045518d19f | ||
|
|
099c6179e4 | ||
|
|
26037b17ac | ||
|
|
1adce5e56d | ||
|
|
73583ea178 | ||
|
|
21a0141b86 | ||
|
|
ecb71ba7cc | ||
|
|
f5224abdf5 | ||
|
|
85484abf7a | ||
|
|
0b0e2fbab8 | ||
|
|
1e2e8fe81b | ||
|
|
78c6724c6a | ||
|
|
f2a895cf86 | ||
|
|
1585999251 | ||
|
|
2c556a4c94 | ||
|
|
9a80fa04c2 | ||
|
|
22a1fa2c84 | ||
|
|
57d364ffc2 | ||
|
|
0912f34286 | ||
|
|
bdb216c110 | ||
|
|
c92d4e7553 | ||
|
|
b919a7e86c | ||
|
|
eefbe9fb18 | ||
|
|
402b9994d8 | ||
|
|
fce1b97512 | ||
|
|
e7b0cec8e1 | ||
|
|
353ddc10f4 | ||
|
|
cabeb61036 | ||
|
|
cc29955c2d | ||
|
|
c2faf38d72 | ||
|
|
4dab17769c | ||
|
|
396032cd3b | ||
|
|
f33ab3a872 | ||
|
|
2ca8364ffc | ||
|
|
2ef81be703 | ||
|
|
359445ae0c | ||
|
|
d9e2e9c76b | ||
|
|
d859a86a6f | ||
|
|
0b7b9c1b3d | ||
|
|
557d725690 | ||
|
|
7274d8badc | ||
|
|
d1837366c0 | ||
|
|
c380daf4a3 | ||
|
|
c564045d81 | ||
|
|
9a5e1751b2 | ||
|
|
131e25e1a1 | ||
|
|
a7ff007af9 | ||
|
|
ef27f45080 | ||
|
|
e3c6d51072 | ||
|
|
48bac25548 | ||
|
|
37f5f104f3 | ||
|
|
3577b52404 | ||
|
|
1843bc8ed0 | ||
|
|
fead00ec57 | ||
|
|
238107d21c | ||
|
|
2063a661bd | ||
|
|
ec02d5862e | ||
|
|
8452084842 | ||
|
|
a41129a5db | ||
|
|
1ea34be611 | ||
|
|
e72bc3cd14 | ||
|
|
9c907af826 | ||
|
|
e8d3d82522 | ||
|
|
7f9d2e96d9 | ||
|
|
d618f8073a | ||
|
|
eb21ba583c | ||
|
|
f29c25395c | ||
|
|
84c9b587a6 | ||
|
|
6dca234d8e | ||
|
|
9217eacbbd | ||
|
|
adbb42a99c | ||
|
|
50d1578a7e | ||
|
|
5ef3b81369 | ||
|
|
ec3a57771d | ||
|
|
a695dab23a | ||
|
|
77af52661c | ||
|
|
0f4acccd08 | ||
|
|
f8c73cd8cc | ||
|
|
55a31a0d8a | ||
|
|
04438c0d54 | ||
|
|
f053ec48e4 | ||
|
|
0df406723c | ||
|
|
12703bf407 | ||
|
|
4a97b46f5f | ||
|
|
36fc3327ac | ||
|
|
2b48dc411f | ||
|
|
da6fe718d0 | ||
|
|
3515333e23 | ||
|
|
bd9ac5872b | ||
|
|
fb15324ad2 | ||
|
|
7a27c04ffa | ||
|
|
c37b252f65 | ||
|
|
2872138900 | ||
|
|
dc4620c529 | ||
|
|
2ac5c85e69 | ||
|
|
ab68fd7f34 | ||
|
|
b4ed8a226e | ||
|
|
cbf40961db | ||
|
|
2d05ba52da | ||
|
|
5d3778b5cf | ||
|
|
8d0eb54a1f | ||
|
|
a3989acc38 | ||
|
|
d199b1bff9 | ||
|
|
d41eed1198 | ||
|
|
6309971089 | ||
|
|
2d2c669421 | ||
|
|
c5f146a418 | ||
|
|
261ee4c313 | ||
|
|
d45b051c04 | ||
|
|
78c83cdf33 | ||
|
|
3266121fa1 | ||
|
|
6467de65a2 | ||
|
|
65fe885638 | ||
|
|
c3033d6f13 | ||
|
|
cfb1327a1b | ||
|
|
264af96e1b | ||
|
|
10cb9bd97a | ||
|
|
2ee18e7f0c | ||
|
|
0b65506a2b | ||
|
|
3502608081 | ||
|
|
ed3107f9ec | ||
|
|
6bce48aad4 | ||
|
|
1e1b6af51c | ||
|
|
ee49900232 | ||
|
|
a1d5589d19 | ||
|
|
721fda2e99 | ||
|
|
5e78861eb5 | ||
|
|
29798f59f3 | ||
|
|
177d0eb07f | ||
|
|
869a87a220 | ||
|
|
0cfd0bbe05 | ||
|
|
08f656532f | ||
|
|
10dece6677 | ||
|
|
885a2936b6 | ||
|
|
90c427740d | ||
|
|
e6028168f2 | ||
|
|
c3f75b3ff0 | ||
|
|
88ce3e0b11 | ||
|
|
dd719dce86 | ||
|
|
d5db7eb853 | ||
|
|
11d5ebe8bc | ||
|
|
6f7cc4907f | ||
|
|
e89c6a422b | ||
|
|
dbb6c4ac6b | ||
|
|
9838666a8a |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'desktop-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/examples/desktop-app/package.json').version"
|
||||
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
|
||||
```
|
||||
|
||||
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
```
|
||||
|
||||
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
- `apps/examples/desktop-app/package.json` → new version
|
||||
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
|
||||
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
```sh
|
||||
bun -F @cline/code typecheck
|
||||
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
|
||||
```
|
||||
|
||||
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
```sh
|
||||
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
|
||||
git commit -m "chore(desktop): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit, then before creating and pushing the tag:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
|
||||
git push origin refs/tags/desktop-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
The release commit must be on `main` and the tag pushed first.
|
||||
|
||||
```sh
|
||||
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
```sh
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
|
||||
## Repo secrets (one-time setup)
|
||||
|
||||
The workflow needs these repository secrets. The Apple ones come from the same
|
||||
Apple Developer account used for manual signing (see the app README's "macOS
|
||||
signing & notarization" section for how to obtain them):
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
|
||||
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
|
||||
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
|
||||
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
|
||||
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
|
||||
|
||||
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
|
||||
OTEL settings) are shared with the CLI publish workflow and already configured.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
|
||||
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
name: desktop-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_tag:
|
||||
description: "Existing release tag to publish, for example desktop-v0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm the desktop release.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate release tag
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
run: |
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#desktop-v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
|
||||
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TAURI_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
|
||||
echo "${TAG} does not point at the checked out commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin +main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
|
||||
echo "${TAG} is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (${{ matrix.arch }})
|
||||
needs: validate
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
arch: aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
arch: x86_64
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/examples/desktop-app/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
env:
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
run: |
|
||||
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
|
||||
echo "APPLE_API_KEY_CONTENT secret is not configured"
|
||||
exit 1
|
||||
fi
|
||||
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Build, sign, and notarize desktop bundle
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
# Developer ID signing (Tauri imports the cert into a temp keychain)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
# Notarization via App Store Connect API key. Tauri reads the Key ID
|
||||
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
# Updater artifact signing (minisign keypair, independent of Apple)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Collect artifacts
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
|
||||
|
||||
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
|
||||
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.arch }}
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
|
||||
--version "$VERSION" \
|
||||
--tag "$TAG" \
|
||||
--dir dist/desktop \
|
||||
--out dist/desktop/latest.json \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file "$RUNNER_TEMP/release-notes.md"
|
||||
cat dist/desktop/latest.json
|
||||
|
||||
- name: Get Previous Desktop Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: "Desktop v${{ needs.validate.outputs.version }}"
|
||||
# The repo-wide "latest" release stays owned by CLI releases; the
|
||||
# desktop auto-update feed is the rolling desktop-latest release.
|
||||
make_latest: "false"
|
||||
files: dist/desktop/*
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update auto-update feed (desktop-latest)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! gh release view desktop-latest >/dev/null 2>&1; then
|
||||
gh release create desktop-latest \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
fi
|
||||
gh release upload desktop-latest dist/desktop/latest.json --clobber
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
|
||||
@@ -0,0 +1,205 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
@@ -1,14 +1,40 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch. The nightly publish script
|
||||
# generates the extension version from a seconds-resolution timestamp, so parallel
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -17,7 +43,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
if: github.repository == 'cline/cline'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -27,60 +53,79 @@ jobs:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
run: |
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
@@ -90,20 +135,24 @@ jobs:
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
@@ -111,12 +160,129 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -124,10 +290,11 @@ jobs:
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
VSCODE_TEST_VERSION: 1.101.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -260,6 +260,41 @@ jobs:
|
||||
git push origin "refs/tags/${TAG}"
|
||||
done
|
||||
|
||||
- name: Get Previous SDK Tag
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: prev_tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# The checkout is shallow and tagless, so fetch the release tags explicitly.
|
||||
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
|
||||
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
|
||||
DELIMITER=$(openssl rand -hex 8)
|
||||
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
|
||||
name: "SDK v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -280,3 +315,26 @@ jobs:
|
||||
echo " - sdk/core/v${VERSION}"
|
||||
echo " - sdk/sdk/v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Post release to Slack
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -85,3 +85,10 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
|
||||
@@ -39,14 +39,6 @@
|
||||
"sdk/packages/core/src/auth/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-telemetry-doc-update",
|
||||
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
|
||||
"scope": [
|
||||
"sdk/packages/core/src/services/telemetry/core-events.ts"
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,13 +16,9 @@
|
||||
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
|
||||
},
|
||||
{
|
||||
"path": "DOC.md",
|
||||
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
|
||||
},
|
||||
{
|
||||
"path": "sdk/ARCHITECTURE.md",
|
||||
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "sdk/AGENTS.md",
|
||||
|
||||
+22
-17
@@ -36,8 +36,13 @@ event names. It exports:
|
||||
|
||||
1. Add the constant to `CORE_TELEMETRY_EVENTS`
|
||||
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
|
||||
3. Update the Event Catalog section in `DOC.md`
|
||||
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
|
||||
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
|
||||
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
|
||||
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
|
||||
**All events should be named using snake_case and so should their properties**
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
@@ -82,7 +87,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
|
||||
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
|
||||
config dir.
|
||||
|
||||
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
|
||||
The canonical pattern is in `apps/cli/src/main.ts`:
|
||||
|
||||
```ts
|
||||
if (configDir) setClineDir(configDir);
|
||||
@@ -90,18 +95,18 @@ setHomeDir(homedir());
|
||||
captureCliExtensionActivated(); // <-- after dir overrides
|
||||
```
|
||||
|
||||
## Hub Daemon Metadata Forwarding
|
||||
## Hub Daemon Telemetry
|
||||
|
||||
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
|
||||
metadata into the daemon argv so the daemon can reconstruct an equivalent
|
||||
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
|
||||
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
|
||||
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
|
||||
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
|
||||
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
|
||||
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.
|
||||
|
||||
```
|
||||
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
|
||||
```
|
||||
|
||||
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
|
||||
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
|
||||
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).
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
@@ -120,10 +125,10 @@ canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
|
||||
On VS Code, the telemetry handle is built **once** in `activate()`
|
||||
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
|
||||
command, and daemon spawn payload. Do not let individual controllers construct their own
|
||||
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
On VS Code, all callers go through the lazy `telemetryService` proxy in
|
||||
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
|
||||
use. Do not let individual controllers construct their own `ITelemetryService` — that
|
||||
fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
|
||||
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
|
||||
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
|
||||
|
||||
Vendored
+7
-7
@@ -68,7 +68,7 @@
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -89,7 +89,7 @@
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"isBackground": false,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -114,16 +114,16 @@
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
"message": 1
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
This is the **Cline** monorepo. Toolchain is **Bun 1.3.13** (package manager + task runner) with **Node >=22** as the runtime. Do not use npm/yarn/pnpm.
|
||||
|
||||
## Cloud Agent Instructions
|
||||
|
||||
### Cline CLI
|
||||
- Run from source: `bun run cli` (interactive: `bun run cli -i`; one-shot: append a prompt). This resolves to `apps/cli` and **auto-spawns the `@cline/cline-hub` daemon** — you do not start the hub separately.
|
||||
- Inspect local health with `bun run cli doctor`; `bun run cli version` prints the version.
|
||||
- An actual agent turn requires an **LLM provider credential**. With no credentials the default `cline` provider fails fast with an `Unauthorized` error and the interactive TUI shows a provider sign-in screen. Configure via `cline auth` or provider env vars (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`, `OPENROUTER_API_KEY`); see `apps/cli/README.md`.
|
||||
|
||||
### Build / Lint / test
|
||||
- SDK packages (`@cline/shared|llms|agents|core|sdk`) resolve each other through compiled `dist/` (their `exports` point only at `dist/`, with no `development` source condition). You **must** run `bun run build:sdk` after changing SDK dependencies/source before running the CLI or SDK tests, otherwise imports fail with missing `@cline/*` / missing `dist/` errors. Running processes do **not** hot-reload SDK source changes — rebuild and restart.\
|
||||
- Known cloud-env test artifact: `@cline/core` test `src/services/workspace/workspace-manifest.test.ts > readGitWorkspaceState > prefers origin and returns the current branch` fails because cloud VMs configure git `insteadOf` rules that rewrite GitHub remotes to `https://x-access-token:...@github.com/...`. This is an environment artifact, not a code bug.
|
||||
- Some `@cline/cli` e2e assertions (`bun -F @cline/cli test:e2e`) may fail on exact tool-listing string formats; treat as pre-existing test drift, not an environment problem.
|
||||
|
||||
### GUI display
|
||||
- A virtual X display is live at **`DISPLAY=:1`** (the same desktop used for screenshots). GUI apps (VS Code, the Tauri desktop window) launched with `DISPLAY=:1` render there and can be screenshotted — no need to start your own `xvfb`. Prefer starting long-running GUI/dev processes in a `tmux` session (see the tmux guidance) so they survive.
|
||||
|
||||
### VS Code extension (`apps/vscode`, package `claude-dev`)
|
||||
Toolchain is pre-installed and persisted in the VM: generated gRPC/proto code, the bundled `ripgrep` binaries (`apps/vscode/bin/`), the built webview (`webview-ui/build`), the esbuild bundle (`dist/extension.js`), VS Code itself (`/usr/bin/code`), and the GUI system libraries its tests need.
|
||||
- **Codegen prerequisite:** `bun run protos` (from `apps/vscode`) regenerates `src/generated/*` and the webview grpc client. The `dev`, `build:webview`, and `check-types` scripts already run it, so proto changes are picked up by those commands; run it manually only if you edit `.proto` files without a full build.
|
||||
- **Build:** `bun run build:webview` (webview UI, ~15s) then `bun esbuild.mjs` (extension bundle). `bun run package` does the full production build.
|
||||
- **Run it (dev host):** `DISPLAY=:1 code --no-sandbox --user-data-dir=/tmp/vscode-userdata --extensionDevelopmentPath=/workspace/apps/vscode <some-folder>`, then click the Cline icon in the Activity Bar to open the webview. (`--no-sandbox` is required in this container.)
|
||||
- **Test:** `bun run test:unit` (bun-based, ~984 tests, no VS Code host needed). `bun run test:integration` (`@vscode/test-electron`, downloads a VS Code build, runs under the GUI libs) and `bun run test:e2e` (Playwright) exercise a real extension host — heavier, and the GUI libs for them are already installed.
|
||||
- One-time deps (already installed, listed here in case they must be recreated): ripgrep via `bun run download-ripgrep`; VS Code test GUI libs per `CONTRIBUTING.md` (`libnss3`, `libatk*`, `libgbm1`, `xvfb`, etc.).
|
||||
|
||||
### Desktop app (`apps/examples/desktop-app`, package `@cline/code`)
|
||||
A Tauri v2 (Rust) shell + Next.js webview + a Bun "sidecar" backend. Rust and the Tauri Linux system libs are pre-installed and persisted.
|
||||
- **Headless (no Rust/window):** run the backend and UI separately — `bun run dev:sidecar` (Bun backend on `127.0.0.1:3126`, serves `ws://.../transport`) and `bun run dev:web` (Next.js UI on `http://localhost:3125`).
|
||||
- **Native window:** `bun run dev` (`tauri dev`) — its `beforeDevCommand` builds the sidecar binary and starts `dev:web` (`:3125`), then Rust `main.rs` spawns the sidecar; so free ports `3125`/`3126` first. Launch with `DISPLAY=:1` to see the window. A `libEGL: DRI3 error` warning is benign (software rendering) — the WebKitGTK window still renders.
|
||||
- **Rust version caveat:** the crate graph needs Cargo's `edition2024` feature, so **Rust ≥1.85** is required (the VM's base 1.83 fails with "feature `edition2024` is required"). The toolchain here was updated via `rustup default stable` (currently 1.97). First `cargo` build downloads/compiles the full Tauri crate graph (a few minutes); subsequent builds are cached.
|
||||
- **System libs (already installed):** `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `build-essential`.
|
||||
- **Test/typecheck:** `bun run typecheck`, `bun run test:chat-ui` (Vitest). Both trigger `build:ui` first.
|
||||
@@ -1,5 +1,72 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
## 3.0.45
|
||||
|
||||
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
|
||||
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
|
||||
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
|
||||
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
|
||||
- Hub status output now includes version numbers
|
||||
- Updated the bundled model catalog (from SDK v0.0.65)
|
||||
|
||||
## 3.0.44
|
||||
|
||||
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
|
||||
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
|
||||
|
||||
## 3.0.43
|
||||
|
||||
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
|
||||
|
||||
## 3.0.42
|
||||
|
||||
- Fixed Ollama native API routing so context window and timeout settings work again
|
||||
|
||||
## 3.0.41
|
||||
|
||||
- Compaction now shows progress status in the TUI
|
||||
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
|
||||
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
|
||||
- Compaction no longer runs during an active turn
|
||||
- Fixed a crash when the terminal title was updated during TUI teardown
|
||||
- The API key fallback hint is now highlighted for better visibility
|
||||
- Benign git states are no longer reported as workspace initialization errors
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
- Fixed provider config not reloading when switching models
|
||||
- Fixed auto-update failing to detect Bun global installs after symlink resolution
|
||||
- Fixed unexpected logouts caused by transient network or server errors during token refresh
|
||||
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
|
||||
- Session id is now preserved when continuing within the same session
|
||||
- Hardened context compaction budget handling
|
||||
|
||||
## 3.0.39
|
||||
|
||||
- You can now select Cline free models on the ClinePass provider in the model picker
|
||||
- Removed the retired ClinePass GLM 5.1 model
|
||||
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
|
||||
- `str_replace` edits now report accurate diffs
|
||||
- Fixed context compaction so canonical session history is preserved
|
||||
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
|
||||
- Cline provider requests now send versioned client-identity headers
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
|
||||
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
|
||||
- Polished the status bar usage display and ClinePass model name
|
||||
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
|
||||
- The thinking-level picker now defaults its cursor to Medium instead of Off
|
||||
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
|
||||
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
|
||||
|
||||
## 3.0.37
|
||||
|
||||
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
|
||||
|
||||
+16
-1
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
|
||||
| `--acp` | ACP (Agent Client Protocol) mode |
|
||||
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
|
||||
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
|
||||
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
|
||||
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
|
||||
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
|
||||
|
||||
`--key` takes precedence over environment variables.
|
||||
|
||||
## Certificate trust
|
||||
|
||||
The CLI automatically trusts your operating system's certificate store, so it
|
||||
works behind corporate TLS-inspecting proxies and with self-signed/internal
|
||||
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
|
||||
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
|
||||
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
|
||||
it changes and is safe to delete (it is rebuilt on the next run).
|
||||
|
||||
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
|
||||
that bundle alongside the system store rather than replacing it. Run with
|
||||
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
|
||||
was written.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// Auto-discovery of OS trust anchors for the Cline CLI.
|
||||
//
|
||||
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
|
||||
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
|
||||
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
|
||||
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
|
||||
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
|
||||
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
|
||||
//
|
||||
// Dependency-free CommonJS with injectable modules so it is unit-testable and
|
||||
// ships verbatim in the published wrapper package.
|
||||
|
||||
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
|
||||
const CERT_BLOCK =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
/**
|
||||
* Returns only the complete certificate blocks from PEM text, or null when
|
||||
* there are none. User files may also hold private keys (combined cert+key
|
||||
* PEMs) or other sections, which must never be copied into the managed
|
||||
* bundle. Files that contain nothing but certificates pass through verbatim
|
||||
* so unchanged bundles keep hash-skipping the rewrite.
|
||||
*/
|
||||
function sanitizePem(text) {
|
||||
const blocks = text.match(CERT_BLOCK) ?? [];
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = text.replace(CERT_BLOCK, "");
|
||||
if (/^\s*$/.test(rest)) {
|
||||
return text;
|
||||
}
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
|
||||
* tls.getCACertificates("system") requires Node >= 22.
|
||||
*/
|
||||
function harvestSystemCerts(tlsModule) {
|
||||
try {
|
||||
const tls = tlsModule || require("node:tls");
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return [];
|
||||
}
|
||||
const certs = tls.getCACertificates("system");
|
||||
if (!Array.isArray(certs)) {
|
||||
return [];
|
||||
}
|
||||
return certs.filter(
|
||||
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file's certificate blocks as PEM text, or null when missing,
|
||||
* unreadable, or holding no complete certificate block.
|
||||
*/
|
||||
function readUserBundle(fsModule, userPath) {
|
||||
if (!userPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = fsModule || require("node:fs");
|
||||
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
// Binary DER would not have loaded in the runtime either; require PEM.
|
||||
return sanitizePem(fs.readFileSync(userPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
|
||||
* value as a single file, but some users set an OS-path-delimited list; the
|
||||
* whole value is tried as one file first, then split.
|
||||
* The managed bundle is excluded so reading it back never re-appends its certs.
|
||||
*/
|
||||
function readUserCerts(fsModule, pathModule, value, managedPath) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const fs = fsModule || require("node:fs");
|
||||
const path = pathModule || require("node:path");
|
||||
const candidates = [];
|
||||
const whole = readUserBundle(fs, value);
|
||||
if (whole) {
|
||||
candidates.push({ filePath: value, pem: whole });
|
||||
} else if (value.includes(path.delimiter)) {
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const pem = readUserBundle(fs, trimmed);
|
||||
if (pem) {
|
||||
candidates.push({ filePath: trimmed, pem });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pems = [];
|
||||
for (const candidate of candidates) {
|
||||
const isManaged =
|
||||
managedPath &&
|
||||
path.resolve(candidate.filePath) === path.resolve(managedPath);
|
||||
if (!isManaged) {
|
||||
pems.push(candidate.pem);
|
||||
}
|
||||
}
|
||||
return pems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates the user PEMs (if any) and the system certificates into one
|
||||
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
|
||||
* markers cannot fuse into one invalid line.
|
||||
*/
|
||||
function buildBundle({ systemCerts, userPems }) {
|
||||
const parts = [...(userPems ?? []), ...systemCerts];
|
||||
return parts
|
||||
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Counts individual PEM certificates across the given bundle strings. */
|
||||
function countCerts(pems) {
|
||||
let count = 0;
|
||||
for (const pem of pems) {
|
||||
count += pem.split(PEM_MARKER).length - 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readFileIfExists(fs, filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClineDir(env, os, path) {
|
||||
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the api-unavailable warning should print. Stamped per Node version
|
||||
* in the cline dir so the nudge shows once rather than on every command; a
|
||||
* version change (upgrade that still falls short, or downgrade) re-arms it.
|
||||
* When the stamp cannot be read or written, warn — bookkeeping failures must
|
||||
* never suppress a real diagnostic.
|
||||
*/
|
||||
function shouldWarnApiUnavailable(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const version = deps.nodeVersion || process.versions.node;
|
||||
const dir = resolveClineDir(env, os, path);
|
||||
const stamp = path.join(dir, `.ca-api-warned-${version}`);
|
||||
try {
|
||||
if (fs.existsSync(stamp)) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(stamp, "", { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically writes [content] to [target]; returns true on success. */
|
||||
function writeBundle(fs, dir, target, content) {
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Owner read/write: the bundle holds public CA material, not secrets,
|
||||
// but there is no reason to make it world-writable.
|
||||
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
// Windows can reject rename over a file a concurrent child holds open.
|
||||
fs.rmSync(target, { force: true });
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Ignore: best-effort cleanup.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
|
||||
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
|
||||
* in place. Returns an outcome the caller can log; `action` is one of
|
||||
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
|
||||
* "no-system-certs" | "api-unavailable".
|
||||
*/
|
||||
function configureNodeExtraCaCerts(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const tls = deps.tls || require("node:tls");
|
||||
|
||||
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
|
||||
// harvest cannot run at all, which the caller should surface to the user.
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return {
|
||||
action: "api-unavailable",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const systemCerts = harvestSystemCerts(tls);
|
||||
if (systemCerts.length === 0) {
|
||||
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
|
||||
// and let the runtime fall back to its bundled CAs.
|
||||
return {
|
||||
action: "no-system-certs",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const managedDir = resolveClineDir(env, os, path);
|
||||
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
|
||||
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
|
||||
const userPems = readUserCerts(fs, path, userValue, managedPath);
|
||||
const bundle = buildBundle({ systemCerts, userPems });
|
||||
const base = {
|
||||
path: managedPath,
|
||||
systemCertCount: systemCerts.length,
|
||||
userCertCount: countCerts(userPems),
|
||||
};
|
||||
|
||||
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
|
||||
// and the concurrent-rename race in the steady state.
|
||||
if (readFileIfExists(fs, managedPath) === bundle) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "unchanged" };
|
||||
}
|
||||
|
||||
if (writeBundle(fs, managedDir, managedPath, bundle)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "written" };
|
||||
}
|
||||
|
||||
// Write failed: fall back to a previously-written bundle if one exists.
|
||||
if (readFileIfExists(fs, managedPath)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "write-failed-reused" };
|
||||
}
|
||||
return { ...base, path: null, action: "write-failed" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
harvestSystemCerts,
|
||||
sanitizePem,
|
||||
readUserBundle,
|
||||
readUserCerts,
|
||||
buildBundle,
|
||||
countCerts,
|
||||
configureNodeExtraCaCerts,
|
||||
shouldWarnApiUnavailable,
|
||||
};
|
||||
@@ -23,6 +23,48 @@ const childEnv = {
|
||||
CLINE_WRAPPER_PATH: scriptPath,
|
||||
};
|
||||
|
||||
// Auto-discover OS trust anchors and pass them to the Bun child via
|
||||
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
|
||||
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
|
||||
// Node, which can read the full store here.
|
||||
try {
|
||||
const caCerts = require("./ca-certs.cjs");
|
||||
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
|
||||
const debug =
|
||||
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
|
||||
// Not debug-gated: on old Nodes the harvest silently doing nothing is
|
||||
// indistinguishable from a broken corporate proxy. Stamped per Node
|
||||
// version so the nudge shows once, not on every command.
|
||||
if (
|
||||
outcome &&
|
||||
outcome.action === "api-unavailable" &&
|
||||
!childEnv.NODE_EXTRA_CA_CERTS &&
|
||||
caCerts.shouldWarnApiUnavailable(childEnv)
|
||||
) {
|
||||
console.warn(
|
||||
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
|
||||
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
|
||||
);
|
||||
}
|
||||
if (debug && outcome) {
|
||||
if (outcome.action === "no-system-certs") {
|
||||
console.warn(
|
||||
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else if (outcome.action === "write-failed") {
|
||||
console.warn(
|
||||
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: fall back to the runtime's default trust on any failure.
|
||||
}
|
||||
|
||||
function run(target) {
|
||||
const result = childProcess.spawnSync(target, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.37",
|
||||
"version": "3.0.46",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -78,19 +78,19 @@
|
||||
"@cline/cline-hub": "workspace:*",
|
||||
"@gramio/format": "^0.7.0",
|
||||
"@opentui-ui/dialog": "^0.1.2",
|
||||
"@opentui/core": "0.1.102",
|
||||
"@opentui/react": "0.1.102",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/react": "0.4.3",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"fzf": "^0.5.2",
|
||||
"marked": "^15.0.12",
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"opentui-spinner": "^0.0.7",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"yaml": "^2.8.2",
|
||||
"nanoid": "^5.1.7",
|
||||
"zod": "^4.1.11"
|
||||
|
||||
@@ -511,6 +511,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
const cwd = session.cwd || process.cwd();
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
// Resolve credentials: env vars take precedence, then session provider.
|
||||
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
|
||||
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
@@ -519,6 +520,7 @@ export class AcpAgent implements Agent {
|
||||
providerId,
|
||||
mode: session.currentMode,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
|
||||
return {
|
||||
providerId,
|
||||
@@ -537,7 +539,23 @@ export class AcpAgent implements Agent {
|
||||
enableAgentTeams: false,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot: resolveWorkspaceRoot(cwd),
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: {
|
||||
name: "cline-acp",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
workspaceName: cwd,
|
||||
ide: "Terminal Shell",
|
||||
platform: process.platform,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// The helper ships as CommonJS in the published wrapper package, so it is
|
||||
// loaded via require rather than an ESM import.
|
||||
const caCerts = require("../../bin/ca-certs.cjs") as {
|
||||
harvestSystemCerts: (tls?: unknown) => string[];
|
||||
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
||||
readUserCerts: (
|
||||
fs: unknown,
|
||||
path: unknown,
|
||||
value: string | null,
|
||||
managedPath: string | null,
|
||||
) => string[];
|
||||
buildBundle: (input: {
|
||||
systemCerts: string[];
|
||||
userPems?: string[];
|
||||
}) => string;
|
||||
countCerts: (pems: string[]) => number;
|
||||
configureNodeExtraCaCerts: (
|
||||
env: Record<string, string>,
|
||||
deps?: { tls?: unknown; fs?: unknown },
|
||||
) => {
|
||||
action: string;
|
||||
path: string | null;
|
||||
systemCertCount: number;
|
||||
userCertCount: number;
|
||||
};
|
||||
shouldWarnApiUnavailable: (
|
||||
env: Record<string, string>,
|
||||
deps?: { fs?: unknown; nodeVersion?: string },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const certSystem =
|
||||
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
||||
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
||||
|
||||
function fakeTls(certs: unknown) {
|
||||
return { getCACertificates: () => certs };
|
||||
}
|
||||
|
||||
describe("ca-certs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("harvestSystemCerts", () => {
|
||||
it("returns only PEM strings from the system store", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
||||
).toEqual([certSystem]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates is unavailable", () => {
|
||||
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates throws", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts({
|
||||
getCACertificates: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserBundle", () => {
|
||||
it("returns PEM contents for a PEM file", () => {
|
||||
const p = join(dir, "user.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
||||
});
|
||||
|
||||
it("returns null for a non-PEM (DER) file", () => {
|
||||
const p = join(dir, "user.der");
|
||||
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing file and for null path", () => {
|
||||
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
||||
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips non-certificate sections such as private keys", () => {
|
||||
// Combined cert+key files (nginx/haproxy style) are common; the key
|
||||
// must never reach the managed bundle.
|
||||
const p = join(dir, "combined.pem");
|
||||
writeFileSync(
|
||||
p,
|
||||
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
||||
);
|
||||
const out = caCerts.readUserBundle(fs, p);
|
||||
expect(out).toContain("USER");
|
||||
expect(out).not.toContain("PRIVATE KEY");
|
||||
expect(out).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("keeps certificates-only files verbatim", () => {
|
||||
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
||||
const p = join(dir, "clean.pem");
|
||||
writeFileSync(p, `${certUser}\n${certSystem}`);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("returns null for a BEGIN marker without a complete block", () => {
|
||||
const p = join(dir, "truncated.pem");
|
||||
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserCerts", () => {
|
||||
it("reads a single PEM file path", () => {
|
||||
const p = join(dir, "corp.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
||||
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
||||
const a = join(dir, "a.pem");
|
||||
const b = join(dir, "b.pem");
|
||||
writeFileSync(a, certUser);
|
||||
writeFileSync(b, certSystem);
|
||||
expect(
|
||||
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
||||
).toEqual([certUser, certSystem]);
|
||||
});
|
||||
|
||||
it("skips missing segments in a delimited value", () => {
|
||||
const a = join(dir, "a.pem");
|
||||
writeFileSync(a, certUser);
|
||||
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
||||
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("excludes the managed bundle from user certs", () => {
|
||||
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
||||
writeFileSync(managed, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty value", () => {
|
||||
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundle", () => {
|
||||
it("merges user PEMs before system certs", () => {
|
||||
expect(
|
||||
caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
}),
|
||||
).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
||||
// certUser has no trailing newline, so this proves the boundary fix.
|
||||
const merged = caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
});
|
||||
expect(merged).not.toContain(
|
||||
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles no user PEMs", () => {
|
||||
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
||||
certSystem,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureNodeExtraCaCerts", () => {
|
||||
it("writes a managed bundle and points the env var at it", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.action).toBe("written");
|
||||
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
||||
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
||||
const userPath = join(dir, "corp.pem");
|
||||
writeFileSync(userPath, certUser);
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: userPath,
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.userCertCount).toBe(1);
|
||||
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written).toContain("USER");
|
||||
expect(written).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("reports unchanged and skips rewrite on the second run", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("written");
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("does not re-append when the user already points at the managed bundle", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const first = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
const env2: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: first,
|
||||
};
|
||||
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
||||
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("no-ops when no system certs are available", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
||||
expect(out.action).toBe("no-system-certs");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
||||
expect(out.action).toBe("api-unavailable");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports write-failed when the bundle cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
fs: failingFs,
|
||||
});
|
||||
expect(out.action).toBe("write-failed");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a stale bundle when the rewrite fails", () => {
|
||||
// First run writes the bundle normally.
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
|
||||
// Second run: writes fail, but the stale bundle is still readable.
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env2: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
||||
// A different system cert forces a rewrite attempt (not "unchanged").
|
||||
tls: fakeTls([certUser]),
|
||||
fs: failingFs,
|
||||
});
|
||||
|
||||
expect(out.action).toBe("write-failed-reused");
|
||||
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCerts", () => {
|
||||
it("counts individual certificates, not files", () => {
|
||||
// One file holding two certs must report 2, not 1.
|
||||
const twoInOne = `${certUser}\n${certSystem}`;
|
||||
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
||||
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
||||
expect(caCerts.countCerts([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnApiUnavailable", () => {
|
||||
it("warns once per Node version, then stays quiet", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { nodeVersion: "22.1.0" };
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms when the Node version changes", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still warns when the stamp cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
||||
// Bookkeeping failure must never suppress the diagnostic.
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SkillConfig,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawn, coreMocks, buildCliSubcommandCommand } = vi.hoisted(() => ({
|
||||
spawn: vi.fn(() => ({ unref: vi.fn() })),
|
||||
coreMocks: {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV:
|
||||
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
|
||||
clearHubDashboardDiscovery: vi.fn(async () => undefined),
|
||||
ensureDetachedHubServer: vi.fn(async () => ({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "hub-token",
|
||||
})),
|
||||
isHubDashboardPidAlive: vi.fn(
|
||||
(pid: number | undefined) => !!pid && pid > 0,
|
||||
),
|
||||
readHubDashboardDiscovery: vi.fn(),
|
||||
readHubDiscovery: vi.fn(),
|
||||
resolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-shared",
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
resolveHubDashboardDiscoveryPath: vi.fn(() => "/tmp/dashboard.json"),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
resolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-shared",
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
stopManagedHubDashboardProcess: vi.fn(async () => false),
|
||||
writeHubDashboardDiscovery: vi.fn(async () => undefined),
|
||||
},
|
||||
buildCliSubcommandCommand: vi.fn(() => ({
|
||||
launcher: "bun",
|
||||
childArgs: ["cline", "dashboard", "serve"],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn }));
|
||||
vi.mock("@cline/core", () => coreMocks);
|
||||
vi.mock("@cline/shared", () => ({
|
||||
resolveClineBuildEnv: () => "development",
|
||||
}));
|
||||
vi.mock("../utils/internal-launch", () => ({ buildCliSubcommandCommand }));
|
||||
|
||||
const originalEnv = {
|
||||
CLINE_HUB_DASHBOARD_LAUNCHER: process.env.CLINE_HUB_DASHBOARD_LAUNCHER,
|
||||
CLINE_HUB_DASHBOARD_ARGS: process.env.CLINE_HUB_DASHBOARD_ARGS,
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH:
|
||||
process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH,
|
||||
};
|
||||
|
||||
function dashboardRecord(pid: number) {
|
||||
return {
|
||||
pid,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("dashboard command lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
spawn.mockClear();
|
||||
buildCliSubcommandCommand.mockClear();
|
||||
coreMocks.clearHubDashboardDiscovery.mockClear();
|
||||
coreMocks.ensureDetachedHubServer.mockClear();
|
||||
coreMocks.isHubDashboardPidAlive.mockClear();
|
||||
coreMocks.isHubDashboardPidAlive.mockImplementation(
|
||||
(pid: number | undefined) => !!pid && pid > 0,
|
||||
);
|
||||
coreMocks.readHubDashboardDiscovery.mockReset();
|
||||
coreMocks.readHubDiscovery.mockReset();
|
||||
coreMocks.resolveHubDashboardDiscoveryPath.mockReset();
|
||||
coreMocks.resolveHubDashboardDiscoveryPath.mockReturnValue(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
coreMocks.stopManagedHubDashboardProcess.mockReset();
|
||||
coreMocks.stopManagedHubDashboardProcess.mockResolvedValue(false);
|
||||
coreMocks.writeHubDashboardDiscovery.mockClear();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true })),
|
||||
);
|
||||
process.env.CLINE_HUB_DASHBOARD_LAUNCHER = "bun";
|
||||
process.env.CLINE_HUB_DASHBOARD_ARGS = JSON.stringify([
|
||||
"cline",
|
||||
"dashboard",
|
||||
"serve",
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv.CLINE_HUB_DASHBOARD_LAUNCHER === undefined) {
|
||||
delete process.env.CLINE_HUB_DASHBOARD_LAUNCHER;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DASHBOARD_LAUNCHER =
|
||||
originalEnv.CLINE_HUB_DASHBOARD_LAUNCHER;
|
||||
}
|
||||
if (originalEnv.CLINE_HUB_DASHBOARD_ARGS === undefined) {
|
||||
delete process.env.CLINE_HUB_DASHBOARD_ARGS;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DASHBOARD_ARGS =
|
||||
originalEnv.CLINE_HUB_DASHBOARD_ARGS;
|
||||
}
|
||||
if (originalEnv.CLINE_HUB_DASHBOARD_DISCOVERY_PATH === undefined) {
|
||||
delete process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH =
|
||||
originalEnv.CLINE_HUB_DASHBOARD_DISCOVERY_PATH;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("waits for a newly started hub-owned dashboard instead of spawning a competing fallback", async () => {
|
||||
const opened: string[] = [];
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(dashboardRecord(888));
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual(["http://127.0.0.1:8787"]);
|
||||
});
|
||||
|
||||
it("waits for the replacement dashboard after the hub process changes", async () => {
|
||||
const opened: string[] = [];
|
||||
const stale = dashboardRecord(888);
|
||||
const replacement = {
|
||||
...dashboardRecord(999),
|
||||
startedAt: "2026-06-22T20:00:01.000Z",
|
||||
updatedAt: "2026-06-22T20:00:01.000Z",
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(replacement);
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual(["http://127.0.0.1:8787"]);
|
||||
expect(coreMocks.readHubDashboardDiscovery).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("accepts a healthy dashboard whose optional hub URL is absent", async () => {
|
||||
const existingHub = {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
const dashboardWithoutHubUrl = {
|
||||
...dashboardRecord(888),
|
||||
hubUrl: undefined,
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(existingHub)
|
||||
.mockResolvedValueOnce(existingHub);
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(dashboardWithoutHubUrl)
|
||||
.mockResolvedValueOnce(dashboardWithoutHubUrl);
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(coreMocks.stopManagedHubDashboardProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when a newly started hub never publishes a healthy dashboard", async () => {
|
||||
vi.useFakeTimers();
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
coreMocks.readHubDashboardDiscovery.mockResolvedValue(undefined);
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const pending = runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
coreMocks.readHubDashboardDiscovery.mockResolvedValue(dashboardRecord(999));
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await expect(pending).resolves.toBe(0);
|
||||
expect(coreMocks.stopManagedHubDashboardProcess).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows CLI fallback only when the hub was already running", async () => {
|
||||
const existingHub = {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(existingHub)
|
||||
.mockResolvedValueOnce(existingHub);
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(dashboardRecord(999));
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(buildCliSubcommandCommand).toHaveBeenCalledWith(
|
||||
"dashboard",
|
||||
["serve"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not spawn a replacement when the existing dashboard cannot stop", async () => {
|
||||
const existingHub = {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(existingHub)
|
||||
.mockResolvedValueOnce(existingHub);
|
||||
coreMocks.readHubDashboardDiscovery.mockResolvedValueOnce(undefined);
|
||||
coreMocks.stopManagedHubDashboardProcess.mockRejectedValueOnce(
|
||||
new Error("dashboard process did not stop"),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: (message) => errors.push(message),
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errors).toEqual(["dashboard process did not stop"]);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes and clears serve discovery inside the sandbox environment", async () => {
|
||||
const observedDataDirs: Array<string | undefined> = [];
|
||||
coreMocks.resolveHubDashboardDiscoveryPath.mockImplementation(() => {
|
||||
observedDataDirs.push(process.env.CLINE_DATA_DIR);
|
||||
return "/tmp/dashboard.json";
|
||||
});
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
cwd: "/tmp/dashboard-serve-cwd",
|
||||
dataDir: "/tmp/dashboard-serve-data",
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedDataDirs).toEqual([
|
||||
"/tmp/dashboard-serve-data",
|
||||
"/tmp/dashboard-serve-data",
|
||||
]);
|
||||
expect(coreMocks.writeHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
expect.objectContaining({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
}),
|
||||
);
|
||||
expect(coreMocks.clearHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops the serve process when dashboard discovery cannot be written", async () => {
|
||||
coreMocks.writeHubDashboardDiscovery.mockRejectedValueOnce(
|
||||
new Error("discovery write failed"),
|
||||
);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const errors: string[] = [];
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: (message) => errors.push(message),
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errors).toEqual(["discovery write failed"]);
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(coreMocks.clearHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ const ENV_KEYS = [
|
||||
"PUBLIC_URL",
|
||||
"ROOM_SECRET",
|
||||
"CLINE_HUB_WEBVIEW_DIST_DIR",
|
||||
"CLINE_HUB_DASHBOARD_WEB_URL",
|
||||
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
|
||||
"CLINE_WRAPPER_PATH",
|
||||
] as const;
|
||||
|
||||
@@ -39,10 +41,9 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("runDashboardCommand", () => {
|
||||
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
|
||||
it("serves the dashboard in the foreground and waits for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const stop = vi.fn();
|
||||
let observedEnv:
|
||||
| {
|
||||
@@ -55,13 +56,16 @@ describe("runDashboardCommand", () => {
|
||||
publicUrl: string | undefined;
|
||||
roomSecret: string | undefined;
|
||||
webviewDistDir: string | undefined;
|
||||
dashboardWebUrl: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
delete process.env.CLINE_HUB_DASHBOARD_WEB_URL;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
@@ -84,6 +88,7 @@ describe("runDashboardCommand", () => {
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
roomSecret: process.env.ROOM_SECRET,
|
||||
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
|
||||
dashboardWebUrl: process.env.CLINE_HUB_DASHBOARD_WEB_URL,
|
||||
};
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:9090/",
|
||||
@@ -93,9 +98,6 @@ describe("runDashboardCommand", () => {
|
||||
stop,
|
||||
};
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown: async (server) => {
|
||||
await server.stop();
|
||||
},
|
||||
@@ -116,8 +118,8 @@ describe("runDashboardCommand", () => {
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
webviewDistDir,
|
||||
dashboardWebUrl: undefined,
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
@@ -126,6 +128,42 @@ describe("runDashboardCommand", () => {
|
||||
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("opens a detached dashboard and exits without waiting for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const ensureDashboard = vi.fn(async () => ({
|
||||
pid: 123,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
const waitForShutdown = vi.fn();
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
ensureDashboard,
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(ensureDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(waitForShutdown).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual(["http://127.0.0.1:8787"]);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("honors --no-open behavior", async () => {
|
||||
const openUrl = vi.fn();
|
||||
|
||||
@@ -135,20 +173,69 @@ describe("runDashboardCommand", () => {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
ensureDashboard: async () => ({
|
||||
pid: 123,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
openUrl,
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops a detached dashboard", async () => {
|
||||
const output: string[] = [];
|
||||
const stopDashboard = vi.fn(async () => true);
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "stop",
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: () => {},
|
||||
},
|
||||
stopDashboard,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stopDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(output).toEqual([JSON.stringify({ stopped: true })]);
|
||||
});
|
||||
|
||||
it("restarts a detached dashboard before opening it", async () => {
|
||||
const stopDashboard = vi.fn(async () => true);
|
||||
const ensureDashboard = vi.fn(async () => ({
|
||||
pid: 456,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "restart",
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
stopDashboard,
|
||||
ensureDashboard,
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stopDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(ensureDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(stopDashboard.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
ensureDashboard.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("finds webview assets from the published wrapper package layout", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
|
||||
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
|
||||
@@ -170,6 +257,7 @@ describe("runDashboardCommand", () => {
|
||||
let observedWebviewDistDir: string | undefined;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
|
||||
clearHubDashboardDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
type HubDashboardDiscoveryRecord,
|
||||
type HubServerDiscoveryRecord,
|
||||
isHubDashboardPidAlive,
|
||||
readHubDashboardDiscovery,
|
||||
readHubDiscovery,
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveHubDashboardDiscoveryPath,
|
||||
stopManagedHubDashboardProcess,
|
||||
writeHubDashboardDiscovery,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { buildCliSubcommandCommand } from "../utils/internal-launch";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
@@ -20,6 +36,7 @@ interface DashboardCommandIo {
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
action?: "open" | "restart" | "serve" | "stop";
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
@@ -30,12 +47,20 @@ export interface RunDashboardCommandOptions {
|
||||
openBrowser?: boolean;
|
||||
io: DashboardCommandIo;
|
||||
startServer?: () => Promise<DashboardServerHandle>;
|
||||
ensureDashboard?: () => Promise<HubDashboardDiscoveryRecord>;
|
||||
stopDashboard?: () => Promise<boolean>;
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
const DASHBOARD_WEB_URL_ENV = "CLINE_HUB_DASHBOARD_WEB_URL";
|
||||
const DEFAULT_HOSTED_DASHBOARD_WEB_URL = "https://cline.bot/dashboard";
|
||||
const DASHBOARD_STARTUP_TIMEOUT_MS = 8_000;
|
||||
const DASHBOARD_STARTUP_POLL_MS = 200;
|
||||
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
|
||||
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
@@ -67,14 +92,20 @@ async function withDashboardEnvironment<T>(
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const webviewDistDir = resolveDefaultWebviewDistDir();
|
||||
const restore = [
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue(
|
||||
DASHBOARD_WEB_URL_ENV,
|
||||
process.env[DASHBOARD_WEB_URL_ENV]?.trim() ||
|
||||
(webviewDistDir ? undefined : DEFAULT_HOSTED_DASHBOARD_WEB_URL),
|
||||
),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, webviewDistDir),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
@@ -94,8 +125,9 @@ async function withDashboardEnvironment<T>(
|
||||
}
|
||||
|
||||
function resolveDefaultWebviewDistDir(): string | undefined {
|
||||
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
|
||||
return undefined;
|
||||
const configuredWebviewDistDir = process.env[WEBVIEW_DIST_ENV]?.trim();
|
||||
if (configuredWebviewDistDir) {
|
||||
return configuredWebviewDistDir;
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -149,6 +181,187 @@ async function openDefaultUrl(url: string): Promise<void> {
|
||||
await open(url, { wait: false });
|
||||
}
|
||||
|
||||
function resolveDashboardDiscoveryPath(): string {
|
||||
return (
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
|
||||
resolveHubDashboardDiscoveryPath(resolveDefaultHubOwnerContext())
|
||||
);
|
||||
}
|
||||
|
||||
async function isDashboardHealthy(
|
||||
record: HubDashboardDiscoveryRecord,
|
||||
): Promise<boolean> {
|
||||
if (!isHubDashboardPidAlive(record.pid)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(new URL("/health", record.listenUrl));
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDefaultDashboard(): Promise<boolean> {
|
||||
return await stopManagedHubDashboardProcess(resolveDashboardDiscoveryPath());
|
||||
}
|
||||
|
||||
function spawnDetachedDashboardServer(cwd: string): void {
|
||||
const command = buildCliSubcommandCommand("dashboard", ["serve"], { cwd });
|
||||
if (!command) {
|
||||
throw new Error("unable to resolve CLI command for dashboard process");
|
||||
}
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]: resolveDashboardDiscoveryPath(),
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function waitForDashboardDiscovery(
|
||||
discoveryPath: string,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
acceptRecord?: (record: HubDashboardDiscoveryRecord) => boolean;
|
||||
} = {},
|
||||
): Promise<HubDashboardDiscoveryRecord> {
|
||||
const timeoutMs = options.timeoutMs ?? DASHBOARD_STARTUP_TIMEOUT_MS;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const discovered = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (
|
||||
discovered &&
|
||||
(options.acceptRecord?.(discovered) ?? true) &&
|
||||
(await isDashboardHealthy(discovered))
|
||||
) {
|
||||
return discovered;
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, DASHBOARD_STARTUP_POLL_MS),
|
||||
);
|
||||
}
|
||||
throw new Error("Timed out waiting for dashboard startup.");
|
||||
}
|
||||
|
||||
function hasHubManagedDashboardLaunchSpec(): boolean {
|
||||
return Boolean(
|
||||
process.env[DASHBOARD_LAUNCHER_ENV]?.trim() &&
|
||||
process.env[DASHBOARD_ARGS_ENV]?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function isSameHubProcess(
|
||||
before: HubServerDiscoveryRecord | undefined,
|
||||
after: HubServerDiscoveryRecord | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
before &&
|
||||
after &&
|
||||
before.url === after.url &&
|
||||
before.pid === after.pid &&
|
||||
before.startedAt === after.startedAt,
|
||||
);
|
||||
}
|
||||
|
||||
function isSameDashboardProcess(
|
||||
before: HubDashboardDiscoveryRecord | undefined,
|
||||
after: HubDashboardDiscoveryRecord,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
before && before.pid === after.pid && before.startedAt === after.startedAt,
|
||||
);
|
||||
}
|
||||
|
||||
function isExpectedDashboard(
|
||||
record: HubDashboardDiscoveryRecord,
|
||||
options: {
|
||||
hubChanged: boolean;
|
||||
hubUrl?: string;
|
||||
previousDashboard?: HubDashboardDiscoveryRecord;
|
||||
},
|
||||
): boolean {
|
||||
if (
|
||||
options.hubChanged &&
|
||||
isSameDashboardProcess(options.previousDashboard, record)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (options.hubUrl && record.hubUrl && record.hubUrl !== options.hubUrl) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureDefaultDashboard(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<HubDashboardDiscoveryRecord> {
|
||||
return await withDashboardEnvironment(options, async () => {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discoveryPath = resolveDashboardDiscoveryPath();
|
||||
const hubBefore = await readHubDiscovery(owner.discoveryPath);
|
||||
const dashboardBefore = await readHubDashboardDiscovery(discoveryPath);
|
||||
await ensureDetachedHubServer(cwd);
|
||||
const hubAfter = await readHubDiscovery(owner.discoveryPath);
|
||||
const hubChanged = !isSameHubProcess(hubBefore, hubAfter);
|
||||
const acceptRecord = (record: HubDashboardDiscoveryRecord) =>
|
||||
isExpectedDashboard(record, {
|
||||
hubChanged,
|
||||
hubUrl: hubAfter?.url,
|
||||
previousDashboard: dashboardBefore,
|
||||
});
|
||||
const discovered = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (
|
||||
discovered &&
|
||||
acceptRecord(discovered) &&
|
||||
(await isDashboardHealthy(discovered))
|
||||
) {
|
||||
return discovered;
|
||||
}
|
||||
if (hasHubManagedDashboardLaunchSpec() && hubChanged) {
|
||||
try {
|
||||
return await waitForDashboardDiscovery(discoveryPath, {
|
||||
acceptRecord,
|
||||
});
|
||||
} catch {
|
||||
// A healthy hub may outlive a failed dashboard child. Fall back to
|
||||
// the CLI-managed child after the hub startup window has elapsed.
|
||||
}
|
||||
}
|
||||
await stopDefaultDashboard();
|
||||
spawnDetachedDashboardServer(cwd);
|
||||
return await waitForDashboardDiscovery(discoveryPath, { acceptRecord });
|
||||
});
|
||||
}
|
||||
|
||||
async function writeDashboardDiscovery(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
await writeHubDashboardDiscovery(resolveDashboardDiscoveryPath(), {
|
||||
pid: process.pid,
|
||||
listenUrl: server.listenUrl,
|
||||
publicUrl: server.publicUrl,
|
||||
inviteUrl: server.inviteUrl,
|
||||
hubUrl: server.hubUrl,
|
||||
startedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDashboardDiscovery(): Promise<void> {
|
||||
await clearHubDashboardDiscovery(resolveDashboardDiscoveryPath()).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function waitForProcessShutdown(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
@@ -181,32 +394,86 @@ export function waitForProcessShutdown(
|
||||
});
|
||||
}
|
||||
|
||||
async function runDashboardServeCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
return await withDashboardEnvironment(options, async () => {
|
||||
const server = await (options.startServer ?? startDefaultDashboardServer)();
|
||||
try {
|
||||
await writeDashboardDiscovery(server);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
}
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
} catch (error) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// Preserve the original serve failure after best-effort cleanup.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await clearDashboardDiscovery();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function openDashboardUrl(
|
||||
options: RunDashboardCommandOptions,
|
||||
record: Pick<
|
||||
HubDashboardDiscoveryRecord,
|
||||
"inviteUrl" | "publicUrl" | "listenUrl"
|
||||
>,
|
||||
): Promise<void> {
|
||||
const dashboardUrl = record.inviteUrl || record.publicUrl || record.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDashboardCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const server = await withDashboardEnvironment(options, () =>
|
||||
(options.startServer ?? startDefaultDashboardServer)(),
|
||||
);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
const action = options.action ?? "open";
|
||||
if (action === "serve") {
|
||||
return await runDashboardServeCommand(options);
|
||||
}
|
||||
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
if (action === "stop") {
|
||||
const stopped = options.stopDashboard
|
||||
? await options.stopDashboard()
|
||||
: await withDashboardEnvironment(options, stopDefaultDashboard);
|
||||
options.io.writeln(JSON.stringify({ stopped }));
|
||||
return 0;
|
||||
}
|
||||
if (action === "restart") {
|
||||
if (options.stopDashboard) {
|
||||
await options.stopDashboard();
|
||||
} else {
|
||||
await withDashboardEnvironment(options, stopDefaultDashboard);
|
||||
}
|
||||
}
|
||||
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
const record = await (
|
||||
options.ensureDashboard ?? (() => ensureDefaultDashboard(options))
|
||||
)();
|
||||
await openDashboardUrl(options, record);
|
||||
if (record.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${record.hubUrl}${c.reset}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
options.io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveDefaultHubOwnerContext,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
@@ -25,6 +27,16 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"owners",
|
||||
"hub-owner.json",
|
||||
),
|
||||
})),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
@@ -62,6 +74,7 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
@@ -96,6 +109,16 @@ describe("runDoctorCommand", () => {
|
||||
"production.json",
|
||||
),
|
||||
});
|
||||
mockResolveDefaultHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"owners",
|
||||
"hub-owner.json",
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
@@ -174,6 +197,40 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.63",
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
cliVersion,
|
||||
coreVersion: "0.0.64",
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
@@ -182,6 +239,10 @@ describe("runDoctorCommand", () => {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath,
|
||||
});
|
||||
mockResolveDefaultHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath,
|
||||
});
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
@@ -49,6 +49,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -317,9 +319,7 @@ function formatHubUptimeFromStartedAt(
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
return resolveDefaultHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
@@ -337,6 +337,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
@@ -419,6 +421,8 @@ export async function runDoctorCommand(
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
|
||||
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
@@ -462,9 +466,9 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
? await stopLocalHubServerGracefully({
|
||||
owner: resolveCliHubOwnerContext(),
|
||||
}).catch(() => false)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveDefaultHubOwnerContext,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
@@ -13,6 +14,10 @@ const {
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
})),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -29,11 +34,13 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +70,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +96,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,8 +128,10 @@ describe("createHubCommand", () => {
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
owner: {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -18,7 +18,7 @@ interface HubCommandIo {
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
if (await stopLocalHubServerGracefully({ owner })) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
}
|
||||
@@ -48,9 +48,7 @@ function formatHubUptimeFromStartedAt(
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
return resolveDefaultHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
@@ -134,6 +132,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -148,8 +148,10 @@ export function isJsonPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".json");
|
||||
}
|
||||
|
||||
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
|
||||
if (raw === "act" || raw === "plan") {
|
||||
export function parseMode(
|
||||
raw: string | undefined,
|
||||
): "act" | "plan" | "yolo" | undefined {
|
||||
if (raw === "act" || raw === "plan" || raw === "yolo") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
mergeScheduleMetadata,
|
||||
parseJsonObjectFlag,
|
||||
parseList,
|
||||
parseMode,
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
@@ -63,8 +65,8 @@ export function registerScheduleCommands(
|
||||
.option("--disabled", "Create in disabled state")
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan>", "Execution mode")
|
||||
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
|
||||
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
|
||||
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
@@ -96,7 +98,7 @@ export function registerScheduleCommands(
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: opts.mode === "plan" ? "plan" : "act",
|
||||
mode: parseMode(opts.mode) ?? "yolo",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
modelSelection?.modelId ??
|
||||
parsed.modelId ??
|
||||
parsed.model ??
|
||||
"openai/gpt-5.3-codex",
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
).trim();
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -165,7 +166,10 @@ export function registerScheduleImportCommand(
|
||||
prompt: String(parsed.prompt ?? "").trim(),
|
||||
provider,
|
||||
model,
|
||||
mode: parsed.mode === "plan" ? "plan" : "act",
|
||||
mode:
|
||||
parseMode(
|
||||
typeof parsed.mode === "string" ? parsed.mode : undefined,
|
||||
) ?? "yolo",
|
||||
workspaceRoot,
|
||||
cwd: String(parsed.cwd ?? "").trim() || undefined,
|
||||
systemPrompt:
|
||||
@@ -229,7 +233,7 @@ export function registerScheduleUpdateCommand(
|
||||
.option("--enabled", "Enable the schedule")
|
||||
.option("--max-parallel <n>", "New max parallel executions")
|
||||
.option("--metadata-json <json>", "New metadata as JSON object")
|
||||
.option("--mode <act|plan>", "New execution mode")
|
||||
.option("--mode <act|plan|yolo>", "New execution mode")
|
||||
.option("--model <model>", "New model")
|
||||
.option("--name <name>", "New name")
|
||||
.option("--pause", "Pause the schedule")
|
||||
|
||||
@@ -101,6 +101,22 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
@@ -118,7 +116,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
|
||||
// them to ~/.bun/install/global/node_modules/..., so match both.
|
||||
if (
|
||||
scriptPath.includes("/.bun/bin") ||
|
||||
scriptPath.includes("/.bun/install/global/")
|
||||
) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
@@ -272,9 +275,7 @@ export function getPreferredKanbanInstaller(
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
return resolveDefaultHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
@@ -314,7 +315,9 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully({ owner }).catch(
|
||||
() => false,
|
||||
);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
@@ -67,6 +67,62 @@ describe("createConnectorRuntimeTurnStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps streaming when tool status delivery fails", async () => {
|
||||
let handlers: StreamHandlers | undefined;
|
||||
const log = vi.fn();
|
||||
const statusError = new Error("message_not_found");
|
||||
const client = {
|
||||
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
|
||||
handlers = callbacks;
|
||||
return () => {};
|
||||
},
|
||||
sendRuntimeSession: async () => {
|
||||
handlers?.onEvent({
|
||||
eventType: "runtime.chat.tool_call_start",
|
||||
payload: { toolName: "run_commands" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
handlers?.onEvent({
|
||||
eventType: "runtime.chat.text_delta",
|
||||
payload: { text: "Final response" },
|
||||
});
|
||||
return {
|
||||
result: {
|
||||
text: "Final response",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of createConnectorRuntimeTurnStream({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
request: { config: {} as never, prompt: "hi" },
|
||||
clientId: "client-1",
|
||||
logger: { core: { log } } as unknown as CliLoggerAdapter,
|
||||
transport: "slack",
|
||||
conversationId: "thread-1",
|
||||
onToolStatus: async () => {
|
||||
throw statusError;
|
||||
},
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks.join("")).toBe("Final response");
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"Connector tool status delivery failed",
|
||||
expect.objectContaining({
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: statusError,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats queued runtime turns as non-error completion", async () => {
|
||||
const log = vi.fn();
|
||||
const client = {
|
||||
|
||||
@@ -169,7 +169,17 @@ export function createConnectorRuntimeTurnStream(input: {
|
||||
return;
|
||||
}
|
||||
lastStatusMessage = message;
|
||||
await input.onToolStatus?.(message);
|
||||
try {
|
||||
await input.onToolStatus?.(message);
|
||||
} catch (error) {
|
||||
input.logger.core.log("Connector tool status delivery failed", {
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
conversationId: input.conversationId,
|
||||
sessionId: input.sessionId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopStreaming = input.client.streamEvents(
|
||||
|
||||
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ initVcr(process.env.CLINE_VCR);
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (isHubDaemonProcess()) {
|
||||
// The hub daemon owns its process-level abort handling. Installing the CLI's
|
||||
// fatal rejection handler first would make expected abort rejections exit it.
|
||||
void import("@cline/core/hub/daemon-entry");
|
||||
} else {
|
||||
let shuttingDown = false;
|
||||
let handlingFatalProcessError = false;
|
||||
@@ -57,10 +61,10 @@ if (!isMainThread) {
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
const { configureCliHubDashboardLaunchEnvironment } = await import(
|
||||
"./utils/hub-dashboard-launch"
|
||||
);
|
||||
configureCliHubDashboardLaunchEnvironment();
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
|
||||
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
|
||||
});
|
||||
vi.mock("./utils/session", () => sessionMocks);
|
||||
vi.mock("./session/session", () => sessionMocks);
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...(await vi.importActual("@cline/core")),
|
||||
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
||||
createTeamName: vi.fn(() => "team-test"),
|
||||
createUserInstructionConfigService: vi.fn(() => ({
|
||||
@@ -332,7 +333,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("does not load runtime modules for root update", async () => {
|
||||
mockState.runAgentImports = 0;
|
||||
@@ -1013,6 +1014,80 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
|
||||
// CLINE-2406: when persisted Cline auth includes an accountId, the
|
||||
// runtime path must call identifyTelemetryAccount(accountContext) so
|
||||
// subsequent task.* and workspace.* events carry user_id.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "usr-abc-123",
|
||||
provider: "cline",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
|
||||
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
|
||||
// identifyTelemetryAccount should not be called from the runtime path.
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
// no auth / no accountId
|
||||
};
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
||||
clineSettings,
|
||||
);
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "cline",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
|
||||
// CLINE-2406: identity identification from saved settings only applies
|
||||
// to Cline-provider sessions; other providers use different auth flows.
|
||||
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
authMocks.normalizeProviderId.mockImplementation(
|
||||
(providerId?: string) => providerId ?? "openrouter",
|
||||
);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs kanban before loading runtime modules", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "kanban"];
|
||||
|
||||
@@ -1056,6 +1131,27 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("does not expose the internal dashboard serve action in invalid action errors", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
process.argv = ["bun", "src/index.ts", "dashboard", "nonesuch"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Unknown dashboard action "nonesuch". Expected restart or stop.',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
consoleError.mock.calls.some((call) => String(call[0]).includes("serve")),
|
||||
).toBe(false);
|
||||
expect(dashboardMocks.runDashboardCommand).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("prints an install hint when kanban is missing", async () => {
|
||||
const stderrWrite = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
@@ -1227,7 +1323,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
@@ -1314,7 +1409,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables truncation compaction by default for prompt runs", async () => {
|
||||
it("uses Core's agentic compaction default for prompt runs", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
@@ -1329,7 +1424,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
|
||||
+57
-3
@@ -15,6 +15,7 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
@@ -46,6 +47,7 @@ import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
getCliTelemetryService,
|
||||
identifyTelemetryAccount,
|
||||
} from "./utils/telemetry";
|
||||
import type { Config } from "./utils/types";
|
||||
import { runConnectWizard } from "./wizards/connect";
|
||||
@@ -621,7 +623,8 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
const dashboardCmd = program
|
||||
.command("dashboard")
|
||||
.description("Start the Cline Hub dashboard and open it in a browser")
|
||||
.description("Open or manage the Cline Hub dashboard")
|
||||
.argument("[action]", "dashboard action: restart or stop")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Workspace root", process.cwd())
|
||||
.option(
|
||||
@@ -633,7 +636,7 @@ export async function runCli(): Promise<void> {
|
||||
.option("--public-url <url>", "Public dashboard URL")
|
||||
.option("--room-secret <secret>", "Invite secret for browser access")
|
||||
.option("--no-open", "Start the dashboard without opening a browser")
|
||||
.action(async () => {
|
||||
.action(async (action?: string) => {
|
||||
const opts = dashboardCmd.opts<{
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
@@ -644,8 +647,22 @@ export async function runCli(): Promise<void> {
|
||||
roomSecret?: string;
|
||||
open?: boolean;
|
||||
}>();
|
||||
const normalizedAction = action?.trim().toLowerCase();
|
||||
if (
|
||||
normalizedAction &&
|
||||
normalizedAction !== "restart" &&
|
||||
normalizedAction !== "stop" &&
|
||||
normalizedAction !== "serve"
|
||||
) {
|
||||
io.writeErr(
|
||||
`Unknown dashboard action "${action}". Expected restart or stop.`,
|
||||
);
|
||||
ctx.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { runDashboardCommand } = await import("./commands/dashboard");
|
||||
ctx.exitCode = await runDashboardCommand({
|
||||
action: normalizedAction as "restart" | "serve" | "stop" | undefined,
|
||||
configDir: opts.config,
|
||||
cwd: opts.cwd,
|
||||
dataDir: opts.dataDir,
|
||||
@@ -926,6 +943,17 @@ export async function runCli(): Promise<void> {
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// Register the SDK early logger as early as possible — before any
|
||||
// provider settings reads — so the full startup sequence is captured.
|
||||
// These components operate before/outside ClineCore sessions, so the
|
||||
// session-scoped logger can't reach them.
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "main",
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
@@ -962,6 +990,25 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
let selectedProviderSettings =
|
||||
providerSettingsManager.getProviderSettings(provider);
|
||||
|
||||
// Apply locally persisted Cline account identity so subsequent events
|
||||
// (task.*, workspace.initialized) carry user_id when available.
|
||||
// Note: user.extension_activated fires anonymously earlier in startup
|
||||
// and cannot be retroactively updated; this is by design for
|
||||
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
|
||||
if (provider === "cline") {
|
||||
const savedAuth = selectedProviderSettings?.auth;
|
||||
if (savedAuth?.accountId) {
|
||||
identifyTelemetryAccount({
|
||||
id: savedAuth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: savedAuth.organizationId,
|
||||
organizationName: savedAuth.organizationName,
|
||||
memberId: savedAuth.memberId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const persistedApiKey = getPersistedProviderApiKey(
|
||||
provider,
|
||||
selectedProviderSettings,
|
||||
@@ -1029,6 +1076,7 @@ export async function runCli(): Promise<void> {
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -1079,7 +1127,13 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: { name: "cline-cli" },
|
||||
client: {
|
||||
name: "cline-cli",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
|
||||
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
|
||||
let activeRuntimeCleanup: (() => void) | undefined;
|
||||
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortInProgress = false;
|
||||
let savedRejectionListeners: Function[] | undefined;
|
||||
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
|
||||
|
||||
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
|
||||
activeRuntimeAbort = abortFn;
|
||||
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners(
|
||||
"unhandledRejection",
|
||||
) as Function[];
|
||||
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
@@ -68,10 +68,7 @@ export function clearAbortInProgress(): void {
|
||||
if (savedRejectionListeners) {
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
for (const listener of savedRejectionListeners) {
|
||||
process.on(
|
||||
"unhandledRejection",
|
||||
listener as (...args: unknown[]) => void,
|
||||
);
|
||||
process.on("unhandledRejection", listener);
|
||||
}
|
||||
savedRejectionListeners = undefined;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@ import {
|
||||
resolveCompactionProviderConfig,
|
||||
} from "./compaction";
|
||||
|
||||
const createHandlerMock = vi.fn();
|
||||
|
||||
// Core defaults to the agentic compaction strategy, which summarizes via a
|
||||
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
|
||||
// key) is needed; every other `@cline/llms` export stays real because
|
||||
// `@cline/core` re-exports them.
|
||||
vi.mock("@cline/llms", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@cline/llms")>()),
|
||||
createHandlerAsync: (config: unknown) => createHandlerMock(config),
|
||||
}));
|
||||
|
||||
async function* streamChunks(
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
@@ -46,6 +65,7 @@ function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
createHandlerMock.mockReset();
|
||||
for (const tempDir of providerSettingsTempDirs.splice(0)) {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
@@ -106,7 +126,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(400_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -126,10 +146,11 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -137,7 +158,7 @@ describe("compactInteractiveMessages", () => {
|
||||
}));
|
||||
const config = createConfig();
|
||||
const compact = vi.fn((context: CoreCompactionContext) => {
|
||||
expect(context.maxInputTokens).toBe(400_000);
|
||||
expect(context.budget.request.maxInputTokens).toBe(360_000);
|
||||
return { messages: [messages[0]] };
|
||||
});
|
||||
config.knownModels = {
|
||||
@@ -157,10 +178,20 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
const mockSummary = "## Goal\nMocked agentic compaction summary";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn(() =>
|
||||
streamChunks([
|
||||
{ type: "text", id: "summary-1", text: mockSummary },
|
||||
{ type: "done", id: "summary-1", success: true },
|
||||
]),
|
||||
),
|
||||
});
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -174,7 +205,8 @@ describe("compactInteractiveMessages", () => {
|
||||
messages,
|
||||
});
|
||||
|
||||
const compactedTextLength = result.messages.reduce(
|
||||
const compactedMessages = result.compactionState?.messages ?? [];
|
||||
const compactedTextLength = compactedMessages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(typeof message.content === "string" ? message.content.length : 0),
|
||||
@@ -182,9 +214,21 @@ describe("compactInteractiveMessages", () => {
|
||||
);
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages.length).toBeGreaterThan(1);
|
||||
expect(result.messages.length).toBeLessThan(messages.length);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
|
||||
// The agentic strategy folds older messages into a summary message
|
||||
// built from the (mocked) summarizer output.
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
const [summaryMessage] = compactedMessages;
|
||||
const summaryText = Array.isArray(summaryMessage?.content)
|
||||
? summaryMessage.content
|
||||
.map((block) => ("text" in block ? block.text : ""))
|
||||
.join("\n")
|
||||
: String(summaryMessage?.content ?? "");
|
||||
expect(summaryText).toContain(mockSummary);
|
||||
});
|
||||
|
||||
it("reports compaction when core returns changed messages with the same count", async () => {
|
||||
@@ -214,8 +258,9 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toHaveLength(messages.length);
|
||||
expect(result.messages[0]?.content).toBe(
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toHaveLength(messages.length);
|
||||
expect(result.compactionState?.messages[0]?.content).toBe(
|
||||
"same count but content should be trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
type ReasoningSettings,
|
||||
type SessionCompactionState,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
@@ -52,13 +54,22 @@ export async function compactInteractiveMessages(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
}): Promise<{ compacted: boolean; messages: Message[] }> {
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
compacted: boolean;
|
||||
canonicalMessages: Message[];
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
modelInfo?.maxInputTokens ??
|
||||
modelInfo?.contextWindow ??
|
||||
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
|
||||
const compactionModelInfo = modelInfo
|
||||
? {
|
||||
...modelInfo,
|
||||
id: modelInfo.id ?? input.config.modelId,
|
||||
}
|
||||
: {
|
||||
id: input.config.modelId,
|
||||
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
|
||||
};
|
||||
const compact = createContextCompactionPrepareTurn(
|
||||
{
|
||||
providerConfig: resolveCompactionProviderConfig(
|
||||
@@ -81,8 +92,11 @@ export async function compactInteractiveMessages(input: {
|
||||
{ mode: "manual" },
|
||||
);
|
||||
if (!compact) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
// Manual compaction intentionally summarizes the full canonical transcript
|
||||
// instead of reusing a prior sidecar summary, which avoids summary-of-summary
|
||||
// drift across repeated `/compact` calls.
|
||||
const result = await compact({
|
||||
agentId: "cli",
|
||||
conversationId: input.sessionId,
|
||||
@@ -90,21 +104,26 @@ export async function compactInteractiveMessages(input: {
|
||||
iteration: 0,
|
||||
messages: input.messages,
|
||||
apiMessages: input.messages,
|
||||
abortSignal: new AbortController().signal,
|
||||
abortSignal: input.abortSignal ?? new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
model: {
|
||||
id: input.config.modelId,
|
||||
provider: input.config.providerId,
|
||||
info: {
|
||||
...(modelInfo ?? {}),
|
||||
id: modelInfo?.id ?? input.config.modelId,
|
||||
maxInputTokens: maxInputTokens,
|
||||
},
|
||||
info: compactionModelInfo,
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
if (!result?.messages) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
return { compacted: true, messages: result.messages };
|
||||
return {
|
||||
compacted: true,
|
||||
canonicalMessages: input.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,38 +107,13 @@ export async function sendTurnWithActModeContinuation<
|
||||
};
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: InteractiveUiMode;
|
||||
to: InteractiveUiMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
|
||||
* switch_to_act_mode path already announces itself via the continuation
|
||||
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
|
||||
* out, since the mode the model last saw never effectively changed.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
return {
|
||||
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending = pending.from === to ? null : { from: pending.from, to };
|
||||
return;
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
},
|
||||
};
|
||||
}
|
||||
// The tracker moved to @cline/shared so the VSCode extension can share the
|
||||
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
|
||||
// import surface stable.
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
|
||||
@@ -1,81 +1,89 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
|
||||
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
|
||||
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
|
||||
function makeConfig(): Config {
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
enableAgentTeams: true,
|
||||
verbose: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
function createChatCommandState(config = createConfig()): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
@@ -84,6 +92,35 @@ function makeChatCommandState(config: Config): ChatCommandState {
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
return {
|
||||
getProviderSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ProviderSettingsManager;
|
||||
}
|
||||
|
||||
function createManifest(sessionId: string): SessionManifest {
|
||||
return {
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: 1,
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
cwd: "/tmp/project",
|
||||
workspace_root: "/tmp/project",
|
||||
enable_tools: true,
|
||||
enable_spawn: true,
|
||||
enable_teams: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function importRuntime() {
|
||||
return await import("./session-runtime");
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
@@ -100,9 +137,9 @@ function makeManager() {
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -114,10 +151,13 @@ function makeManager() {
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
updateSessionConnection: vi.fn(async () => {}),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
@@ -133,7 +173,7 @@ function makeTurnResult() {
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
@@ -150,20 +190,22 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
async function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
config?: Config;
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const config = options.config ?? createConfig();
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
chatCommandState: createChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
@@ -172,26 +214,325 @@ function makeRuntime(
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
it("manual compact updates the active session sidecar without restarting", async () => {
|
||||
const sessionId = "sess-active";
|
||||
const messages = [
|
||||
{ id: "u1", role: "user" as const, content: "hello" },
|
||||
{ id: "a1", role: "assistant" as const, content: "world" },
|
||||
];
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
compactInteractiveMessagesMock.mockResolvedValue({
|
||||
compacted: true,
|
||||
canonicalMessages: messages,
|
||||
compactionState,
|
||||
});
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.compactCurrentSession();
|
||||
|
||||
expect(result).toEqual({
|
||||
messagesBefore: messages.length,
|
||||
messagesAfter: messages.length,
|
||||
workingContextMessagesAfter: compactionState.messages.length,
|
||||
compacted: true,
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(manager.stop).not.toHaveBeenCalled();
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
}),
|
||||
providerSettingsManager: expect.objectContaining({
|
||||
getProviderSettings: expect.any(Function),
|
||||
}),
|
||||
sessionId,
|
||||
messages,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
compactionState,
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("rejects manual compact while the active session is running", async () => {
|
||||
const sessionId = "sess-running";
|
||||
const messages = [{ role: "user" as const, content: "hello" }];
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
status: "running",
|
||||
}),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"Cannot compact while the current turn is running",
|
||||
);
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects manual compact when compaction is disabled", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
config.compaction = { enabled: false };
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"compaction is off",
|
||||
);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries compacted working context across mode-switch restarts", async () => {
|
||||
const firstSessionId = "sess-mode-before";
|
||||
const secondSessionId = "sess-mode-after";
|
||||
const prefixMessage = {
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: "large original",
|
||||
};
|
||||
const tailMessage = {
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: "new canonical tail",
|
||||
};
|
||||
const messages = [prefixMessage, tailMessage];
|
||||
const summaryMessage = {
|
||||
id: "summary",
|
||||
role: "user" as const,
|
||||
content: "summary",
|
||||
};
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: [prefixMessage],
|
||||
compactedMessages: [summaryMessage],
|
||||
conversationId: firstSessionId,
|
||||
systemPrompt: "compacted system",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: firstSessionId,
|
||||
manifest: createManifest(firstSessionId),
|
||||
manifestPath: "/tmp/session-before.json",
|
||||
messagesPath: "/tmp/session-before.messages.json",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: secondSessionId,
|
||||
manifest: createManifest(secondSessionId),
|
||||
manifestPath: "/tmp/session-after.json",
|
||||
messagesPath: "/tmp/session-after.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.applyMode("plan");
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
|
||||
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
|
||||
firstSessionId,
|
||||
);
|
||||
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
|
||||
const restartInput = manager.start.mock.calls[1]?.[0];
|
||||
expect(restartInput).toMatchObject({
|
||||
initialMessages: messages,
|
||||
initialCompactionState: expect.objectContaining({
|
||||
source_message_count: messages.length,
|
||||
messages: [summaryMessage, tailMessage],
|
||||
system_prompt: "compacted system",
|
||||
}),
|
||||
});
|
||||
expect(restartInput.initialCompactionState).not.toHaveProperty(
|
||||
"conversation_id",
|
||||
);
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
@@ -202,7 +543,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
@@ -212,7 +553,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
@@ -224,7 +565,9 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: { session_id: "session-restarted" },
|
||||
manifest: createManifest("session-restarted"),
|
||||
manifestPath: "/tmp/session-restarted.json",
|
||||
messagesPath: "/tmp/session-restarted.messages.json",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -249,13 +592,13 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
createRuntimeHooksMock.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = makeRuntime(manager, {
|
||||
const runtime = await makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
@@ -307,14 +650,51 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: "resumed-session",
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
@@ -322,16 +702,14 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
config: expect.objectContaining({ sessionId: "resumed-session" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
@@ -348,8 +726,46 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
@@ -371,7 +787,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
@@ -399,12 +815,89 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
|
||||
const manager = makeManager();
|
||||
const config = {
|
||||
...createConfig(),
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
config.providerId = "openai-compatible";
|
||||
config.modelId = "custom-model";
|
||||
config.apiKey = "new-key";
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
}),
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the active session connection in place without restarting", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.updateCurrentSessionConnection({
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
|
||||
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
});
|
||||
|
||||
it("does not reuse the session id when restarting empty", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
const secondStart = manager.start.mock.calls[1]?.[0] as {
|
||||
config?: { sessionId?: string };
|
||||
};
|
||||
expect(secondStart?.config?.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
new SessionNotFoundError("session-1"),
|
||||
);
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
@@ -422,7 +915,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
|
||||
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
|
||||
const manager = makeManager();
|
||||
let runtime!: ReturnType<typeof makeRuntime>;
|
||||
let runtime!: Awaited<ReturnType<typeof makeRuntime>>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
@@ -432,7 +925,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = makeRuntime(manager);
|
||||
runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
@@ -450,7 +943,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -46,6 +49,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
|
||||
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
|
||||
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
|
||||
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
export type SessionConnectionUpdate = Parameters<
|
||||
CliCore["updateSessionConnection"]
|
||||
>[1];
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
@@ -116,6 +122,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
let manualCompactionAbortController: AbortController | undefined;
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
@@ -205,15 +212,23 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
// For continuing the same conversation, e.g. after a config change.
|
||||
sessionId?: string,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: buildSessionConfig(),
|
||||
config: {
|
||||
...buildSessionConfig(),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -309,6 +324,25 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
};
|
||||
|
||||
const readCompactionState = async (
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await manager.readSessionCompactionState(sessionId);
|
||||
} catch (error) {
|
||||
input.config.logger?.log?.("Failed to read session compaction state", {
|
||||
sessionId,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (
|
||||
error: unknown,
|
||||
): Promise<MissingSessionRecovery> => {
|
||||
@@ -343,6 +377,15 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const readCurrentCompactionState = async (): Promise<
|
||||
SessionCompactionState | undefined
|
||||
> => {
|
||||
if (!activeSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return await readCompactionState(activeSessionId);
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -380,7 +423,15 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
@@ -392,7 +443,12 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
reuseSessionId,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
@@ -411,14 +467,45 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
const [{ messages, status }, compactionState] = await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
]);
|
||||
if (status !== "read") {
|
||||
// If reading recovered a missing hub session, the current messages are
|
||||
// already in the replacement session. If the read is stale, another async
|
||||
// operation changed the active session while this read was in flight.
|
||||
return;
|
||||
}
|
||||
await restartWithMessages(messages);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await restartWithMessages(
|
||||
messages,
|
||||
undefined,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
{ preserveSessionId: true },
|
||||
);
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
if (!manager || !sessionId) {
|
||||
// No live session to update; the next startup builds its config from
|
||||
// the already-mutated CLI config, so nothing else is needed.
|
||||
return;
|
||||
}
|
||||
await manager.updateSessionConnection(sessionId, update);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
@@ -532,6 +619,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Cannot fork an empty session.");
|
||||
}
|
||||
const compactionState = await readCompactionState(forkedFromSessionId);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await manager.stop(forkedFromSessionId);
|
||||
const forkMetadata = buildForkSessionMetadata({
|
||||
forkedFromSessionId,
|
||||
@@ -539,8 +630,33 @@ export function createInteractiveSessionRuntime(input: {
|
||||
sourceSession: sessionRecord,
|
||||
messages,
|
||||
});
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
await startFreshSession(
|
||||
messages,
|
||||
forkMetadata,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
// Report carried context from what the new session actually accepted:
|
||||
// the host can reject the inherited state (e.g. stale anchor), and the
|
||||
// UI must not claim a carry-over that did not happen.
|
||||
const acceptedState = projectedMessages
|
||||
? await readCompactionState(activeSessionId)
|
||||
: undefined;
|
||||
return {
|
||||
forkedFromSessionId,
|
||||
newSessionId: activeSessionId,
|
||||
carriedWorkingContext: acceptedState
|
||||
? {
|
||||
workingContextMessages: acceptedState.messages.length,
|
||||
canonicalMessages: messages.length,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
@@ -561,9 +677,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
if (!sessionManager) {
|
||||
if (input.config.compaction?.enabled === false) {
|
||||
throw new Error(
|
||||
"Cannot compact because compaction is off for this session.",
|
||||
);
|
||||
}
|
||||
const manager = sessionManager;
|
||||
const sourceSessionId = activeSessionId;
|
||||
if (!manager || !sourceSessionId) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
@@ -577,12 +701,28 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: activeSessionId,
|
||||
messages,
|
||||
});
|
||||
const sessionRecord = await manager.get(sourceSessionId);
|
||||
if (sessionRecord?.status === "running") {
|
||||
throw new Error(
|
||||
"Cannot compact while the current turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof compactInteractiveMessages>>;
|
||||
const abortController = new AbortController();
|
||||
manualCompactionAbortController = abortController;
|
||||
try {
|
||||
result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: sourceSessionId,
|
||||
messages,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
} finally {
|
||||
if (manualCompactionAbortController === abortController) {
|
||||
manualCompactionAbortController = undefined;
|
||||
}
|
||||
}
|
||||
if (!result.compacted) {
|
||||
return {
|
||||
messagesBefore,
|
||||
@@ -590,10 +730,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
await restartWithMessages(result.messages);
|
||||
if (!result.compactionState) {
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: messagesBefore,
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
const updated = await manager.updateSessionCompactionState(
|
||||
sourceSessionId,
|
||||
result.compactionState,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
throw new Error("Compaction could not be saved. Try again.");
|
||||
}
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: result.messages.length,
|
||||
messagesAfter: result.canonicalMessages.length,
|
||||
workingContextMessagesAfter: result.compactionState?.messages.length,
|
||||
compacted: true,
|
||||
};
|
||||
};
|
||||
@@ -683,6 +837,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
abortRequested = true;
|
||||
markAbortInProgress();
|
||||
manualCompactionAbortController?.abort(
|
||||
new Error("Interactive runtime abort requested"),
|
||||
);
|
||||
sessionManager
|
||||
.abort(activeSessionId, new Error("Interactive runtime abort requested"))
|
||||
.catch(() => {});
|
||||
@@ -730,6 +887,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
compactCurrentSession,
|
||||
|
||||
@@ -9,23 +9,6 @@ import {
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
|
||||
|
||||
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
|
||||
|
||||
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
|
||||
|
||||
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
|
||||
|
||||
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
|
||||
|
||||
- Read files, search the codebase, and gather context to understand the problem
|
||||
- Ask clarifying questions when requirements are ambiguous
|
||||
- Present your plan as a structured outline with clear steps
|
||||
- Explain tradeoffs between different approaches when they exist
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
explicitSystemPrompt?: string;
|
||||
@@ -34,15 +17,10 @@ export async function resolveSystemPrompt(input: {
|
||||
mode?: AgentMode;
|
||||
}): Promise<string> {
|
||||
const metadata = await buildWorkspaceMetadata(input.cwd);
|
||||
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
// Both modes get the mode-tag explanation: after a switch, the transcript
|
||||
// still contains messages tagged with the other mode.
|
||||
rules = rules
|
||||
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
|
||||
: MODE_TAG_INSTRUCTIONS;
|
||||
if (input.mode === "plan") {
|
||||
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
|
||||
}
|
||||
// Mode-tag and plan-mode instructions are appended by the shared prompt
|
||||
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
|
||||
// @cline/shared), so only the caller-specific rules are merged here.
|
||||
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
workspaceRoot: input.cwd,
|
||||
|
||||
@@ -43,6 +43,15 @@ const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
"ClinePass limit reached",
|
||||
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
].join("\n");
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
extractClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
const prefix = "you have reached your";
|
||||
const suffix = "please try again later.";
|
||||
const start = normalized.indexOf(prefix);
|
||||
if (start === -1) return undefined;
|
||||
const suffixStart = normalized.indexOf(suffix, start);
|
||||
if (suffixStart === -1) return undefined;
|
||||
const end = suffixStart + suffix.length;
|
||||
if (!normalized.slice(start, end).includes("clinepass limit")) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start, end);
|
||||
},
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -769,6 +802,126 @@ describe("runAgent", () => {
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_LIMIT_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
resolveReasoningForModelChange,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModelChange", () => {
|
||||
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
} as Config;
|
||||
const getProviderSettings = vi.fn(() => ({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible" as const,
|
||||
protocol: "openai-chat" as const,
|
||||
model: "old-model",
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
const updateCurrentSessionConnection = vi.fn(async () => {});
|
||||
|
||||
await applyInteractiveModelChange({
|
||||
config,
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
},
|
||||
sessionRuntime: {
|
||||
ensureReady,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible",
|
||||
protocol: "openai-chat",
|
||||
model: "custom-model",
|
||||
});
|
||||
expect(ensureReady).toHaveBeenCalledOnce();
|
||||
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
|
||||
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
});
|
||||
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function applyInteractiveModelChange(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<
|
||||
ProviderSettingsManager,
|
||||
"getProviderSettings" | "saveProviderSettings"
|
||||
>;
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
| "ensureReady"
|
||||
| "restartWithCurrentMessages"
|
||||
| "updateCurrentSessionConnection"
|
||||
>;
|
||||
}): Promise<void> {
|
||||
const { config, providerSettingsManager, sessionRuntime } = input;
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
|
||||
// Provider changes affect more than the model connection: startup resolves
|
||||
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
|
||||
// the runtime with the existing transcript so all of that state changes
|
||||
// together. restartWithCurrentMessages preserves the session ID.
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
// A same-ID restart reuses the existing manifest. Sync its connection label
|
||||
// after the fully configured runtime is live so session history reflects the
|
||||
// provider/model that will handle subsequent turns.
|
||||
await sessionRuntime.updateCurrentSessionConnection({
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -687,25 +732,12 @@ export async function runInteractive(
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
onModelChange: () =>
|
||||
applyInteractiveModelChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
providerSettingsManager,
|
||||
sessionRuntime,
|
||||
}),
|
||||
onSessionRestart: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await sessionRuntime.restartEmpty();
|
||||
|
||||
@@ -17,14 +17,19 @@
|
||||
// - Auto-approve all (Shift+Tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { test } from "@microsoft/tui-test";
|
||||
import { expect, test } from "@microsoft/tui-test";
|
||||
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js";
|
||||
import { clineEnv } from "../helpers/env.js";
|
||||
import {
|
||||
toggleAutoApproveAll,
|
||||
waitForChatReady,
|
||||
} from "../helpers/page-objects/chat.js";
|
||||
import { expectVisible } from "../helpers/terminal.js";
|
||||
import {
|
||||
expectNotVisible,
|
||||
expectVisible,
|
||||
typeAndSubmit,
|
||||
} from "../helpers/terminal.js";
|
||||
|
||||
test.describe("cline (authenticated) - shows chat view", () => {
|
||||
test.use({
|
||||
@@ -53,3 +58,54 @@ test.describe("Auto-approve all - Shift+Tab toggle", () => {
|
||||
await toggleAutoApproveAll(terminal);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Dialog dismissal - panel is fully removed", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
});
|
||||
|
||||
// The dialog panel's background is @opentui-ui/dialog's DEFAULT_STYLE
|
||||
// (#262626), which xterm reports as this packed 24-bit color.
|
||||
const DIALOG_PANEL_BG = 0x262626;
|
||||
|
||||
const countPanelCells = (terminal: Terminal): number => {
|
||||
let count = 0;
|
||||
for (const shift of terminal.serialize().shifts.values()) {
|
||||
if (shift.bgColor === DIALOG_PANEL_BG) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
// @opentui-ui/dialog is built against @opentui/core ^0.1.69, whose
|
||||
// Renderable.remove(id) took an id. Core 0.4.x renamed it to
|
||||
// remove(child) and throws on a non-renderable argument, so the
|
||||
// package's removeDialog() aborted before detaching its panel — the React
|
||||
// portal content unmounted, but the imperative grey box stayed on screen
|
||||
// over the chat. Asserting on the panel's background (not its text) is what
|
||||
// distinguishes a leaked box from a clean teardown.
|
||||
test("closing the help dialog removes its grey panel", async ({
|
||||
terminal,
|
||||
}) => {
|
||||
await waitForChatReady(terminal);
|
||||
await typeAndSubmit(terminal, "/help");
|
||||
await expectVisible(terminal, "Keyboard Shortcuts");
|
||||
expect(countPanelCells(terminal)).toBeGreaterThan(0);
|
||||
|
||||
terminal.keyEscape();
|
||||
await expectNotVisible(terminal, "Keyboard Shortcuts");
|
||||
|
||||
// The panel unmounts a frame after its content; poll until the
|
||||
// dialog's imperative box is detached rather than sampling once.
|
||||
const deadline = Date.now() + 10_000;
|
||||
let remaining = countPanelCells(terminal);
|
||||
while (remaining > 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
remaining = countPanelCells(terminal);
|
||||
}
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClineAccountCreditsErrorMessage", () => {
|
||||
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the plain human-readable Cline API message", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("Not enough credits available"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the legacy insufficient balance phrasing", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Insufficient balance. Your Cline credits balance is $0.00.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", async () => {
|
||||
const { isClineAccountCreditsErrorMessage } = await import(
|
||||
"./cline-account"
|
||||
);
|
||||
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage(
|
||||
"Your credit balance is too low to access the Anthropic API.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
// The Cline API's 402 response carries `code: "insufficient_credits"` and
|
||||
// the message "Not enough credits available". Depending on how much of the
|
||||
// payload survives error extraction, the CLI may see the raw JSON blob or
|
||||
// just the human-readable message, so match both. The
|
||||
// "insufficient balance" pair is an older backend phrasing kept for safety.
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
normalized.includes("insufficient_credits") ||
|
||||
normalized.includes("not enough credits") ||
|
||||
(normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +158,38 @@ 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;
|
||||
@@ -183,6 +222,7 @@ export async function loadClineAccountSnapshot(input: {
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
@@ -131,7 +134,7 @@ function formatToolParams(
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={f.path}>
|
||||
<span key={`${f.path}:${sl}:${el}`}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
@@ -419,6 +422,86 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactionDividerRow(props: {
|
||||
entry: Extract<ChatEntry, { kind: "compaction" }>;
|
||||
}) {
|
||||
const { entry } = props;
|
||||
const { width: terminalWidth } = useTerminalDimensions();
|
||||
const inProgress = entry.status === "started";
|
||||
const labelColor = inProgress
|
||||
? "cyan"
|
||||
: entry.status === "failed"
|
||||
? "red"
|
||||
: entry.status === "cancelled" || entry.status === "skipped"
|
||||
? "gray"
|
||||
: "cyan";
|
||||
const label = `✻ ${formatCompactionDividerLabel(entry)} ✻`;
|
||||
// Fill the remaining line with a plain rule instead of a flexGrow bordered
|
||||
// box: a single fixed-content text row keeps the renderer's diffing stable.
|
||||
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
{inProgress ? (
|
||||
<box width={2}>
|
||||
<spinner name="dots" color={labelColor} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg="gray" content="── " />
|
||||
)}
|
||||
<text fg={labelColor} selectable content={label} />
|
||||
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Switch to Cline usage-based billing and retry with the Cline provider."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Interactive CLI: </text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="type /model, press tab to change provider, choose Cline, then retry."
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Headless CLI: </text>
|
||||
<text fg={props.defaultFg} selectable content="rerun with " />
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -534,6 +617,15 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -557,6 +649,9 @@ export function ChatEntryView(props: {
|
||||
</box>
|
||||
);
|
||||
|
||||
case "compaction":
|
||||
return <CompactionDividerRow entry={entry} />;
|
||||
|
||||
case "done": {
|
||||
const parts: string[] = [];
|
||||
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
import {
|
||||
getProviderAuthStorageId,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
/**
|
||||
* Persist a manually entered API key for an OAuth-capable provider — the
|
||||
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
|
||||
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
|
||||
* stale token would otherwise keep winning over the manual key.
|
||||
*
|
||||
* The key is written both to the provider's auth storage entry (cline-pass
|
||||
* stores credentials under "cline") and to the provider's own entry: settings
|
||||
* resolution lets a direct entry shadow the storage entry, and provider
|
||||
* switching copies merged settings (including auth) into direct entries, so
|
||||
* both must be updated for the manual key to reliably take effect.
|
||||
*/
|
||||
export function saveManualProviderApiKey(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): void {
|
||||
// Empty strings delete these keys from the stored auth object.
|
||||
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId: storageProviderId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
if (
|
||||
providerId !== storageProviderId &&
|
||||
manager.read().providers[providerId]
|
||||
) {
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isProviderConfigured,
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
@@ -16,3 +27,99 @@ describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveManualProviderApiKey", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createManager(): ProviderSettingsManager {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
|
||||
tempDirs.push(dir);
|
||||
return new ProviderSettingsManager({
|
||||
filePath: join(dir, "providers.json"),
|
||||
});
|
||||
}
|
||||
|
||||
it("clears stored OAuth tokens so the manual key takes effect", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
accountId: "acct_123",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline", "manual-api-key");
|
||||
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
expect(settings?.apiKey).toBe("manual-api-key");
|
||||
expect(settings?.auth?.accessToken).toBeUndefined();
|
||||
expect(settings?.auth?.refreshToken).toBeUndefined();
|
||||
expect(settings?.auth?.accountId).toBe("acct_123");
|
||||
expect(getPersistedProviderApiKey("cline", settings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline", settings)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves cline-pass keys to the shared cline auth storage entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
// cline-pass inherits auth storage from the "cline" entry, so the key
|
||||
// must land there and the stale tokens must be gone for both providers.
|
||||
const clineSettings = manager.getProviderSettings("cline");
|
||||
expect(clineSettings?.apiKey).toBe("manual-api-key");
|
||||
expect(clineSettings?.auth?.accessToken).toBeUndefined();
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears stale credentials copied into a direct cline-pass entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
// Provider switching copies the merged settings (including auth) into
|
||||
// a direct cline-pass entry, which shadows the shared "cline" entry.
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline-pass",
|
||||
apiKey: "stale-copied-key",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -724,13 +727,27 @@ export function CodexCliStatusContent(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `true` on successful login, `"use_api_key"` when the user opts
|
||||
* into manual API key entry (only offered with `allowApiKeyFallback`).
|
||||
*/
|
||||
export type OAuthLoginResult = boolean | "use_api_key";
|
||||
|
||||
export function OAuthLoginContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
props: ChoiceContext<OAuthLoginResult> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
allowApiKeyFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerId, providerName } = props;
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -863,9 +880,19 @@ export function OAuthLoginContent(
|
||||
if (key.name === "escape") {
|
||||
cancelAuthAttempt();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "k" && allowApiKeyFallback) {
|
||||
cancelAuthAttempt();
|
||||
resolve("use_api_key");
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
@@ -892,8 +919,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -915,8 +942,83 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual API key entry for OAuth-capable providers — the escape hatch for
|
||||
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
|
||||
* the manual key takes effect (see saveManualProviderApiKey).
|
||||
*/
|
||||
export function OAuthApiKeyInputContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
resolve,
|
||||
dismiss,
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const apiKey = value.trim();
|
||||
if (!apiKey) return;
|
||||
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
submit();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Esc to cancel</em>
|
||||
Use an API key from your Cline dashboard instead of OAuth login. This
|
||||
replaces any saved login tokens.
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">API key</text>
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<input
|
||||
value={value}
|
||||
onInput={setValue}
|
||||
placeholder="Paste your API key"
|
||||
flexGrow={1}
|
||||
focused
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Enter to save, Esc to go back</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: ClineModelPickerTier;
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
|
||||
ClineModelPickerTier,
|
||||
string
|
||||
> = {
|
||||
recommended: "Recommended",
|
||||
subscribed: "Subscribed",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
// Featured entries for the sectioned picker, keyed by provider: cline gets
|
||||
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
|
||||
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
|
||||
export function buildFeaturedModelEntries(
|
||||
providerId: string,
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
return providerId === "cline-pass"
|
||||
? buildClinePassModelEntries(data)
|
||||
: buildClineModelEntries(data);
|
||||
}
|
||||
|
||||
function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Shown under the Free section header when picking a model for ClinePass
|
||||
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
|
||||
"Try with limited usage, separate from ClinePass quota.";
|
||||
|
||||
// ClinePass shows the subscription's models plus the Cline free models — both
|
||||
// providers hit the same Cline API, so free models are selectable in place
|
||||
// (they ride usage billing at $0 instead of the subscription quota).
|
||||
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
|
||||
// the ClinePass catalog contains exactly these two buckets, so the sections
|
||||
// already list every selectable model. An empty clinePass bucket means the
|
||||
// fetch fell back to the bundled list (which has no pass models) — without an
|
||||
// escape into the full catalog a subscriber could only pick free models, so
|
||||
// browse-all comes back in that degraded mode.
|
||||
function buildClinePassModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.clinePass) {
|
||||
entries.push({ kind: "model", model: m, tier: "subscribed" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
if (data.clinePass.length === 0) {
|
||||
entries.push({ kind: "browse" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The quota explainer only makes sense in the ClinePass picker, which is the
|
||||
// only picker that has a "subscribed" section
|
||||
export function freeTierDescriptionFor(
|
||||
entries: ClineModelPickerEntry[],
|
||||
): string | undefined {
|
||||
const isClinePassPicker = entries.some(
|
||||
(entry) => entry.kind === "model" && entry.tier === "subscribed",
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
|
||||
describe("cline model picker entries", () => {
|
||||
it("builds Recommended/Free sections for the cline provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("anthropic/claude-sonnet-5"),
|
||||
tier: "recommended",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds Subscribed/Free sections for the cline-pass provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
|
||||
{
|
||||
kind: "model",
|
||||
model: model("cline-pass/kimi-k2.6"),
|
||||
tier: "subscribed",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds the browse-all escape when the clinePass bucket is empty", () => {
|
||||
// The fetch fell back to the bundled list (no pass models); the sections
|
||||
// alone would leave a subscriber able to pick only free models.
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
|
||||
const data = {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
|
||||
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
@@ -9,20 +8,23 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
export {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerBrowse,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
@@ -39,12 +41,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
@@ -68,20 +71,6 @@ export function useClineRecommendedModels() {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function ClineModelPicker(props: {
|
||||
entries: ClineModelPickerEntry[];
|
||||
selected: number;
|
||||
@@ -103,6 +92,7 @@ export function ClineModelPicker(props: {
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const rows: ReactNode[] = [];
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -112,14 +102,20 @@ export function ClineModelPicker(props: {
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
|
||||
rows.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
{entry.tier === "free" && freeTierDescription && (
|
||||
<text fg="gray">
|
||||
<em>{freeTierDescription}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
@@ -29,12 +34,13 @@ function resolveDisplayName(
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
@@ -62,11 +68,13 @@ export function ClineModelSelectorContent(
|
||||
key: string;
|
||||
kind: "header" | "model" | "browse";
|
||||
label: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}[] = [];
|
||||
let lastTier: string | null = null;
|
||||
const freeTierDescription = freeTierDescriptionFor(entries);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
@@ -76,7 +84,9 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
|
||||
description:
|
||||
entry.tier === "free" ? freeTierDescription : undefined,
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
@@ -156,8 +166,18 @@ export function ClineModelSelectorContent(
|
||||
if (row.kind === "header") {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
marginTop={isFirst ? 0 : 1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg="gray">{row.label}</text>
|
||||
{row.description && (
|
||||
<text fg="gray">
|
||||
<em>{row.description}</em>
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenConfigOptions } from "./use-config-panel";
|
||||
|
||||
export interface LocalSlashCommandActionInput {
|
||||
name: string;
|
||||
isRunning: boolean;
|
||||
openAccount: () => void;
|
||||
openConfig: (options?: OpenConfigOptions) => void;
|
||||
openMcpManager: () => Promise<boolean>;
|
||||
@@ -46,7 +47,12 @@ export function runLocalSlashCommandAction(
|
||||
return true;
|
||||
}
|
||||
if (normalized === "compact") {
|
||||
input.runCompact();
|
||||
// Autocomplete can invoke local commands while a turn is running. Keep
|
||||
// /compact handled, but do not let it take ownership of the active turn's
|
||||
// shared running state.
|
||||
if (!input.isRunning) {
|
||||
input.runCompact();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (normalized === "fork") {
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type AccountDialogAction,
|
||||
AccountDialogContent,
|
||||
} from "../components/dialogs/account-dialog";
|
||||
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
|
||||
import {
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export function useAccountDialog(opts: {
|
||||
@@ -60,14 +63,14 @@ export function useAccountDialog(opts: {
|
||||
return;
|
||||
}
|
||||
if (action === "login") {
|
||||
const saved = await dialog.choice<boolean>({
|
||||
const saved = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
if (saved === true) {
|
||||
await onAccountChange?.();
|
||||
await openAccountDialog();
|
||||
return;
|
||||
|
||||
@@ -6,13 +6,14 @@ import type {
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveStatusNoticeLabel } from "../../utils/events";
|
||||
import { resolveNonCompactionStatusLabel } from "../../utils/events";
|
||||
import {
|
||||
formatToolInput,
|
||||
formatToolOutput,
|
||||
truncate,
|
||||
} from "../../utils/helpers";
|
||||
import type { ChatEntry, InlineStream, TuiProps } from "../types";
|
||||
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
|
||||
|
||||
interface AgentEventDeps {
|
||||
appendEntry: (entry: ChatEntry) => void;
|
||||
@@ -32,6 +33,7 @@ interface AgentEventDeps {
|
||||
}
|
||||
|
||||
export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
const openCompactionEntryRef = useRef(false);
|
||||
const {
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
@@ -45,6 +47,47 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
verbose,
|
||||
} = deps;
|
||||
|
||||
// Compaction dividers that arrived while an assistant message was still
|
||||
// streaming. Appending them immediately would split the message in two, so
|
||||
// they are held until the active content block closes (or the turn ends).
|
||||
const pendingCompactionEntriesRef = useRef<
|
||||
Extract<ChatEntry, { kind: "compaction" }>[]
|
||||
>([]);
|
||||
|
||||
const flushPendingCompactionEntries = useCallback(() => {
|
||||
const pending = pendingCompactionEntriesRef.current;
|
||||
if (pending.length === 0) return;
|
||||
pendingCompactionEntriesRef.current = [];
|
||||
for (const entry of pending) {
|
||||
if (entry.status !== "started" && openCompactionEntryRef.current) {
|
||||
updateEntry((current) =>
|
||||
current.kind === "compaction" && current.status === "started"
|
||||
? { ...current, ...entry }
|
||||
: current,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry(entry);
|
||||
if (entry.status === "started") {
|
||||
openCompactionEntryRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [appendEntry, updateEntry]);
|
||||
|
||||
const finalizeDanglingCompactionEntry = useCallback(
|
||||
(status: "failed" | "cancelled") => {
|
||||
if (!openCompactionEntryRef.current) return;
|
||||
openCompactionEntryRef.current = false;
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status }
|
||||
: entry,
|
||||
);
|
||||
},
|
||||
[updateEntry],
|
||||
);
|
||||
|
||||
const closeToolEntry = useCallback(
|
||||
(event: AgentEvent & { type: "content_end" }) => {
|
||||
const error = event.error ?? undefined;
|
||||
@@ -84,9 +127,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(true);
|
||||
setIsStreaming(true);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "iteration_end":
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
break;
|
||||
case "content_start": {
|
||||
setIsStreaming(false);
|
||||
@@ -165,11 +210,15 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("cancelled");
|
||||
break;
|
||||
case "error":
|
||||
setIsRunning(false);
|
||||
setIsStreaming(false);
|
||||
closeInlineStream();
|
||||
flushPendingCompactionEntries();
|
||||
finalizeDanglingCompactionEntry("failed");
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
@@ -181,8 +230,40 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
break;
|
||||
case "notice":
|
||||
if (event.displayRole === "status") {
|
||||
closeInlineStream();
|
||||
const label = resolveStatusNoticeLabel(event);
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (!compaction) {
|
||||
closeInlineStream();
|
||||
}
|
||||
if (compaction) {
|
||||
if (activeInlineStreamRef.current) {
|
||||
// An assistant message is still streaming; appending now
|
||||
// would split it around the divider. Hold the divider (final
|
||||
// state until the content block closes, then reconcile it
|
||||
// with the same open divider atomically.
|
||||
pendingCompactionEntriesRef.current.push({
|
||||
kind: "compaction",
|
||||
...compaction,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (compaction.status === "started") {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
openCompactionEntryRef.current = true;
|
||||
} else if (openCompactionEntryRef.current) {
|
||||
// Finalize the in-progress divider in place, wherever it
|
||||
// sits in the transcript.
|
||||
updateEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, ...compaction }
|
||||
: entry,
|
||||
);
|
||||
openCompactionEntryRef.current = false;
|
||||
} else {
|
||||
appendEntry({ kind: "compaction", ...compaction });
|
||||
}
|
||||
break;
|
||||
}
|
||||
const label = resolveNonCompactionStatusLabel(event);
|
||||
if (label) {
|
||||
appendEntry({ kind: "status", text: label });
|
||||
}
|
||||
@@ -200,6 +281,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
[
|
||||
appendEntry,
|
||||
updateLastEntry,
|
||||
updateEntry,
|
||||
closeInlineStream,
|
||||
activeInlineStreamRef,
|
||||
setIsRunning,
|
||||
@@ -208,6 +290,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
closeToolEntry,
|
||||
finalizeDanglingCompactionEntry,
|
||||
flushPendingCompactionEntries,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ function makeActions(
|
||||
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
|
||||
): Omit<LocalSlashCommandActionInput, "name"> {
|
||||
return {
|
||||
isRunning: false,
|
||||
openAccount: vi.fn(),
|
||||
openConfig: vi.fn(),
|
||||
openMcpManager: vi.fn(async () => false),
|
||||
@@ -58,6 +59,32 @@ describe("runLocalSlashCommandAction", () => {
|
||||
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
|
||||
});
|
||||
|
||||
it("does not start compaction while a turn is running", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ isRunning: true, runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts compaction while the session is idle", () => {
|
||||
const runCompact = vi.fn();
|
||||
const actions = makeActions({ runCompact });
|
||||
|
||||
const handled = runLocalSlashCommandAction({
|
||||
name: "compact",
|
||||
...actions,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runCompact).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("waits for clear to reset the runtime session", async () => {
|
||||
let resolveClear: (() => void) | undefined;
|
||||
const clearConversation = vi.fn(
|
||||
@@ -161,7 +188,7 @@ describe("formatCompactionStatus", () => {
|
||||
messagesAfter: 300,
|
||||
compacted: true,
|
||||
}),
|
||||
).toBe("Compacted context; message count stayed at 300.");
|
||||
).toBe("Compacted context; message count stayed at 300 messages.");
|
||||
});
|
||||
|
||||
it("reports empty sessions separately", () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { useSession } from "../contexts/session-context";
|
||||
import type { AppView, TuiProps } from "../types";
|
||||
import { formatCompactionStatus } from "../utils/compaction-status";
|
||||
import { hydrateSessionMessages } from "../utils/hydrate-messages";
|
||||
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
|
||||
import { HistoryDialogContent } from "../views/history-view";
|
||||
@@ -116,21 +115,42 @@ export function useLocalCommandActions(input: {
|
||||
}, [dialog, refocusTextarea, termHeight]);
|
||||
|
||||
const runCompact = useCallback(async () => {
|
||||
session.setIsRunning(true);
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: "Compacting context...",
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
});
|
||||
try {
|
||||
const result = await onCompact();
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "status",
|
||||
text: formatCompactionStatus(result),
|
||||
}));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? {
|
||||
...entry,
|
||||
status: result.compacted ? "completed" : "skipped",
|
||||
messagesBefore: result.messagesBefore,
|
||||
messagesAfter:
|
||||
result.workingContextMessagesAfter ?? result.messagesAfter,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
} catch (error) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
const cancelled =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || /abort/i.test(error.message));
|
||||
session.updateLastEntry((entry) =>
|
||||
entry.kind === "compaction" && entry.status === "started"
|
||||
? { ...entry, status: cancelled ? "cancelled" : "failed" }
|
||||
: entry,
|
||||
);
|
||||
if (!cancelled) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
session.setIsRunning(false);
|
||||
}
|
||||
}, [onCompact, session]);
|
||||
|
||||
@@ -159,6 +179,15 @@ export function useLocalCommandActions(input: {
|
||||
kind: "status",
|
||||
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
|
||||
}));
|
||||
if (result.carriedWorkingContext) {
|
||||
session.appendEntry({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: result.carriedWorkingContext.canonicalMessages,
|
||||
messagesAfter: result.carriedWorkingContext.workingContextMessages,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
session.updateLastEntry(() => ({
|
||||
kind: "error",
|
||||
@@ -181,6 +210,7 @@ export function useLocalCommandActions(input: {
|
||||
}
|
||||
return runLocalSlashCommandAction({
|
||||
name: resolved.name,
|
||||
isRunning: session.isRunning,
|
||||
invocation,
|
||||
openAccount,
|
||||
openConfig,
|
||||
@@ -209,6 +239,7 @@ export function useLocalCommandActions(input: {
|
||||
openSkills,
|
||||
runCompact,
|
||||
runFork,
|
||||
session.isRunning,
|
||||
slashCommandRegistry,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
refreshProviderModelsFromSource,
|
||||
resolveProviderConfig,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
UseExistingOrReconfigureContent,
|
||||
} from "../components/dialogs/provider-picker";
|
||||
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
@@ -79,6 +82,51 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
|
||||
* using the provider's stored API key and headers, mirroring the extension's
|
||||
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
|
||||
* to manual model-id entry.
|
||||
*/
|
||||
async function fetchOpenAiCompatibleModelIds(
|
||||
providerId: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const config = manager.getProviderConfig(providerId, {
|
||||
includeKnownModels: false,
|
||||
});
|
||||
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
|
||||
if (!baseUrl || !URL.canParse(baseUrl)) return [];
|
||||
|
||||
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
|
||||
const apiKey = config?.apiKey?.trim();
|
||||
if (
|
||||
apiKey &&
|
||||
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
|
||||
) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const list = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const ids = list
|
||||
.map((model) => {
|
||||
const id = (model as { id?: unknown } | null)?.id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return [...new Set(ids)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function providerToExistingProviderOptions(input: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -131,6 +179,23 @@ async function runProviderChange(
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
|
||||
// Manual API key entry is the escape hatch for when OAuth login isn't
|
||||
// working; only the Cline providers accept a dashboard API key.
|
||||
const supportsManualApiKey = isClineProvider(newProviderId);
|
||||
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
|
||||
await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<OAuthApiKeyInputContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
providerSettingsManager={manager}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
let needsAuth = true;
|
||||
if (isProviderConfigured(newProviderId, existingSettings)) {
|
||||
let option: ExistingProviderOption | undefined;
|
||||
@@ -165,17 +230,22 @@ async function runProviderChange(
|
||||
if (needsAuth) {
|
||||
let saved: boolean | undefined;
|
||||
if (isOAuthProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
const loginResult = await dialog.choice<OAuthLoginResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
|
||||
<OAuthLoginContent
|
||||
{...ctx}
|
||||
providerId={newProviderId}
|
||||
providerName={displayName}
|
||||
allowApiKeyFallback={supportsManualApiKey}
|
||||
/>
|
||||
),
|
||||
});
|
||||
saved =
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
@@ -275,12 +345,28 @@ export function useModelSelector(opts: {
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
let providerDisplayName = config.providerId;
|
||||
let endpointModelOptions: ModelOption[] = [];
|
||||
|
||||
const refreshProviderContext = async () => {
|
||||
modelOptions = buildModelOptions(
|
||||
config.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
providerDisplayName = await getProviderDisplayName(config.providerId);
|
||||
// Free-text providers (openai-compatible) can still suggest model
|
||||
// ids when their endpoint answers /models; otherwise they keep the
|
||||
// manual input.
|
||||
endpointModelOptions = usesModelIdInput(config.providerId)
|
||||
? buildModelOptions(
|
||||
Object.fromEntries(
|
||||
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
|
||||
(id) => [id, { id, name: id }],
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (endpointModelOptions.length > 0) {
|
||||
modelOptions = endpointModelOptions;
|
||||
}
|
||||
};
|
||||
|
||||
if (!options?.startWithProviderChange) {
|
||||
@@ -316,7 +402,10 @@ export function useModelSelector(opts: {
|
||||
let pickingModel = true;
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
if (
|
||||
usesModelIdInput(config.providerId) &&
|
||||
endpointModelOptions.length === 0
|
||||
) {
|
||||
const modelId = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -341,7 +430,13 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.providerId === "cline") {
|
||||
if (
|
||||
config.providerId === "cline" ||
|
||||
config.providerId === "cline-pass"
|
||||
) {
|
||||
// ClinePass gets the same sectioned picker with Subscribed/Free
|
||||
// sections — free models are selectable while staying on ClinePass
|
||||
const featuredProviderId = config.providerId;
|
||||
const clineResult = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
@@ -351,7 +446,10 @@ export function useModelSelector(opts: {
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
buildFeaturedModelEntries(
|
||||
featuredProviderId,
|
||||
await fetchClineRecommendedModels(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type TerminalTitleRenderer,
|
||||
useTerminalTitle,
|
||||
} from "./use-terminal-title";
|
||||
|
||||
const reactMock = vi.hoisted(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
return {
|
||||
cleanups,
|
||||
// Run effect bodies now, but retain their cleanups so each test can move
|
||||
// the renderer across the native destruction boundary before unmount.
|
||||
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) {
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: reactMock.useEffect,
|
||||
}));
|
||||
|
||||
function createTitleRenderer() {
|
||||
let destroyed = false;
|
||||
const setTerminalTitle = vi.fn(() => {
|
||||
if (destroyed) {
|
||||
throw new Error("setTerminalTitle called after renderer destruction");
|
||||
}
|
||||
});
|
||||
const renderer: TerminalTitleRenderer = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
setTerminalTitle,
|
||||
};
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
renderer,
|
||||
setTerminalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactMock.cleanups.length = 0;
|
||||
reactMock.useEffect.mockClear();
|
||||
});
|
||||
|
||||
describe("useTerminalTitle", () => {
|
||||
it("sets and resets the title while the renderer is active", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
|
||||
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
|
||||
});
|
||||
|
||||
it("does not set the title when its effect runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
titleRenderer.destroy();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset the title when cleanup runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
|
||||
titleRenderer.destroy();
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TerminalTitleRenderer {
|
||||
readonly isDestroyed: boolean;
|
||||
setTerminalTitle(title: string): void;
|
||||
}
|
||||
|
||||
export function useTerminalTitle(
|
||||
renderer: TerminalTitleRenderer,
|
||||
terminalTitle: string,
|
||||
): void {
|
||||
// setTerminalTitle writes into memory owned by the native renderer, so it
|
||||
// must never run after destroy. React can flush passive effects after the
|
||||
// renderer's memory has been freed.
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const rendererMock = vi.hoisted(() => ({
|
||||
defaultBackground: null,
|
||||
defaultForeground: null,
|
||||
})),
|
||||
isDestroyed: false,
|
||||
on: vi.fn(),
|
||||
setTerminalTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
const rootMock = vi.hoisted(() => ({
|
||||
@@ -37,7 +39,9 @@ describe("renderOpenTui", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
destroyHandlers.length = 0;
|
||||
rendererMock.isDestroyed = false;
|
||||
rendererMock.destroy.mockReset();
|
||||
rendererMock.setTerminalTitle.mockReset();
|
||||
rendererMock.on.mockReset();
|
||||
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
|
||||
if (event === "destroy") {
|
||||
@@ -96,4 +100,37 @@ describe("renderOpenTui", () => {
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the terminal title before destroying the renderer", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
|
||||
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
|
||||
const titleCallOrder =
|
||||
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
|
||||
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
|
||||
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
|
||||
});
|
||||
|
||||
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
|
||||
const { renderOpenTui } = await import("./index");
|
||||
const tui = await renderOpenTui({} as TuiProps);
|
||||
|
||||
tui.destroy();
|
||||
// Simulate OpenTUI's own signal handler destroying the renderer in the
|
||||
// same dispatch (e.g. an idle SIGTERM fires both our handler and
|
||||
// OpenTUI's exitHandler before microtasks drain).
|
||||
rendererMock.isDestroyed = true;
|
||||
for (const handler of destroyHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function renderOpenTui(
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
// Reset the title while the native renderer is still alive; the
|
||||
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
|
||||
// Re-check here: OpenTUI's own signal handlers can destroy the
|
||||
// renderer between destroy() queuing this microtask and it running
|
||||
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const raw = readFileSyncStrippingUtf8Bom(filePath);
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
|
||||
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import type { AppView, TuiProps } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
@@ -472,15 +473,7 @@ function App(props: TuiProps) {
|
||||
};
|
||||
}, [renderer, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
renderer.setTerminalTitle("");
|
||||
};
|
||||
}, [renderer]);
|
||||
useTerminalTitle(renderer, terminalTitle);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -44,6 +44,15 @@ export type ChatEntry = (
|
||||
}
|
||||
| { kind: "error"; text: string }
|
||||
| { kind: "status"; text: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
compactionMode: "auto" | "manual" | "inherited";
|
||||
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
messagesBefore?: number;
|
||||
messagesAfter?: number;
|
||||
}
|
||||
| { kind: "team"; text: string }
|
||||
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
|
||||
| {
|
||||
@@ -90,6 +99,7 @@ export interface ResumedSessionResult {
|
||||
export interface InteractiveCompactionResult {
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
@@ -185,7 +195,15 @@ export interface TuiProps {
|
||||
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
|
||||
onCompact: () => Promise<InteractiveCompactionResult>;
|
||||
onFork: () => Promise<
|
||||
{ forkedFromSessionId: string; newSessionId: string } | undefined
|
||||
| {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
formatTokenCount,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "./compaction-status";
|
||||
|
||||
describe("parseCompactionNoticeMetadata", () => {
|
||||
it("extracts a divider entry from a completed auto-compaction notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
reason: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toEqual({
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a streaming divider entry from a started notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "started",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "started" });
|
||||
});
|
||||
|
||||
it("maps manual compaction notices to manual mode", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "manual_compaction",
|
||||
phase: "completed",
|
||||
})?.compactionMode,
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("maps a benign no-result terminal notice to skipped", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "skipped",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "skipped" });
|
||||
});
|
||||
|
||||
it("ignores non-compaction metadata", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
|
||||
).toBeUndefined();
|
||||
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-numeric counters instead of rendering garbage", () => {
|
||||
const parsed = parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: "25000",
|
||||
tokensAfter: Number.NaN,
|
||||
});
|
||||
expect(parsed?.tokensBefore).toBeUndefined();
|
||||
expect(parsed?.tokensAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats counts into compact units", () => {
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
expect(formatTokenCount(6_300)).toBe("6.3k");
|
||||
expect(formatTokenCount(25_000)).toBe("25k");
|
||||
expect(formatTokenCount(1_200_000)).toBe("1.2M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactionDividerLabel", () => {
|
||||
it("includes token and message deltas when present", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
|
||||
});
|
||||
|
||||
it("labels in-progress compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Auto compacting messages");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Compacting messages");
|
||||
});
|
||||
|
||||
it("labels failed and cancelled compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "failed",
|
||||
}),
|
||||
).toBe("Compaction failed");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "cancelled",
|
||||
}),
|
||||
).toBe("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("labels skipped compaction without calling it cancelled", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "skipped",
|
||||
}),
|
||||
).toBe("Compaction skipped");
|
||||
});
|
||||
|
||||
it("labels inherited working context from forks and restarts", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: 60,
|
||||
messagesAfter: 15,
|
||||
}),
|
||||
).toBe("Compacted working context carried over · 60 → 15 messages");
|
||||
});
|
||||
|
||||
it("labels manual compaction and omits missing counters", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "completed",
|
||||
}),
|
||||
).toBe("Context compacted (manual)");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,105 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
import type { ChatEntry, InteractiveCompactionResult } from "../types";
|
||||
|
||||
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a compaction divider entry from a status notice's metadata.
|
||||
* "started" notices produce a streaming (in-progress) divider; "completed"
|
||||
* notices produce the final divider with counters. Returns undefined for
|
||||
* non-compaction notices.
|
||||
*/
|
||||
export function parseCompactionNoticeMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Omit<CompactionDividerEntry, "kind"> | undefined {
|
||||
if (
|
||||
!metadata ||
|
||||
(metadata.phase !== "started" &&
|
||||
metadata.phase !== "completed" &&
|
||||
metadata.phase !== "skipped")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = metadata.kind ?? metadata.reason;
|
||||
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
|
||||
return undefined;
|
||||
}
|
||||
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
|
||||
if (metadata.phase === "started") {
|
||||
return { compactionMode, status: "started" };
|
||||
}
|
||||
if (metadata.phase === "skipped") {
|
||||
return { compactionMode, status: "skipped" };
|
||||
}
|
||||
return {
|
||||
compactionMode,
|
||||
status: "completed",
|
||||
tokensBefore: asFiniteNumber(metadata.tokensBefore),
|
||||
tokensAfter: asFiniteNumber(metadata.tokensAfter),
|
||||
messagesBefore: asFiniteNumber(metadata.messagesBefore),
|
||||
messagesAfter: asFiniteNumber(metadata.messagesAfter),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1_000) {
|
||||
return `${count}`;
|
||||
}
|
||||
if (count < 1_000_000) {
|
||||
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
}
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
|
||||
export function formatCompactionDividerLabel(
|
||||
entry: CompactionDividerEntry,
|
||||
): string {
|
||||
if (entry.status === "started") {
|
||||
return entry.compactionMode === "manual"
|
||||
? "Compacting messages"
|
||||
: "Auto compacting messages";
|
||||
}
|
||||
if (entry.status === "failed") {
|
||||
return "Compaction failed";
|
||||
}
|
||||
if (entry.status === "cancelled") {
|
||||
return "Compaction cancelled";
|
||||
}
|
||||
if (entry.status === "skipped") {
|
||||
return "Compaction skipped";
|
||||
}
|
||||
const parts: string[] = [
|
||||
entry.compactionMode === "manual"
|
||||
? "Context compacted (manual)"
|
||||
: entry.compactionMode === "inherited"
|
||||
? "Compacted working context carried over"
|
||||
: "Context compacted",
|
||||
];
|
||||
if (
|
||||
typeof entry.tokensBefore === "number" &&
|
||||
typeof entry.tokensAfter === "number"
|
||||
) {
|
||||
parts.push(
|
||||
`${formatTokenCount(entry.tokensBefore)} → ${formatTokenCount(entry.tokensAfter)} tokens`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof entry.messagesBefore === "number" &&
|
||||
typeof entry.messagesAfter === "number"
|
||||
) {
|
||||
parts.push(`${entry.messagesBefore} → ${entry.messagesAfter} messages`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
@@ -9,8 +110,11 @@ export function formatCompactionStatus(
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
loadIndividualSubscriptionPlansFromProviderSettings,
|
||||
} from "../../cline-account";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildFeaturedModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
@@ -206,11 +206,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
|
||||
// Cline featured model picker
|
||||
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
|
||||
const recommended = useClineRecommendedModels();
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
() =>
|
||||
recommended.data
|
||||
? buildFeaturedModelEntries(activeProviderId, recommended.data)
|
||||
: [],
|
||||
[recommended.data, activeProviderId],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
@@ -221,20 +224,37 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLocalProviderModels("cline")
|
||||
.then(({ models }) => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of models) {
|
||||
// The featured picker serves both cline and cline-pass, so pool reasoning
|
||||
// support and display names from both catalogs
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
getLocalProviderModels(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const ids = new Set<string>();
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
for (const m of result.value.models) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
})
|
||||
.catch(() => {});
|
||||
resolveProviderConfig("cline")
|
||||
.then((resolved) => {
|
||||
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setClineModelReasoningIds(ids);
|
||||
});
|
||||
void Promise.allSettled(
|
||||
["cline", "cline-pass"].map((providerId) =>
|
||||
resolveProviderConfig(providerId),
|
||||
),
|
||||
).then((results) => {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value?.knownModels) {
|
||||
Object.assign(merged, result.value.knownModels);
|
||||
}
|
||||
}
|
||||
if (Object.keys(merged).length > 0) {
|
||||
setClineKnownModels(merged);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Thinking level
|
||||
|
||||
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,5 +207,6 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
// ClinePass uses the featured picker too, with Subscribed/Free sections
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ChatCommandState = {
|
||||
export type ForkSessionResult = {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
/**
|
||||
* Present when the source session had valid compaction state that was
|
||||
* re-anchored onto the forked session, so the UI can surface why the
|
||||
* next request is smaller than the canonical history.
|
||||
*/
|
||||
carriedWorkingContext?: {
|
||||
workingContextMessages: number;
|
||||
canonicalMessages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MuteCommandInput = {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const detail =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
|
||||
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClinePassLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Switch to Cline usage-based billing",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
@@ -24,6 +27,18 @@ export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getCliClinePassLimitMessage(message: string): string {
|
||||
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
|
||||
const lines = [
|
||||
"ClinePass limit reached",
|
||||
detail,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
];
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
);
|
||||
}
|
||||
|
||||
export function getClinePassLimitDetailMessage(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
return extractClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClinePassLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClinePassLimitError" ||
|
||||
isClinePassLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -15,31 +15,29 @@ function createConfig(compaction?: Config["compaction"]): Config {
|
||||
}
|
||||
|
||||
describe("CLI compaction mode helpers", () => {
|
||||
it("defaults enabled compaction to basic truncation", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
|
||||
it("defaults enabled compaction to agentic summarization", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
|
||||
expect(getCliCompactionMode(createConfig())).toBe(
|
||||
DEFAULT_CLI_COMPACTION_MODE,
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
|
||||
"Truncation",
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
const config = createConfig({ enabled: true, maxInputTokens: 123 });
|
||||
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
|
||||
|
||||
applyCliCompactionMode(config, "basic");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("basic");
|
||||
|
||||
applyCliCompactionMode(config, "off");
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: false,
|
||||
maxInputTokens: 123,
|
||||
preserveRecentTokens: 123,
|
||||
});
|
||||
expect(getCliCompactionMode(config)).toBe("off");
|
||||
});
|
||||
@@ -47,7 +45,6 @@ describe("CLI compaction mode helpers", () => {
|
||||
it("builds default and explicit core compaction config", () => {
|
||||
expect(buildCliCompactionConfig()).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
});
|
||||
expect(buildCliCompactionConfig("agentic")).toEqual({
|
||||
enabled: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
|
||||
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
|
||||
CliCompactionMode,
|
||||
"agentic" | "basic"
|
||||
> = "basic";
|
||||
> = "agentic";
|
||||
|
||||
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
|
||||
agentic: "agentic",
|
||||
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
|
||||
} as const satisfies Record<CliCompactionMode, string>;
|
||||
|
||||
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
|
||||
"Context compaction mode: agentic|basic|off (default: basic)";
|
||||
"Context compaction mode: agentic|basic|off (default: agentic)";
|
||||
|
||||
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
|
||||
|
||||
@@ -31,8 +31,11 @@ export function parseCliCompactionMode(
|
||||
}
|
||||
|
||||
export function buildCliCompactionConfig(
|
||||
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
|
||||
mode?: CliCompactionMode,
|
||||
): NonNullable<Config["compaction"]> {
|
||||
if (mode === undefined) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (mode === "off") {
|
||||
return { enabled: false };
|
||||
}
|
||||
@@ -43,9 +46,7 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
|
||||
if (config.compaction?.enabled === false) {
|
||||
return "off";
|
||||
}
|
||||
return config.compaction?.strategy === "agentic"
|
||||
? "agentic"
|
||||
: DEFAULT_CLI_COMPACTION_MODE;
|
||||
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
|
||||
}
|
||||
|
||||
export function applyCliCompactionMode(
|
||||
|
||||
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
|
||||
export async function prepareCliEnterpriseIntegration(
|
||||
input: ClineCoreStartInput,
|
||||
) {
|
||||
const workspacePath =
|
||||
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
|
||||
if (!workspacePath) {
|
||||
return undefined;
|
||||
}
|
||||
const bundle = await loadCliRemoteConfigBundle();
|
||||
if (!bundle) {
|
||||
return undefined;
|
||||
}
|
||||
captureRemoteConfigInitialized(bundle);
|
||||
return prepareRemoteConfigCoreIntegration({
|
||||
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
|
||||
workspacePath,
|
||||
pluginName: "enterprise",
|
||||
controlPlane: {
|
||||
name: "cline-account",
|
||||
|
||||
@@ -1,19 +1,64 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
describe("resolveStatusNoticeLabel", () => {
|
||||
it("maps compaction status reasons to stable labels", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "auto-compacting",
|
||||
reason: "auto_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("auto-compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "manual",
|
||||
reason: "manual_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction-budget-adjusted",
|
||||
reason: "compaction_budget_emergency",
|
||||
} as AgentEvent),
|
||||
).toBe("context budget adjusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
beforeEach(() => {
|
||||
output = "";
|
||||
errorOutput = "";
|
||||
setCurrentOutputMode("text");
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
errorOutput += String(chunk);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
errorOutput += `${args.map(String).join(" ")}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a ⎿ before text that follows a tool block", () => {
|
||||
@@ -160,6 +205,23 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toContain("── aborted (2 iterations) ──");
|
||||
});
|
||||
|
||||
it("formats ClinePass limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("ClinePass limit reached");
|
||||
expect(errorOutput).toContain("Switch to Cline usage-based billing");
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "../tui/utils/compaction-status";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -27,8 +32,31 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
const compaction = parseCompactionNoticeMetadata(event.metadata);
|
||||
if (compaction) {
|
||||
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
|
||||
}
|
||||
return resolveNonCompactionStatusLabel(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a status notice already known not to be a compaction notice.
|
||||
* Callers that have parsed the compaction metadata themselves use this to
|
||||
* avoid re-parsing.
|
||||
*/
|
||||
export function resolveNonCompactionStatusLabel(
|
||||
event: AgentEvent,
|
||||
): string | undefined {
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
switch (event.reason) {
|
||||
case "auto_compaction":
|
||||
return "auto-compacting";
|
||||
case "manual_compaction":
|
||||
return "compacting";
|
||||
case "compaction_budget_emergency":
|
||||
return "context budget adjusted";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
@@ -176,7 +204,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(event.error.message);
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -53,6 +53,37 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("zeros cost of free models selected on the cline-pass provider", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// subscription (cline-pass/...) models are not in the free bucket
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -73,7 +73,9 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
// Free models are also selectable on ClinePass — they ride usage billing at $0
|
||||
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
|
||||
return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildCliSubcommandCommand } from "./internal-launch";
|
||||
|
||||
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
|
||||
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
|
||||
|
||||
export function configureCliHubDashboardLaunchEnvironment(): void {
|
||||
if (
|
||||
process.env[DASHBOARD_LAUNCHER_ENV]?.trim() &&
|
||||
process.env[DASHBOARD_ARGS_ENV]?.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const command = buildCliSubcommandCommand("dashboard", ["serve"]);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
process.env[DASHBOARD_LAUNCHER_ENV] = command.launcher;
|
||||
process.env[DASHBOARD_ARGS_ENV] = JSON.stringify(command.childArgs);
|
||||
}
|
||||
@@ -42,11 +42,12 @@ export function getPersistedProviderApiKey(
|
||||
* or endpoint config for the provider. Used by the picker to decide whether
|
||||
* to offer "Use existing configuration?" before opening the configure dialog.
|
||||
*
|
||||
* Treats OAuth providers as configured when an access token is present; for
|
||||
* everything else, any persisted API key, base URL, or model id counts. We
|
||||
* don't enforce required fields here — the runtime no longer pre-flights
|
||||
* credentials, so a missing key only matters when the API call actually
|
||||
* runs and the provider's own auth error is surfaced.
|
||||
* Treats OAuth providers as configured when an access token or a manually
|
||||
* saved API key is present (the /settings escape hatch for when OAuth isn't
|
||||
* working); for everything else, any persisted API key, base URL, or model id
|
||||
* counts. We don't enforce required fields here — the runtime no longer
|
||||
* pre-flights credentials, so a missing key only matters when the API call
|
||||
* actually runs and the provider's own auth error is surfaced.
|
||||
*/
|
||||
export function isProviderConfigured(
|
||||
providerId: string,
|
||||
@@ -54,7 +55,8 @@ export function isProviderConfigured(
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
|
||||
return Boolean(getPersistedProviderApiKey(providerId, settings));
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
if (settings.baseUrl?.trim()) return true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import {
|
||||
ensureSchedulerHub,
|
||||
type HubScheduleClient,
|
||||
@@ -135,10 +136,11 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
const mode = await p.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
|
||||
{ value: "act", label: "Act", hint: "execute tasks" },
|
||||
{ value: "plan", label: "Plan", hint: "plan only" },
|
||||
],
|
||||
initialValue: "act",
|
||||
initialValue: "yolo",
|
||||
});
|
||||
if (isCancel(mode)) return;
|
||||
|
||||
@@ -214,8 +216,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
cronPattern,
|
||||
prompt: (prompt as string).trim(),
|
||||
provider: provider ?? "cline",
|
||||
model: model ?? "openai/gpt-5.3-codex",
|
||||
mode: (mode as string) === "plan" ? "plan" : "act",
|
||||
model: model ?? CLINE_DEFAULT_MODEL_ID,
|
||||
mode: mode as "act" | "plan" | "yolo",
|
||||
workspaceRoot: (workspace as string).trim(),
|
||||
systemPrompt,
|
||||
maxIterations,
|
||||
|
||||
@@ -23,6 +23,12 @@ bun run start
|
||||
|
||||
Open <http://127.0.0.1:8787> and click **Connect**. The server will discover or spawn a local detached hub on startup; the hub endpoint is printed in the console and shown in the sidebar.
|
||||
|
||||
From the CLI, `cline dashboard` opens the running dashboard in the default
|
||||
browser and exits. The local hub daemon owns the background dashboard process
|
||||
when the hub is launched by the CLI; starting a new hub replaces the discovered
|
||||
dashboard process. Use `cline dashboard restart` or `cline dashboard stop` for
|
||||
manual lifecycle control.
|
||||
|
||||
For webview development with Vite hot reload:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -54,7 +54,9 @@ process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
console.log(`[cline-hub:dev] Vite webview: ${webviewDevServerUrl}`);
|
||||
console.log("[cline-hub:dev] Hub dashboard: http://127.0.0.1:8787/");
|
||||
console.log(
|
||||
"[cline-hub:dev] Hub dashboard: use the invite URL printed by the server (includes roomSecret).",
|
||||
);
|
||||
|
||||
spawn(
|
||||
"webview",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user