mirror of
https://github.com/cline/cline.git
synced 2026-09-13 01:39:57 +08:00
Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d7cddb9d9 | ||
|
|
55e169f1f8 | ||
|
|
c227e1ae36 | ||
|
|
d0a0c802af | ||
|
|
f9227fead4 | ||
|
|
bd83980359 | ||
|
|
4d3b161b9a | ||
|
|
255be9ad29 | ||
|
|
77b181a472 | ||
|
|
45476650b7 | ||
|
|
f847b06cfd | ||
|
|
598b3af7eb | ||
|
|
fc936b7305 | ||
|
|
9247dc30bb | ||
|
|
1b499161cc | ||
|
|
0e5cb433b0 | ||
|
|
53a46c309b | ||
|
|
36c78267c6 | ||
|
|
76c30b1c60 | ||
|
|
b496c5a1f3 | ||
|
|
2fc06ce446 | ||
|
|
64c50380f3 | ||
|
|
e36197d911 | ||
|
|
52d187578e | ||
|
|
a6239c420c | ||
|
|
c7c5e6518b | ||
|
|
98c0717302 | ||
|
|
b4aed24ff8 | ||
|
|
e9ec82d2ef | ||
|
|
e3ff875e09 | ||
|
|
dc175c73a8 | ||
|
|
d91f1ce166 | ||
|
|
bc5a2e85a4 | ||
|
|
9c56a726a7 | ||
|
|
72c9bbfcaa | ||
|
|
53a7c3e80e | ||
|
|
8751daaba9 | ||
|
|
aa1fffbe80 | ||
|
|
283c3ba937 | ||
|
|
69c9a9ac28 | ||
|
|
4d238558d0 | ||
|
|
51e5daf8eb | ||
|
|
28ef954258 | ||
|
|
9c4841ea07 | ||
|
|
1bd200e906 | ||
|
|
fabbc144d6 | ||
|
|
372f029343 | ||
|
|
9d9b7aa6f8 | ||
|
|
8c7095b805 | ||
|
|
05535e844c | ||
|
|
c3dc4a95bc | ||
|
|
9466fcc018 | ||
|
|
dd7a1c5fa6 | ||
|
|
fe75eb6271 | ||
|
|
e4091c3686 | ||
|
|
bcf8c1d03e | ||
|
|
5099eed5ee | ||
|
|
b4c640733b | ||
|
|
40ba9d25eb | ||
|
|
adb8ee0c4d | ||
|
|
01617f9a05 | ||
|
|
b72d7f1a6f | ||
|
|
d5b966d0a8 | ||
|
|
1bb0833287 | ||
|
|
2c64c4ce5b | ||
|
|
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 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
|
||||
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."
|
||||
@@ -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,60 @@
|
||||
# Some coding-agent GitHub Apps advertise themselves by auto-commenting on every
|
||||
# new PR ("<Tool> Agent can help with this pull request. Just @<tool> ..."). The
|
||||
# app needs pull_requests:write for its real job (pushing branches, opening PRs),
|
||||
# and GitHub offers no per-behavior control over an installed App, so the ad
|
||||
# cannot be disabled at the source. This deletes those promo comments as they
|
||||
# appear. Genuine agent output comments (work results, reviews) don't match the
|
||||
# promo pattern and are left alone.
|
||||
#
|
||||
# No checkout, API-calls-only — comment text is only ever handled as data inside
|
||||
# the script, never interpolated into the workflow definition.
|
||||
name: repo-delete-agent-promo-comments
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
delete:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# Prefilter so a runner only spins up for bot comments that look like the
|
||||
# ad; the script re-verifies before deleting.
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
endsWith(github.event.comment.user.login, '[bot]') &&
|
||||
contains(github.event.comment.body, 'can help with this pull request')
|
||||
# Comment deletion goes through the issues API, but GitHub gates the
|
||||
# endpoint by where the comment lives: issue comments need `issues`,
|
||||
# PR-conversation comments need `pull-requests`. The prefilter restricts
|
||||
# this job to PR comments, so pull-requests is the one that matters;
|
||||
# issues is kept in case the prefilter is ever widened.
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
|
||||
# write permissions and fires on attacker-postable events.
|
||||
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment
|
||||
|
||||
// Belt and suspenders on top of the job-level prefilter: only
|
||||
// delete when the author is a real GitHub App bot AND the body
|
||||
// matches the self-promotion shape ("... can help with this
|
||||
// pull request. Just @<handle> ..."). A human quoting the ad
|
||||
// text is not a Bot; a bot posting real work output doesn't
|
||||
// match the promo shape.
|
||||
const isBot = comment.user.type === "Bot"
|
||||
const isPromo = /\bcan help with this pull request\b[\s\S]*@\w/i.test(comment.body || "")
|
||||
|
||||
if (!isBot || !isPromo) {
|
||||
core.info("not an agent promo comment, leaving it alone")
|
||||
return
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
...context.repo,
|
||||
comment_id: comment.id,
|
||||
})
|
||||
core.info(`deleted promo comment ${comment.id} by ${comment.user.login} on #${context.payload.issue.number}`)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Cloud coding agents append promotional badge blocks to PR bodies after the
|
||||
# agent's final turn, wrapped around <!-- <VENDOR>_AGENT_PR_BODY_BEGIN/END -->
|
||||
# marker comments. The agent itself never sees that content, so no repo rule or
|
||||
# agent instruction can prevent it. This strips it from the PR description on
|
||||
# open/edit, keeping only the agent-authored content between the markers.
|
||||
#
|
||||
# Uses pull_request_target so the token has write access on PRs from forks. That
|
||||
# trigger is only unsafe when a job checks out and executes PR code — this one
|
||||
# never checks out the repository, it only calls the REST API.
|
||||
name: repo-strip-agent-badges
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
concurrency:
|
||||
group: strip-agent-badges-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
strip:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.body, '_AGENT_PR_BODY')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
|
||||
# write permissions under pull_request_target.
|
||||
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
// Re-fetch instead of trusting the event payload: the body may have
|
||||
// been edited again between the event firing and this run (agent
|
||||
// harnesses edit PR bodies post-open), and updating from the stale
|
||||
// snapshot would clobber the newer content.
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
})
|
||||
const body = pr.body || ""
|
||||
|
||||
// The BEGIN/END comments wrap the agent-authored content; everything
|
||||
// outside them (vendor promo badges, "open in <tool>" links) is
|
||||
// appended by the harness. Keep only what's between the markers.
|
||||
// The backreference requires BEGIN and END to name the same vendor.
|
||||
// No markers -> no match -> body passes through unchanged.
|
||||
const cleaned = body
|
||||
.replace(
|
||||
/^[\s\S]*?<!--\s*([A-Z][A-Z0-9_]*?)_AGENT_PR_BODY_BEGIN\s*-->\r?\n?([\s\S]*?)<!--\s*\1_AGENT_PR_BODY_END\s*-->[\s\S]*$/,
|
||||
"$2",
|
||||
)
|
||||
.trimEnd()
|
||||
|
||||
// No change means a previous run already cleaned this body. Returning
|
||||
// without an update is what stops `edited` from retriggering forever.
|
||||
if (cleaned === body) {
|
||||
core.info("nothing to strip")
|
||||
return
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
...context.repo,
|
||||
pull_number: pr.number,
|
||||
body: cleaned,
|
||||
})
|
||||
core.info(`stripped ${body.length - cleaned.length} chars from PR #${pr.number}`)
|
||||
@@ -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}'"
|
||||
Vendored
+4
-2
@@ -51,7 +51,8 @@
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"CLINE_ENVIRONMENT": "staging"
|
||||
"CLINE_ENVIRONMENT": "staging",
|
||||
"CLINE_DIR": "${userHome}/.cline_staging"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -75,7 +76,8 @@
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
"CLINE_ENVIRONMENT": "local",
|
||||
"CLINE_DIR": "${userHome}/.cline_local"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -226,7 +226,7 @@ Run Cline with zero interaction for scripting and automation. Pipe input, get JS
|
||||
|
||||
```bash
|
||||
cline "Run tests and fix any failures"
|
||||
git diff origin/main | cline "Review these changes for issues"
|
||||
git diff origin/main | cline "Review these changes for issues"
|
||||
cline --json "List all TODO comments" | jq -r 'select(.type == "agent_event" and .event.text) | .event.text'
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.47
|
||||
|
||||
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
|
||||
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
|
||||
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
|
||||
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
|
||||
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
|
||||
- Aborting a task no longer risks killing the shared hub daemon
|
||||
- Connector status delivery failures are no longer fatal to the turn
|
||||
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
|
||||
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
|
||||
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
|
||||
- Updated the bundled model catalog (from SDK v0.0.66)
|
||||
|
||||
## 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)
|
||||
|
||||
+2
-2
@@ -257,10 +257,10 @@ 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) |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
|
||||
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
|
||||
| `--kanban` | Run the external `kanban` app |
|
||||
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.43",
|
||||
"version": "3.0.47",
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
} from "../connectors/common";
|
||||
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
|
||||
import {
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
stopAllConnectors,
|
||||
} from "./connect";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disableConnectorAutostart: vi.fn(),
|
||||
getPersistedConnectorConnection: vi.fn(),
|
||||
getConnector: vi.fn(),
|
||||
listActiveConnectors: vi.fn(),
|
||||
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
|
||||
persistConnectorConnection: vi.fn(),
|
||||
removePersistedConnectorConnection: vi.fn(),
|
||||
run: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
disableConnectorAutostart: mocks.disableConnectorAutostart,
|
||||
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
|
||||
listActiveConnectors: mocks.listActiveConnectors,
|
||||
persistConnectorConnection: mocks.persistConnectorConnection,
|
||||
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/registry", () => ({
|
||||
getConnector: mocks.getConnector,
|
||||
listConnectors: mocks.listConnectors,
|
||||
}));
|
||||
|
||||
describe("runConnectAdapter", () => {
|
||||
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
|
||||
const io: ConnectIo = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listConnectors.mockReturnValue([]);
|
||||
mocks.listActiveConnectors.mockReturnValue([]);
|
||||
mocks.run.mockImplementation(
|
||||
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
|
||||
context.setPersistenceInstanceId("cline_bot");
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
mocks.validate.mockResolvedValue(0);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDetachedChild === undefined) {
|
||||
delete process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
|
||||
} else {
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = previousDetachedChild;
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a successful detached connector start", async () => {
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
["-k", "token"],
|
||||
);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a successful env-only connector start", async () => {
|
||||
await expect(runConnectAdapter("telegram", [], io)).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
[],
|
||||
);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists connector-resolved launch arguments", async () => {
|
||||
mocks.run.mockImplementation(
|
||||
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
|
||||
context.setPersistenceInstanceId("resolved_bot");
|
||||
context.setPersistenceArgs([
|
||||
"--bot-token",
|
||||
"token",
|
||||
"--bot-username",
|
||||
"resolved_bot",
|
||||
]);
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["--bot-token", "token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"resolved_bot",
|
||||
["--bot-token", "token", "--bot-username", "resolved_bot"],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite persistence when a connector is already running", async () => {
|
||||
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"-i",
|
||||
"--interactive",
|
||||
])("disables autostart after a successful %s foreground run exits", async (interactiveFlag) => {
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token", interactiveFlag], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not change persistence after a failed foreground run", async () => {
|
||||
mocks.run.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist a failed detached launch", async () => {
|
||||
mocks.run.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves persistence unchanged when an internal detached child exits", async () => {
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = "1";
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist help invocations", async () => {
|
||||
await expect(runConnectAdapter("telegram", ["--help"], io)).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves autostart unchanged during shared process cleanup", async () => {
|
||||
const stopAll = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
});
|
||||
mocks.listConnectors.mockReturnValue([
|
||||
{ name: "telegram", description: "Telegram" },
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
showHelp: vi.fn(),
|
||||
stopAll,
|
||||
});
|
||||
|
||||
await expect(stopAllConnectors(io)).resolves.toEqual({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
executed: 1,
|
||||
});
|
||||
|
||||
expect(stopAll).toHaveBeenCalledWith(io);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables autostart for an explicit stop-all command", async () => {
|
||||
const stopAll = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
});
|
||||
mocks.listConnectors.mockReturnValue([
|
||||
{ name: "telegram", description: "Telegram" },
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
showHelp: vi.fn(),
|
||||
stopAll,
|
||||
});
|
||||
|
||||
await expect(runStopAllConnectors(io)).resolves.toBe(0);
|
||||
|
||||
expect(stopAll).toHaveBeenCalledWith(io);
|
||||
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("validates a replacement before stopping the active instance", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.validate.mockResolvedValue(1);
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "bad-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.validate).toHaveBeenCalledWith(["-k", "bad-token"], io);
|
||||
expect(stopInstance).not.toHaveBeenCalled();
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows restart help without stopping an active instance", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(runRestartConnector("telegram", ["--help"], io)).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledWith(["--help"], io, expect.any(Object));
|
||||
expect(mocks.validate).not.toHaveBeenCalled();
|
||||
expect(stopInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the last successful launch when a replacement fails", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getPersistedConnectorConnection.mockReturnValue({
|
||||
channel: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
connectArgs: ["-k", "new-token"],
|
||||
lastSuccessfulArgs: ["-k", "old-token"],
|
||||
enabled: true,
|
||||
updatedAt: "2026-07-25T00:00:00.000Z",
|
||||
lastConnectedAt: "2026-07-25T00:00:00.000Z",
|
||||
});
|
||||
mocks.run
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockImplementationOnce(
|
||||
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
|
||||
context.setPersistenceInstanceId("cline_bot");
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
|
||||
expect(mocks.run).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
["-k", "new-token"],
|
||||
io,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.run).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
["-k", "old-token"],
|
||||
io,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
["-k", "old-token"],
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts an active instance without persisted rollback arguments", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getPersistedConnectorConnection.mockReturnValue(undefined);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
|
||||
expect(mocks.run).toHaveBeenCalledWith(
|
||||
["-k", "new-token"],
|
||||
io,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start a replacement when the active process cannot be stopped", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not count an already-running instance as a successful replacement", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getPersistedConnectorConnection.mockReturnValue({
|
||||
channel: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
connectArgs: ["-k", "new-token"],
|
||||
lastSuccessfulArgs: ["-k", "old-token"],
|
||||
enabled: true,
|
||||
updatedAt: "2026-07-25T00:00:00.000Z",
|
||||
lastConnectedAt: "2026-07-25T00:00:00.000Z",
|
||||
});
|
||||
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"[connect] replacement was not started because telegram instance cline_bot is still running",
|
||||
);
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,29 @@
|
||||
import {
|
||||
disableConnectorAutostart,
|
||||
getPersistedConnectorConnection,
|
||||
listActiveConnectors,
|
||||
persistConnectorConnection,
|
||||
removePersistedConnectorConnection,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
} from "../connectors/common";
|
||||
import { getConnector, listConnectors } from "../connectors/registry";
|
||||
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
|
||||
import type {
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../connectors/types";
|
||||
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
|
||||
|
||||
export async function stopAllConnectors(
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult & { executed: number }> {
|
||||
let stoppedProcesses = 0;
|
||||
let failedProcesses = 0;
|
||||
let stoppedSessions = 0;
|
||||
let executed = 0;
|
||||
for (const entry of listConnectors()) {
|
||||
@@ -18,42 +37,209 @@ export async function stopAllConnectors(
|
||||
executed += 1;
|
||||
const result = await connector.stopAll(io);
|
||||
stoppedProcesses += result.stoppedProcesses;
|
||||
failedProcesses += result.failedProcesses;
|
||||
stoppedSessions += result.stoppedSessions;
|
||||
}
|
||||
return { stoppedProcesses, stoppedSessions, executed };
|
||||
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
|
||||
}
|
||||
|
||||
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
|
||||
const { stoppedProcesses, stoppedSessions, executed } =
|
||||
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
|
||||
await stopAllConnectors(io);
|
||||
if (executed === 0) {
|
||||
io.writeln("[connect] no adapters support stop yet");
|
||||
return 0;
|
||||
}
|
||||
disableConnectorAutostart();
|
||||
io.writeln(
|
||||
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
|
||||
`[connect] stopped processes=${stoppedProcesses} failed=${failedProcesses} sessions=${stoppedSessions}`,
|
||||
);
|
||||
return 0;
|
||||
return failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runStopConnector(
|
||||
adapterName: string,
|
||||
io: ConnectIo,
|
||||
options: {
|
||||
autostart: "disable" | "preserve";
|
||||
instanceId?: string;
|
||||
} = {
|
||||
autostart: "disable",
|
||||
},
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
if (!connector.stopAll) {
|
||||
const stop = options.instanceId
|
||||
? connector.stopInstance
|
||||
? () => connector.stopInstance?.(options.instanceId ?? "", io)
|
||||
: undefined
|
||||
: connector.stopAll
|
||||
? () => connector.stopAll?.(io)
|
||||
: undefined;
|
||||
if (!stop) {
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
return 1;
|
||||
}
|
||||
const result: ConnectStopResult = await connector.stopAll(io);
|
||||
const result = await stop();
|
||||
if (!result) {
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
return 1;
|
||||
}
|
||||
if (options.autostart === "disable") {
|
||||
disableConnectorAutostart(connector.name, options.instanceId);
|
||||
}
|
||||
io.writeln(
|
||||
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
|
||||
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
|
||||
);
|
||||
return 0;
|
||||
return result.failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runRestartConnector(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
requestedInstanceId?: string,
|
||||
): Promise<number> {
|
||||
if (passthroughArgs.some((arg) => HELP_FLAGS.has(arg))) {
|
||||
return await runConnectAdapter(adapterName, passthroughArgs, io);
|
||||
}
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
const activeInstances = listActiveConnectors().filter(
|
||||
(record) => record.type === adapterName,
|
||||
);
|
||||
if (!requestedInstanceId && activeInstances.length > 1) {
|
||||
io.writeErr(
|
||||
`cannot safely restart ${adapterName}: ${activeInstances.length} instances are active; specify an instance`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const instanceId = requestedInstanceId ?? activeInstances[0]?.instanceId;
|
||||
const targetIsActive =
|
||||
instanceId !== undefined &&
|
||||
activeInstances.some((record) => record.instanceId === instanceId);
|
||||
if (!targetIsActive || !instanceId) {
|
||||
return await runConnectAdapter(adapterName, passthroughArgs, io);
|
||||
}
|
||||
|
||||
const validationExitCode = await connector.validate(passthroughArgs, io);
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
const previousConnection = getPersistedConnectorConnection(
|
||||
adapterName,
|
||||
instanceId,
|
||||
);
|
||||
const stopExitCode = await runStopConnector(adapterName, io, {
|
||||
autostart: "preserve",
|
||||
instanceId,
|
||||
});
|
||||
if (stopExitCode !== 0) {
|
||||
return stopExitCode;
|
||||
}
|
||||
const replacement = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
passthroughArgs,
|
||||
io,
|
||||
);
|
||||
if (replacement.exitCode === 0) {
|
||||
if (replacement.instanceId && replacement.instanceId !== instanceId) {
|
||||
removePersistedConnectorConnection(adapterName, instanceId);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (replacement.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
|
||||
io.writeErr(
|
||||
`[connect] replacement was not started because ${adapterName} instance ${instanceId} is still running`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (!previousConnection) {
|
||||
io.writeErr(
|
||||
`[connect] replacement failed and ${adapterName} instance ${instanceId} has no successful launch arguments for rollback`,
|
||||
);
|
||||
return replacement.exitCode;
|
||||
}
|
||||
|
||||
io.writeErr(
|
||||
`[connect] replacement failed; restoring ${adapterName} instance ${instanceId}`,
|
||||
);
|
||||
const rollback = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
previousConnection.lastSuccessfulArgs,
|
||||
io,
|
||||
);
|
||||
if (rollback.exitCode === 0) {
|
||||
io.writeln(`[connect] restored ${adapterName} instance ${instanceId}`);
|
||||
} else {
|
||||
io.writeErr(
|
||||
`[connect] failed to restore ${adapterName} instance ${instanceId}`,
|
||||
);
|
||||
}
|
||||
return replacement.exitCode;
|
||||
}
|
||||
|
||||
interface ConnectAdapterResult {
|
||||
exitCode: number;
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
async function runConnectAdapterWithResult(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectAdapterResult> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return { exitCode: 1 };
|
||||
}
|
||||
let persistenceArgs = passthroughArgs;
|
||||
let persistenceInstanceId: string | undefined;
|
||||
const context: ConnectRunContext = {
|
||||
setPersistenceArgs: (args) => {
|
||||
persistenceArgs = [...args];
|
||||
},
|
||||
setPersistenceInstanceId: (instanceId) => {
|
||||
persistenceInstanceId = instanceId;
|
||||
},
|
||||
};
|
||||
const exitCode = await connector.run(passthroughArgs, io, context);
|
||||
if (exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
|
||||
return { exitCode, instanceId: persistenceInstanceId };
|
||||
}
|
||||
const isHelpInvocation = passthroughArgs.some((arg) => HELP_FLAGS.has(arg));
|
||||
const isInteractiveInvocation = passthroughArgs.some((arg) =>
|
||||
INTERACTIVE_FLAGS.has(arg),
|
||||
);
|
||||
const isDetachedChild =
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
|
||||
if (
|
||||
exitCode === 0 &&
|
||||
!isHelpInvocation &&
|
||||
!isDetachedChild &&
|
||||
isInteractiveInvocation
|
||||
) {
|
||||
disableConnectorAutostart(connector.name, persistenceInstanceId);
|
||||
} else if (
|
||||
exitCode === 0 &&
|
||||
!isHelpInvocation &&
|
||||
!isDetachedChild &&
|
||||
persistenceInstanceId
|
||||
) {
|
||||
persistConnectorConnection(
|
||||
connector.name,
|
||||
persistenceInstanceId,
|
||||
persistenceArgs,
|
||||
);
|
||||
}
|
||||
return { exitCode, instanceId: persistenceInstanceId };
|
||||
}
|
||||
|
||||
export async function runConnectAdapter(
|
||||
@@ -61,12 +247,14 @@ export async function runConnectAdapter(
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
return connector.run(passthroughArgs, io);
|
||||
const result = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
passthroughArgs,
|
||||
io,
|
||||
);
|
||||
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
|
||||
? 0
|
||||
: result.exitCode;
|
||||
}
|
||||
|
||||
export function formatAdapterList(): string {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 {
|
||||
@@ -21,6 +22,7 @@ const {
|
||||
mockClearHubDiscovery,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockEnsureFileExists,
|
||||
mockListActiveConnectors,
|
||||
mockStopAllConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
@@ -49,8 +51,10 @@ const {
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
mockEnsureFileExists: vi.fn(),
|
||||
mockListActiveConnectors: vi.fn(() => []),
|
||||
mockStopAllConnectors: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
executed: 0,
|
||||
})),
|
||||
@@ -69,6 +73,7 @@ vi.mock("@cline/core", () => ({
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
listActiveConnectors: mockListActiveConnectors,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/common", () => ({
|
||||
@@ -99,6 +104,7 @@ describe("runDoctorCommand", () => {
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
executed: 0,
|
||||
});
|
||||
@@ -174,6 +180,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);
|
||||
@@ -248,6 +288,7 @@ describe("runDoctorCommand", () => {
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 5,
|
||||
executed: 3,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureFileExists,
|
||||
listActiveConnectors,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
@@ -11,14 +12,15 @@ import {
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
listActiveConnectors,
|
||||
} from "../connectors/status";
|
||||
formatUptime,
|
||||
resolveClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
@@ -49,6 +51,8 @@ type SpawnedProcessRecord = {
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
cliVersion: string;
|
||||
coreVersion?: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
@@ -337,6 +341,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 +425,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}`,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
|
||||
pid: 50174,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
uptime: "1m 5s",
|
||||
cliVersion,
|
||||
coreVersion: "0.0.62",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -134,6 +135,8 @@ export function createHubCommand(
|
||||
pid: health?.pid,
|
||||
startedAt: health?.startedAt,
|
||||
uptime,
|
||||
cliVersion,
|
||||
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { relative, sep } from "node:path";
|
||||
import {
|
||||
resolveClineDataDir,
|
||||
resolveClineDir,
|
||||
setHomeDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createProgram } from "./program";
|
||||
|
||||
/** Render an absolute path under `home` the way help text does: `~/...`. */
|
||||
function tildePath(absolutePath: string, home: string): string {
|
||||
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
|
||||
}
|
||||
|
||||
describe("root option help text", () => {
|
||||
const FAKE_HOME = "/home/cline-help-test";
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(() => {
|
||||
// Pin the resolver inputs so the defaults below are the true defaults
|
||||
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
|
||||
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
setHomeDir(FAKE_HOME);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the actual resolver defaults for --config and --data-dir", () => {
|
||||
// A wide help width keeps each option description on one line so the
|
||||
// full default text can be matched.
|
||||
const help = createProgram()
|
||||
.configureHelp({ helpWidth: 500 })
|
||||
.helpInformation();
|
||||
|
||||
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
|
||||
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
|
||||
|
||||
// Sanity-check the resolvers themselves so the assertions below can't
|
||||
// silently drift along with a resolver regression.
|
||||
expect(configDefault).toBe("~/.cline");
|
||||
expect(dataDirDefault).toBe("~/.cline/data");
|
||||
|
||||
expect(help).toContain(
|
||||
`Configuration directory (default: ${configDefault})`,
|
||||
);
|
||||
expect(help).toContain(
|
||||
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"--acp",
|
||||
"Run in Agent Client Protocol (ACP) mode for editor integration",
|
||||
)
|
||||
.option(
|
||||
"--config <path>",
|
||||
"Configuration directory (default: ~/.cline/data/settings)",
|
||||
)
|
||||
.option("--config <path>", "Configuration directory (default: ~/.cline)")
|
||||
.option(
|
||||
"--data-dir <path>",
|
||||
"Use isolated local state at this directory path (default: ~/.cline)",
|
||||
"Use isolated local state at this directory path (default: ~/.cline/data)",
|
||||
)
|
||||
.option(
|
||||
"--hooks-dir <path>",
|
||||
@@ -136,6 +133,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
interactive: !!opts.tui,
|
||||
outputMode: opts.json ? "json" : "text",
|
||||
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
|
||||
modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen),
|
||||
sandbox: !!opts.dataDir,
|
||||
acpMode: !!opts.acp,
|
||||
thinking: false,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -937,9 +938,18 @@ class DiscordConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopDiscordConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async validateOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
if (!options.applicationId) {
|
||||
@@ -960,7 +970,16 @@ class DiscordConnector extends ConnectorBase<
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.applicationId);
|
||||
const statePath = this.resolveConnectorStatePath(options.applicationId);
|
||||
const bindingsPath = this.resolveBindingsPath(options.applicationId);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -971,26 +990,24 @@ class DiscordConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<DiscordThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
|
||||
foregroundHint:
|
||||
"[discord] use `cline connect discord -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Discord connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
|
||||
foregroundHint:
|
||||
"[discord] use `cline connect discord -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Discord connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -409,11 +410,66 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopGoogleChatConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
private parseCredentials(
|
||||
options: ConnectGoogleChatOptions,
|
||||
):
|
||||
| { client_email: string; private_key: string; project_id?: string }
|
||||
| undefined {
|
||||
if (!options.credentialsJson) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(options.credentialsJson) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (
|
||||
typeof parsed.client_email !== "string" ||
|
||||
typeof parsed.private_key !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"credentials JSON must include string client_email and private_key fields",
|
||||
);
|
||||
}
|
||||
return {
|
||||
client_email: parsed.client_email,
|
||||
private_key: parsed.private_key,
|
||||
project_id:
|
||||
typeof parsed.project_id === "string" ? parsed.project_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected override async validateOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
try {
|
||||
this.parseCredentials(options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.writeErr(
|
||||
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.userName);
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -424,26 +480,25 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<GoogleChatThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Google Chat connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Google Chat connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
@@ -452,38 +507,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
});
|
||||
const logger = createChatSdkLogger(loggerAdapter);
|
||||
const consoleLogger = new ConsoleLogger("info", "gchat-connect");
|
||||
let parsedCredentials:
|
||||
| { client_email: string; private_key: string; project_id?: string }
|
||||
| undefined;
|
||||
if (options.credentialsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(options.credentialsJson) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (
|
||||
typeof parsed.client_email !== "string" ||
|
||||
typeof parsed.private_key !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"credentials JSON must include string client_email and private_key fields",
|
||||
);
|
||||
}
|
||||
parsedCredentials = {
|
||||
client_email: parsed.client_email,
|
||||
private_key: parsed.private_key,
|
||||
project_id:
|
||||
typeof parsed.project_id === "string"
|
||||
? parsed.project_id
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
io.writeErr(
|
||||
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const parsedCredentials = this.parseCredentials(options);
|
||||
const endpointUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/gchat`;
|
||||
const gchat = createGoogleChatAdapter(
|
||||
parsedCredentials
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import { getConnectorSystemPrompt } from "./prompts";
|
||||
@@ -477,11 +478,23 @@ class LinearConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopLinearConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectLinearOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.userName);
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -492,25 +505,24 @@ class LinearConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<LinearThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[linear] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[linear] use `cline connect linear -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Linear connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[linear] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[linear] use `cline connect linear -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Linear connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -668,11 +669,23 @@ class SlackConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopSlackConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectSlackOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.userName);
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const stateStorePath = this.resolveStateStorePath(options.userName);
|
||||
@@ -684,27 +697,26 @@ class SlackConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -2,9 +2,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectTelegramOptions } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnDetachedConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../common")>()),
|
||||
spawnDetachedConnector: mocks.spawnDetachedConnector,
|
||||
}));
|
||||
|
||||
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
(
|
||||
telegramConnector as unknown as {
|
||||
@@ -15,6 +25,11 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
const originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
const tempDataDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
});
|
||||
|
||||
function useTempClineDataDir(): string {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
|
||||
tempDataDirs.push(dataDir);
|
||||
@@ -153,7 +168,7 @@ describe("telegramConnector", () => {
|
||||
expect(options.botUsername).toBe("test_bot");
|
||||
});
|
||||
|
||||
it("does not call getMe when the token-only connector is already running", async () => {
|
||||
it("validates a token before reporting its connector as already running", async () => {
|
||||
const dataDir = useTempClineDataDir();
|
||||
const connectorDir = join(dataDir, "connectors", "telegram");
|
||||
mkdirSync(connectorDir, { recursive: true });
|
||||
@@ -167,26 +182,87 @@ describe("telegramConnector", () => {
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected getMe call");
|
||||
});
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
await expect(
|
||||
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => errors.push(text),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
telegramConnector.run(
|
||||
["--bot-token", "123:test", "--cwd", "/tmp/work"],
|
||||
{
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
{
|
||||
setPersistenceArgs: vi.fn(),
|
||||
setPersistenceInstanceId: vi.fn(),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual([
|
||||
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the resolved bot username in persistence args", async () => {
|
||||
const dataDir = useTempClineDataDir();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const setPersistenceArgs = vi.fn();
|
||||
const setPersistenceInstanceId = vi.fn();
|
||||
mocks.spawnDetachedConnector.mockImplementation(() => {
|
||||
const connectorDir = join(dataDir, "connectors", "telegram");
|
||||
mkdirSync(connectorDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(connectorDir, "resolved_bot.json"),
|
||||
JSON.stringify({
|
||||
botUsername: "resolved_bot",
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
return process.pid;
|
||||
});
|
||||
|
||||
await expect(
|
||||
telegramConnector.run(
|
||||
["--bot-token", "123:test", "--cwd", "/tmp/work"],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{ setPersistenceArgs, setPersistenceInstanceId },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setPersistenceArgs).toHaveBeenCalledWith([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--bot-username",
|
||||
"resolved_bot",
|
||||
]);
|
||||
expect(setPersistenceInstanceId).toHaveBeenCalledWith("resolved_bot");
|
||||
expect(mocks.spawnDetachedConnector).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram bot username resolution", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
|
||||
import { isProcessRunning } from "../common";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
|
||||
import {
|
||||
type ActiveConnectorTurn,
|
||||
handleConnectorUserTurn,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -604,10 +605,37 @@ class TelegramConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopTelegramConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async validateOptions(
|
||||
options: ConnectTelegramOptions,
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
try {
|
||||
await resolveTelegramBotUsername({
|
||||
...options,
|
||||
botUsername: undefined,
|
||||
});
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
inputOptions: ConnectTelegramOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
if (
|
||||
!inputOptions.botUsername &&
|
||||
@@ -621,7 +649,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
io.writeln(
|
||||
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
|
||||
);
|
||||
return 0;
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
}
|
||||
let resolvedBotUsername: string;
|
||||
@@ -638,6 +666,8 @@ class TelegramConnector extends ConnectorBase<
|
||||
const backgroundArgs = inputOptions.botUsername
|
||||
? rawArgs
|
||||
: [...rawArgs, "--bot-username", resolvedBotUsername];
|
||||
context.setPersistenceArgs(backgroundArgs);
|
||||
context.setPersistenceInstanceId(options.botUsername);
|
||||
const statePath = this.resolveConnectorStatePath(options.botUsername);
|
||||
const bindingsPath = this.resolveBindingsPath(options.botUsername);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -648,26 +678,24 @@ class TelegramConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<TelegramThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs: backgroundArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
|
||||
foregroundHint:
|
||||
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Telegram connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs: backgroundArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
|
||||
foregroundHint:
|
||||
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Telegram connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -444,15 +445,27 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopWhatsAppConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectWhatsAppOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
const instanceKey = resolveInstanceKey({
|
||||
phoneNumberId: options.phoneNumberId,
|
||||
userName: options.userName,
|
||||
});
|
||||
context.setPersistenceInstanceId(instanceKey);
|
||||
const statePath = this.resolveConnectorStatePath(instanceKey);
|
||||
const bindingsPath = this.resolveBindingsPath(instanceKey);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -463,26 +476,24 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<WhatsAppThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch WhatsApp connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch WhatsApp connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConnectorBase } from "./base";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "./common";
|
||||
import type { ConnectIo } from "./types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isProcessRunning: vi.fn(),
|
||||
spawnDetachedConnector: vi.fn(),
|
||||
terminateProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./common")>()),
|
||||
isProcessRunning: mocks.isProcessRunning,
|
||||
spawnDetachedConnector: mocks.spawnDetachedConnector,
|
||||
terminateProcess: mocks.terminateProcess,
|
||||
}));
|
||||
|
||||
class TestConnector extends ConnectorBase<
|
||||
Record<string, never>,
|
||||
{ pid: number }
|
||||
> {
|
||||
constructor() {
|
||||
super("test", "Test connector");
|
||||
}
|
||||
|
||||
protected readOptions(): Record<string, never> {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected async runWithOptions(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
runBackground(
|
||||
io: ConnectIo,
|
||||
options?: {
|
||||
readState?: () => { pid: number } | undefined;
|
||||
isRunning?: (state: { pid: number }) => boolean;
|
||||
startupTimeoutMs?: number;
|
||||
},
|
||||
): Promise<number | undefined> {
|
||||
return this.maybeRunInBackground({
|
||||
rawArgs: ["--token", "secret"],
|
||||
io,
|
||||
interactive: false,
|
||||
childEnvVar: "CLINE_TEST_CONNECT_CHILD",
|
||||
statePath: "/tmp/test-connector.json",
|
||||
readState: options?.readState ?? (() => undefined),
|
||||
isRunning: options?.isRunning ?? (() => false),
|
||||
formatAlreadyRunningMessage: () => "already running",
|
||||
formatBackgroundStartMessage: (pid) => `started ${pid}`,
|
||||
foregroundHint: "foreground hint",
|
||||
launchFailureMessage: "launch failed",
|
||||
startupTimeoutMs: options?.startupTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
stopProcess(
|
||||
io: ConnectIo,
|
||||
options: {
|
||||
statePath: string;
|
||||
readState: (path: string) => { pid: number } | undefined;
|
||||
stopSessions?: (state: { pid: number }) => Promise<number>;
|
||||
clearBindings?: (state: { pid: number }) => void;
|
||||
},
|
||||
) {
|
||||
return this.stopManagedProcess({
|
||||
io,
|
||||
statePath: options.statePath,
|
||||
readState: options.readState,
|
||||
describeStoppedProcess: (state) => `stopped pid=${state.pid}`,
|
||||
getPid: (state) => state.pid,
|
||||
stopSessions: options.stopSessions ?? (async () => 0),
|
||||
clearBindings: options.clearBindings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("ConnectorBase background launch", () => {
|
||||
const io: ConnectIo = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isProcessRunning.mockReturnValue(true);
|
||||
mocks.terminateProcess.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("returns a failure exit code when the detached process is not created", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(0);
|
||||
|
||||
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith("launch failed");
|
||||
});
|
||||
|
||||
it("returns success only after a detached process receives a pid", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
let reads = 0;
|
||||
|
||||
await expect(
|
||||
new TestConnector().runBackground(io, {
|
||||
readState: () => (++reads > 1 ? { pid: 42 } : undefined),
|
||||
isRunning: () => true,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(io.writeln).toHaveBeenCalledWith("started 42");
|
||||
});
|
||||
|
||||
it("fails when the detached child exits before becoming ready", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
mocks.isProcessRunning.mockReturnValue(false);
|
||||
|
||||
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: child exited before becoming ready",
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates a detached child that never becomes ready", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
|
||||
await expect(
|
||||
new TestConnector().runBackground(io, { startupTimeoutMs: 0 }),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: timed out after 0ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a distinct result when a connector is already running", async () => {
|
||||
await expect(
|
||||
new TestConnector().runBackground(io, {
|
||||
readState: () => ({ pid: 99 }),
|
||||
isRunning: () => true,
|
||||
}),
|
||||
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
|
||||
expect(io.writeln).toHaveBeenCalledWith("already running");
|
||||
expect(mocks.spawnDetachedConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps state and reports failure when the process survives termination", async () => {
|
||||
const connector = new TestConnector();
|
||||
const removeStateFile = vi.spyOn(
|
||||
connector as unknown as { removeStateFile: (path: string) => void },
|
||||
"removeStateFile",
|
||||
);
|
||||
const stopSessions = vi.fn(async () => 1);
|
||||
const clearBindings = vi.fn();
|
||||
mocks.terminateProcess.mockResolvedValue(false);
|
||||
mocks.isProcessRunning.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
connector.stopProcess(io, {
|
||||
statePath: "/tmp/test-connector.json",
|
||||
readState: () => ({ pid: 42 }),
|
||||
stopSessions,
|
||||
clearBindings,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
|
||||
expect(removeStateFile).not.toHaveBeenCalled();
|
||||
expect(stopSessions).not.toHaveBeenCalled();
|
||||
expect(clearBindings).not.toHaveBeenCalled();
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"[connect] failed to stop connector process pid=42",
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans stale state after confirming the process is already gone", async () => {
|
||||
const connector = new TestConnector();
|
||||
const removeStateFile = vi.spyOn(
|
||||
connector as unknown as { removeStateFile: (path: string) => void },
|
||||
"removeStateFile",
|
||||
);
|
||||
const stopSessions = vi.fn(async () => 1);
|
||||
const clearBindings = vi.fn();
|
||||
mocks.terminateProcess.mockResolvedValue(false);
|
||||
mocks.isProcessRunning.mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
connector.stopProcess(io, {
|
||||
statePath: "/tmp/test-connector.json",
|
||||
readState: () => ({ pid: 42 }),
|
||||
stopSessions,
|
||||
clearBindings,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 1,
|
||||
});
|
||||
|
||||
expect(removeStateFile).toHaveBeenCalledWith("/tmp/test-connector.json");
|
||||
expect(stopSessions).toHaveBeenCalledWith({ pid: 42 });
|
||||
expect(clearBindings).toHaveBeenCalledWith({ pid: 42 });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
@@ -13,15 +14,19 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "./types";
|
||||
|
||||
const SHOW_HELP_ERROR = "__SHOW_HELP__";
|
||||
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const CONNECTOR_STARTUP_POLL_MS = 100;
|
||||
|
||||
export abstract class ConnectorBase<Options, State>
|
||||
implements ConnectCommandDefinition
|
||||
{
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -41,8 +46,16 @@ export abstract class ConnectorBase<Options, State>
|
||||
options: Options,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number>;
|
||||
|
||||
protected async validateOptions(
|
||||
_options: Options,
|
||||
_io: ConnectIo,
|
||||
): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
showHelp(io: ConnectIo): void {
|
||||
const output = this.createCommand().helpInformation().trimEnd();
|
||||
for (const line of output.split("\n")) {
|
||||
@@ -50,7 +63,11 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
}
|
||||
|
||||
async run(rawArgs: string[], io: ConnectIo): Promise<number> {
|
||||
async run(
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
let options: Options;
|
||||
try {
|
||||
options = this.parseArgs(rawArgs);
|
||||
@@ -63,7 +80,27 @@ export abstract class ConnectorBase<Options, State>
|
||||
io.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
return this.runWithOptions(options, rawArgs, io);
|
||||
const validationExitCode = await this.validateOptions(options, io);
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
return this.runWithOptions(options, rawArgs, io, context);
|
||||
}
|
||||
|
||||
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
|
||||
let options: Options;
|
||||
try {
|
||||
options = this.parseArgs(rawArgs);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message === SHOW_HELP_ERROR) {
|
||||
this.showHelp(io);
|
||||
return 0;
|
||||
}
|
||||
io.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
return await this.validateOptions(options, io);
|
||||
}
|
||||
|
||||
protected parseArgs(rawArgs: string[]): Options {
|
||||
@@ -145,14 +182,15 @@ export abstract class ConnectorBase<Options, State>
|
||||
formatBackgroundStartMessage: (pid: number) => string;
|
||||
foregroundHint: string;
|
||||
launchFailureMessage: string;
|
||||
}): Promise<boolean> {
|
||||
startupTimeoutMs?: number;
|
||||
}): Promise<number | undefined> {
|
||||
if (input.interactive || process.env[input.childEnvVar] === "1") {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
const runningState = input.readState(input.statePath);
|
||||
if (runningState && input.isRunning(runningState)) {
|
||||
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
|
||||
return true;
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
const pid = spawnDetachedConnector(
|
||||
["connect", this.name],
|
||||
@@ -161,11 +199,32 @@ export abstract class ConnectorBase<Options, State>
|
||||
);
|
||||
if (!pid) {
|
||||
input.io.writeErr(input.launchFailureMessage);
|
||||
return true;
|
||||
return 1;
|
||||
}
|
||||
input.io.writeln(input.formatBackgroundStartMessage(pid));
|
||||
input.io.writeln(input.foregroundHint);
|
||||
return true;
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = input.startupTimeoutMs ?? CONNECTOR_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const state = input.readState(input.statePath);
|
||||
if (state && input.isRunning(state)) {
|
||||
return 0;
|
||||
}
|
||||
if (!isProcessRunning(pid)) {
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: child exited before becoming ready`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, CONNECTOR_STARTUP_POLL_MS),
|
||||
);
|
||||
}
|
||||
await terminateProcess(pid);
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected async stopAllFromStatePaths(
|
||||
@@ -177,13 +236,15 @@ export abstract class ConnectorBase<Options, State>
|
||||
) => Promise<ConnectStopResult>,
|
||||
): Promise<ConnectStopResult> {
|
||||
let stoppedProcesses = 0;
|
||||
let failedProcesses = 0;
|
||||
let stoppedSessions = 0;
|
||||
for (const statePath of statePaths) {
|
||||
const result = await stopInstance(statePath, io);
|
||||
stoppedProcesses += result.stoppedProcesses;
|
||||
failedProcesses += result.failedProcesses;
|
||||
stoppedSessions += result.stoppedSessions;
|
||||
}
|
||||
return { stoppedProcesses, stoppedSessions };
|
||||
return { stoppedProcesses, failedProcesses, stoppedSessions };
|
||||
}
|
||||
|
||||
protected async stopManagedProcess(input: {
|
||||
@@ -198,17 +259,31 @@ export abstract class ConnectorBase<Options, State>
|
||||
const state = input.readState(input.statePath);
|
||||
if (!state) {
|
||||
this.removeStateFile(input.statePath);
|
||||
return { stoppedProcesses: 0, stoppedSessions: 0 };
|
||||
return {
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
};
|
||||
}
|
||||
const pid = input.getPid(state);
|
||||
let stoppedProcesses = 0;
|
||||
if (await terminateProcess(input.getPid(state))) {
|
||||
if (await terminateProcess(pid)) {
|
||||
stoppedProcesses = 1;
|
||||
input.io.writeln(input.describeStoppedProcess(state));
|
||||
} else if (isProcessRunning(pid)) {
|
||||
input.io.writeErr(
|
||||
`[connect] failed to stop connector process pid=${pid}`,
|
||||
);
|
||||
return {
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
};
|
||||
}
|
||||
const stoppedSessions = await input.stopSessions(state);
|
||||
input.clearBindings?.(state);
|
||||
this.removeStateFile(input.statePath);
|
||||
return { stoppedProcesses, stoppedSessions };
|
||||
return { stoppedProcesses, failedProcesses: 0, stoppedSessions };
|
||||
}
|
||||
|
||||
protected parseOptionalInteger(
|
||||
|
||||
@@ -83,6 +83,24 @@ describe("spawnDetachedConnector", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks detached children and removes the hub-daemon-only environment flag", () => {
|
||||
const env = {
|
||||
CLINE_BUILD_ENV: "production",
|
||||
CLINE_RUN_AS_HUB_DAEMON: "1",
|
||||
UNCHANGED: "value",
|
||||
};
|
||||
|
||||
expect(
|
||||
__test__.buildDetachedConnectorEnv("CLINE_TELEGRAM_CONNECT_CHILD", env),
|
||||
).toEqual({
|
||||
CLINE_BUILD_ENV: "production",
|
||||
CLINE_CONNECTOR_DETACHED_CHILD: "1",
|
||||
CLINE_TELEGRAM_CONNECT_CHILD: "1",
|
||||
UNCHANGED: "value",
|
||||
});
|
||||
expect(env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSessionReplyText", () => {
|
||||
|
||||
@@ -10,11 +10,24 @@ import {
|
||||
import { join } from "node:path";
|
||||
import type { HubSessionClient, HubSessionRow } from "@cline/core";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
|
||||
"CLINE_CONNECTOR_DETACHED_CHILD";
|
||||
|
||||
/**
|
||||
* Internal success from a detached connect when an instance is already running.
|
||||
* `runConnectAdapter` maps this to exit 0 without changing persisted autostart
|
||||
* state.
|
||||
*/
|
||||
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -123,6 +136,19 @@ function buildDetachedConnectorCommand(
|
||||
};
|
||||
}
|
||||
|
||||
function buildDetachedConnectorEnv(
|
||||
childEnvKey: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const childEnv = {
|
||||
...withResolvedClineBuildEnv(env),
|
||||
[childEnvKey]: "1",
|
||||
[CLINE_CONNECTOR_DETACHED_CHILD_ENV]: "1",
|
||||
};
|
||||
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
export function resolveConnectorDebugLogPath(
|
||||
adapterName: string,
|
||||
instanceKey: string,
|
||||
@@ -190,10 +216,7 @@ export function spawnDetachedConnector(
|
||||
detachedLogFd === undefined
|
||||
? "ignore"
|
||||
: ["ignore", detachedLogFd, detachedLogFd],
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
},
|
||||
env: buildDetachedConnectorEnv(childEnvKey),
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
@@ -245,6 +268,7 @@ export function spawnDetachedConnector(
|
||||
export const __test__ = {
|
||||
buildDetachedConnectorArgs,
|
||||
buildDetachedConnectorCommand,
|
||||
buildDetachedConnectorEnv,
|
||||
};
|
||||
|
||||
export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,13 +5,25 @@ export type ConnectIo = {
|
||||
|
||||
export type ConnectStopResult = {
|
||||
stoppedProcesses: number;
|
||||
failedProcesses: number;
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectRunContext = {
|
||||
setPersistenceArgs: (args: string[]) => void;
|
||||
setPersistenceInstanceId: (instanceId: string) => void;
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
run(args: string[], io: ConnectIo): Promise<number>;
|
||||
run(
|
||||
args: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number>;
|
||||
validate(args: string[], io: ConnectIo): Promise<number>;
|
||||
showHelp(io: ConnectIo): void;
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
}
|
||||
|
||||
+20
-6
@@ -1,13 +1,19 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import { disposeAll, initVcr, isHubDaemonProcess } from "@cline/shared";
|
||||
import {
|
||||
disposeAll,
|
||||
initVcr,
|
||||
isHubDaemonProcess,
|
||||
setConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { logCliProcessError } from "./logging/errors";
|
||||
import {
|
||||
abortActiveRuntime,
|
||||
cleanupActiveRuntime,
|
||||
isAbortInProgress,
|
||||
} from "./runtime/active-runtime";
|
||||
import { resolveCliLaunchSpec } from "./utils/internal-launch";
|
||||
import { writeErr } from "./utils/output";
|
||||
|
||||
// Initialize VCR before any HTTP requests are made.
|
||||
@@ -16,7 +22,20 @@ 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 {
|
||||
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
|
||||
if (cliLaunchSpec) {
|
||||
setConnectorCliLaunchSpec({
|
||||
launcher: cliLaunchSpec.launcher,
|
||||
connectArgsPrefix: [...cliLaunchSpec.childArgsPrefix, "connect"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
let handlingFatalProcessError = false;
|
||||
const forwardSignalToRuntime = () => {
|
||||
@@ -57,11 +76,6 @@ if (!isMainThread) {
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
+257
-4
@@ -1,4 +1,6 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CliMigrationNotice,
|
||||
@@ -18,6 +20,7 @@ vi.mock("node:fs", async () => {
|
||||
const originalArgv = [...process.argv];
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const mockState = vi.hoisted(() => ({
|
||||
runAgentImports: 0,
|
||||
runInteractiveImports: 0,
|
||||
@@ -61,6 +64,13 @@ const kanbanMocks = vi.hoisted(() => ({
|
||||
const dashboardMocks = vi.hoisted(() => ({
|
||||
runDashboardCommand: vi.fn(),
|
||||
}));
|
||||
const connectMocks = vi.hoisted(() => ({
|
||||
formatAdapterList: vi.fn(() => ""),
|
||||
runConnectAdapter: vi.fn(async () => 0),
|
||||
runRestartConnector: vi.fn(async () => 0),
|
||||
runStopAllConnectors: vi.fn(async () => 0),
|
||||
runStopConnector: vi.fn(async () => 0),
|
||||
}));
|
||||
const migrationNoticeMocks = vi.hoisted(() => ({
|
||||
getClineCliMigrationNotice: vi.fn<
|
||||
(
|
||||
@@ -198,6 +208,7 @@ vi.mock("./runtime/prompt", () => ({
|
||||
}));
|
||||
vi.mock("./commands/kanban", () => kanbanMocks);
|
||||
vi.mock("./commands/dashboard", () => dashboardMocks);
|
||||
vi.mock("./commands/connect", () => connectMocks);
|
||||
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
||||
vi.mock("./commands/update", () => updateMocks);
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
@@ -208,8 +219,17 @@ vi.mock("./utils/telemetry", () => telemetryMocks);
|
||||
vi.mock("./utils/worktree", () => worktreeMocks);
|
||||
|
||||
describe("runCli lightweight command dispatch", () => {
|
||||
let globalSettingsRoot: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
process.exitCode = undefined;
|
||||
// Startup now reads persisted general settings; point the resolver at a
|
||||
// fresh temp file so the developer's real settings cannot leak in.
|
||||
globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-"));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
globalSettingsRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
mockState.runAgentImports = 0;
|
||||
mockState.runInteractiveImports = 0;
|
||||
mockState.runAgentCalls = 0;
|
||||
@@ -273,6 +293,16 @@ describe("runCli lightweight command dispatch", () => {
|
||||
kanbanMocks.launchKanban.mockResolvedValue(0);
|
||||
dashboardMocks.runDashboardCommand.mockReset();
|
||||
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
|
||||
connectMocks.formatAdapterList.mockReset();
|
||||
connectMocks.formatAdapterList.mockReturnValue("");
|
||||
connectMocks.runConnectAdapter.mockReset();
|
||||
connectMocks.runConnectAdapter.mockResolvedValue(0);
|
||||
connectMocks.runRestartConnector.mockReset();
|
||||
connectMocks.runRestartConnector.mockResolvedValue(0);
|
||||
connectMocks.runStopAllConnectors.mockReset();
|
||||
connectMocks.runStopAllConnectors.mockResolvedValue(0);
|
||||
connectMocks.runStopConnector.mockReset();
|
||||
connectMocks.runStopConnector.mockResolvedValue(0);
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
|
||||
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
|
||||
@@ -299,6 +329,16 @@ describe("runCli lightweight command dispatch", () => {
|
||||
afterEach(() => {
|
||||
process.exitCode = undefined;
|
||||
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (globalSettingsRoot) {
|
||||
rmSync(globalSettingsRoot, { recursive: true, force: true });
|
||||
globalSettingsRoot = undefined;
|
||||
}
|
||||
|
||||
process.argv = [...originalArgv];
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
value: originalStdinIsTTY,
|
||||
@@ -333,6 +373,55 @@ 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("routes connector restart arguments through the restart lifecycle", async () => {
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--restart",
|
||||
"telegram",
|
||||
"-k",
|
||||
"token",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
["-k", "token"],
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
|
||||
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a targeted connector restart to one instance", async () => {
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--restart-instance",
|
||||
"cline_bot",
|
||||
"telegram",
|
||||
"-k",
|
||||
"token",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
["-k", "token"],
|
||||
expect.any(Object),
|
||||
"cline_bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not load runtime modules for root update", async () => {
|
||||
@@ -848,6 +937,172 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("persisted general settings at startup", () => {
|
||||
function writePersistedSettings(settings: Record<string, unknown>) {
|
||||
const path = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
if (!path) {
|
||||
throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set");
|
||||
}
|
||||
writeFileSync(path, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
it("restores the persisted plan mode when no mode flag is provided", async () => {
|
||||
writePersistedSettings({ planActMode: "plan" });
|
||||
promptMocks.resolveSystemPrompt.mockClear();
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "plan" }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "plan" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit --act flag over the persisted plan mode", async () => {
|
||||
writePersistedSettings({ planActMode: "plan" });
|
||||
promptMocks.resolveSystemPrompt.mockClear();
|
||||
process.argv = ["bun", "src/index.ts", "--act"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "act" }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "act" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the persisted auto-approve setting as a runtime policy", async () => {
|
||||
writePersistedSettings({ toolAutoApprove: false });
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: false },
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit --auto-approve flag over the persisted setting", async () => {
|
||||
writePersistedSettings({ toolAutoApprove: false });
|
||||
process.argv = ["bun", "src/index.ts", "--auto-approve", "true"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores disabled compaction across restarts", async () => {
|
||||
writePersistedSettings({
|
||||
compactionEnabled: false,
|
||||
compactionStrategy: "basic",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: false },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the persisted compaction strategy across restarts", async () => {
|
||||
writePersistedSettings({
|
||||
compactionEnabled: true,
|
||||
compactionStrategy: "basic",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: true, strategy: "basic" },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit --compaction flag over the persisted mode", async () => {
|
||||
writePersistedSettings({ compactionEnabled: false });
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "agentic"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: true, strategy: "agentic" },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies persisted settings to single-prompt runs as well", async () => {
|
||||
writePersistedSettings({
|
||||
compactionEnabled: true,
|
||||
compactionStrategy: "basic",
|
||||
planActMode: "plan",
|
||||
});
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: true, strategy: "basic" },
|
||||
mode: "plan",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces chat view when resuming a session", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "--id", "sess_123"];
|
||||
|
||||
@@ -1302,7 +1557,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
@@ -1389,7 +1643,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();
|
||||
|
||||
@@ -1404,7 +1658,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
|
||||
+51
-12
@@ -43,6 +43,11 @@ import {
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { resolveCliReasoning } from "./utils/reasoning";
|
||||
import {
|
||||
resolveStartupCompactionMode,
|
||||
resolveStartupMode,
|
||||
resolveStartupToolAutoApprove,
|
||||
} from "./utils/startup-settings";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
@@ -362,6 +367,11 @@ export async function runCli(): Promise<void> {
|
||||
.description("Connect to an external channel")
|
||||
.argument("[channel]", "Channel to connect Cline CLI to")
|
||||
.option("--stop", "Kill all current channel connections")
|
||||
.option("--restart", "Restart a channel connection")
|
||||
.option(
|
||||
"--restart-instance <id>",
|
||||
"Restart one connector instance (used by daemon recovery)",
|
||||
)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.addHelpText(
|
||||
@@ -372,16 +382,32 @@ export async function runCli(): Promise<void> {
|
||||
const {
|
||||
formatAdapterList,
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
runStopConnector,
|
||||
} = await import("./commands/connect");
|
||||
const opts = connectCmd.opts();
|
||||
if (opts.stop) {
|
||||
if (opts.stop && (opts.restart || opts.restartInstance)) {
|
||||
io.writeErr("connect accepts only one of --stop or --restart");
|
||||
ctx.exitCode = 1;
|
||||
} else if (opts.stop) {
|
||||
if (adapter) {
|
||||
ctx.exitCode = await runStopConnector(adapter, io);
|
||||
} else {
|
||||
ctx.exitCode = await runStopAllConnectors(io);
|
||||
}
|
||||
} else if (opts.restart || opts.restartInstance) {
|
||||
if (!adapter) {
|
||||
io.writeErr("connect --restart requires a channel");
|
||||
ctx.exitCode = 1;
|
||||
} else {
|
||||
ctx.exitCode = await runRestartConnector(
|
||||
adapter,
|
||||
connectCmd.args.slice(1),
|
||||
io,
|
||||
opts.restartInstance,
|
||||
);
|
||||
}
|
||||
} else if (adapter) {
|
||||
// connectCmd.args = [adapter, ...passthroughFlags]. Pass only the
|
||||
// connector-specific flags (everything after the adapter name).
|
||||
@@ -844,14 +870,6 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
}
|
||||
setCurrentOutputMode(args.outputMode);
|
||||
const defaultToolAutoApprove = true;
|
||||
const effectiveToolAutoApprove =
|
||||
args.autoApproveOverride ?? defaultToolAutoApprove;
|
||||
const toolPolicies: Record<string, ToolPolicy> = {
|
||||
"*": {
|
||||
autoApprove: effectiveToolAutoApprove,
|
||||
},
|
||||
};
|
||||
|
||||
if (args.outputMode === "json" && (args.interactive || !args.prompt)) {
|
||||
writeErr(
|
||||
@@ -928,6 +946,27 @@ export async function runCli(): Promise<void> {
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// General settings toggled in the TUI /settings panel persist to the
|
||||
// global settings file; explicit CLI flags take precedence over the
|
||||
// persisted values, which in turn override the built-in defaults.
|
||||
const persistedGlobalSettings = coreServer.readGlobalSettings();
|
||||
const defaultToolAutoApprove = true;
|
||||
const effectiveToolAutoApprove = resolveStartupToolAutoApprove(
|
||||
args,
|
||||
persistedGlobalSettings,
|
||||
defaultToolAutoApprove,
|
||||
);
|
||||
const toolPolicies: Record<string, ToolPolicy> = {
|
||||
"*": {
|
||||
autoApprove: effectiveToolAutoApprove,
|
||||
},
|
||||
};
|
||||
const effectiveMode = resolveStartupMode(args, persistedGlobalSettings);
|
||||
const effectiveCompactionMode = resolveStartupCompactionMode(
|
||||
args,
|
||||
persistedGlobalSettings,
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -1086,13 +1125,13 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
explicitSystemPrompt: args.systemPrompt,
|
||||
providerId: provider,
|
||||
mode: args.mode ?? "act",
|
||||
mode: effectiveMode,
|
||||
}),
|
||||
execution: {
|
||||
maxConsecutiveMistakes: args.retries ?? 3,
|
||||
},
|
||||
checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG,
|
||||
compaction: buildCliCompactionConfig(args.compactionMode),
|
||||
compaction: buildCliCompactionConfig(effectiveCompactionMode),
|
||||
timeoutSeconds: args.timeoutSeconds,
|
||||
sandbox: sandboxEnabled,
|
||||
sandboxDataDir,
|
||||
@@ -1100,7 +1139,7 @@ export async function runCli(): Promise<void> {
|
||||
thinking: resolvedReasoning.thinking,
|
||||
reasoningEffort: resolvedReasoning.reasoningEffort,
|
||||
outputMode: args.outputMode,
|
||||
mode: args.mode,
|
||||
mode: effectiveMode,
|
||||
logger: loggerAdapter.core,
|
||||
loggerConfig: loggerAdapter.runtimeConfig,
|
||||
telemetry: getCliTelemetryService(loggerAdapter.core),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -163,6 +183,15 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
const mockSummary = "## Goal\nMocked agentic compaction summary";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn(() =>
|
||||
streamChunks([
|
||||
{ type: "text", id: "summary-1", text: mockSummary },
|
||||
{ type: "done", id: "summary-1", success: true },
|
||||
]),
|
||||
),
|
||||
});
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -189,6 +218,17 @@ describe("compactInteractiveMessages", () => {
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
|
||||
// The agentic strategy folds older messages into a summary message
|
||||
// built from the (mocked) summarizer output.
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
const [summaryMessage] = compactedMessages;
|
||||
const summaryText = Array.isArray(summaryMessage?.content)
|
||||
? summaryMessage.content
|
||||
.map((block) => ("text" in block ? block.text : ""))
|
||||
.join("\n")
|
||||
: String(summaryMessage?.content ?? "");
|
||||
expect(summaryText).toContain(mockSummary);
|
||||
});
|
||||
|
||||
it("reports compaction when core returns changed messages with the same count", async () => {
|
||||
|
||||
@@ -55,54 +55,44 @@ const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
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";
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
isClineNotSubscribedMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.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",
|
||||
},
|
||||
}));
|
||||
vi.mock(
|
||||
"@cline/core",
|
||||
async (importActual: () => Promise<typeof import("@cline/core")>) => ({
|
||||
...(await importActual()),
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
isClineNotSubscribedMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../utils/approval", () => ({
|
||||
askQuestionInTerminal: vi.fn(),
|
||||
|
||||
@@ -205,7 +205,9 @@ export async function runAgent(
|
||||
event.error.message.trim()
|
||||
) {
|
||||
displayedErrorMessages.add(
|
||||
formatCliErrorMessage(event.error.message).trim(),
|
||||
formatCliErrorMessage(event.error.message, {
|
||||
modelId: config.modelId,
|
||||
}).trim(),
|
||||
);
|
||||
}
|
||||
handleEvent(event, config);
|
||||
@@ -390,7 +392,9 @@ export async function runAgent(
|
||||
}
|
||||
|
||||
if (result.finishReason !== "completed") {
|
||||
const errorText = formatCliErrorMessage(result.text).trim();
|
||||
const errorText = formatCliErrorMessage(result.text, {
|
||||
modelId: config.modelId,
|
||||
}).trim();
|
||||
if (
|
||||
errorText &&
|
||||
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
|
||||
@@ -411,7 +415,7 @@ export async function runAgent(
|
||||
);
|
||||
process.exitCode = 0;
|
||||
} catch (err) {
|
||||
const message = formatCliErrorMessage(err);
|
||||
const message = formatCliErrorMessage(err, { modelId: config.modelId });
|
||||
logCliError(config.logger, "CLI task run failed", { error: err });
|
||||
writeErr(message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
getCurrentContextSize,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
setCompactionModeGlobally,
|
||||
setPlanActModeGlobally,
|
||||
setToolAutoApproveGlobally,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
@@ -712,15 +715,20 @@ export async function runInteractive(
|
||||
onTurnErrorReported: () => {},
|
||||
onAutoApproveChange: (enabled) => {
|
||||
setInteractiveAutoApprove(enabled);
|
||||
setToolAutoApproveGlobally(enabled);
|
||||
void refreshInteractiveSessionPolicies();
|
||||
},
|
||||
onCompactionModeChange: async (mode) => {
|
||||
await sessionRuntime.ensureReady();
|
||||
applyCliCompactionMode(config, mode);
|
||||
setCompactionModeGlobally(mode);
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onModeChange: async (mode) => {
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
// Persist the user's choice immediately, even when the switch is
|
||||
// deferred until the current turn aborts, so it survives restarts.
|
||||
setPlanActModeGlobally(mode);
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
|
||||
@@ -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,113 @@ 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"),
|
||||
});
|
||||
|
||||
type Background = {
|
||||
mode: number | undefined;
|
||||
color: number | undefined;
|
||||
};
|
||||
type TerminalSnapshot = ReturnType<Terminal["serialize"]> & {
|
||||
baseY: number;
|
||||
};
|
||||
const backgroundsEqual = (
|
||||
left: Background | undefined,
|
||||
right: Background | undefined,
|
||||
): boolean => left?.mode === right?.mode && left?.color === right?.color;
|
||||
const snapshotTerminal = (terminal: Terminal): TerminalSnapshot => ({
|
||||
...terminal.serialize(),
|
||||
baseY: terminal.getCursor().baseY,
|
||||
});
|
||||
|
||||
const findTextPosition = (
|
||||
terminal: Terminal,
|
||||
text: string,
|
||||
): { x: number; y: number } => {
|
||||
const lines = terminal.getViewableBuffer();
|
||||
for (let y = 0; y < lines.length; y++) {
|
||||
const x = lines[y].join("").indexOf(text);
|
||||
if (x !== -1) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to locate visible text: ${text}`);
|
||||
};
|
||||
|
||||
const getCellBackground = (
|
||||
snapshot: TerminalSnapshot,
|
||||
position: { x: number; y: number },
|
||||
): Background => {
|
||||
const targetRow = snapshot.baseY + position.y;
|
||||
let background: Background = { mode: undefined, color: undefined };
|
||||
|
||||
for (let y = snapshot.baseY; y <= targetRow; y++) {
|
||||
for (let x = 0; x < TERMINAL_WIDE.columns; x++) {
|
||||
const shift = snapshot.shifts.get(`${x},${y}`);
|
||||
if (shift?.bgColorMode !== undefined) {
|
||||
background = { mode: shift.bgColorMode, color: shift.bgColor };
|
||||
}
|
||||
if (x === position.x && y === targetRow) {
|
||||
return background;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cell is outside the visible terminal: ${position.x},${position.y}`,
|
||||
);
|
||||
};
|
||||
|
||||
// @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);
|
||||
const terminalBeforeDialog = snapshotTerminal(terminal);
|
||||
await typeAndSubmit(terminal, "/help");
|
||||
await expectVisible(terminal, "Keyboard Shortcuts");
|
||||
const dialogPosition = findTextPosition(terminal, "Keyboard Shortcuts");
|
||||
const backgroundAtDialogPosition = getCellBackground(
|
||||
terminalBeforeDialog,
|
||||
dialogPosition,
|
||||
);
|
||||
const dialogBackground = getCellBackground(
|
||||
snapshotTerminal(terminal),
|
||||
dialogPosition,
|
||||
);
|
||||
expect(dialogBackground).not.toEqual(backgroundAtDialogPosition);
|
||||
|
||||
terminal.keyEscape();
|
||||
await expectNotVisible(terminal, "Keyboard Shortcuts");
|
||||
|
||||
// The panel unmounts a frame after its content. Poll the title's former
|
||||
// position until the background captured from the visible panel is gone.
|
||||
const deadline = Date.now() + 10_000;
|
||||
let backgroundAfterDialog = getCellBackground(
|
||||
snapshotTerminal(terminal),
|
||||
dialogPosition,
|
||||
);
|
||||
while (
|
||||
!backgroundsEqual(backgroundAfterDialog, backgroundAtDialogPosition) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
backgroundAfterDialog = getCellBackground(
|
||||
snapshotTerminal(terminal),
|
||||
dialogPosition,
|
||||
);
|
||||
}
|
||||
expect(backgroundAfterDialog).toEqual(backgroundAtDialogPosition);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ClineSubscriptionPlan } from "@cline/core";
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClineFreeModelLimitResetTime,
|
||||
} from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -8,6 +11,8 @@ import {
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineFreeModelLimitErrorMessage,
|
||||
isClineFreePromotionEndedErrorMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
@@ -29,6 +34,7 @@ import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
parseAskQuestionInput,
|
||||
parseEditorInput,
|
||||
@@ -129,12 +135,13 @@ function formatToolParams(
|
||||
case "read_files": {
|
||||
const info = parseReadFilesInput(rawInput);
|
||||
if (!info?.files.length) return fallback;
|
||||
const keys = buildReadFilesKeys(info.files);
|
||||
return info.files.map((f, i) => {
|
||||
const sl = f.startLine != null ? String(f.startLine) : "undefined";
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={`${i}:${f.path}`}>
|
||||
<span key={keys[i]}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
@@ -478,14 +485,6 @@ function ClinePassLimitErrorView(props: {
|
||||
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 " />
|
||||
@@ -502,6 +501,71 @@ function ClinePassLimitErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineFreeModelLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
}) {
|
||||
const resetTime = extractClineFreeModelLimitResetTime(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">Daily free model limit reached</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="You've reached today's free usage limit for this model."
|
||||
/>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={
|
||||
resetTime
|
||||
? `Try again in ${resetTime} or select another model.`
|
||||
: "Try again later or select another model."
|
||||
}
|
||||
/>
|
||||
<text fg="gray">Open the model selector with /model.</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Free model promotion ended</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="The free promotion for this model has ended and it is no longer available."
|
||||
/>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Select another model to continue."
|
||||
/>
|
||||
<text fg="gray">Open the model selector with /model.</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -626,6 +690,17 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClineFreeModelLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineFreeModelLimitErrorView
|
||||
defaultFg={defaultFg}
|
||||
message={entry.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
|
||||
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import type React from "react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
parseEditorInput,
|
||||
parseReadFilesInput,
|
||||
@@ -22,13 +23,14 @@ export function formatApprovalParams(
|
||||
case "read_files": {
|
||||
const info = parseReadFilesInput(rawInput);
|
||||
if (!info?.files.length) break;
|
||||
const keys = buildReadFilesKeys(info.files);
|
||||
return info.files.map((f, i) => {
|
||||
const range =
|
||||
f.startLine != null
|
||||
? ` lines ${f.startLine}-${f.endLine ?? "end"}`
|
||||
: "";
|
||||
return (
|
||||
<text key={f.path} fg="gray" selectable>
|
||||
<text key={keys[i]} fg="gray" selectable>
|
||||
{" "}
|
||||
{shortenPath(f.path, 60)}
|
||||
{range && <span fg="gray">{range}</span>}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AgentEventDeps {
|
||||
}) => void;
|
||||
onTurnErrorReported: TuiProps["onTurnErrorReported"];
|
||||
verbose: boolean;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
@@ -45,6 +46,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
addUsageDelta,
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
modelId,
|
||||
} = deps;
|
||||
|
||||
// Compaction dividers that arrived while an assistant message was still
|
||||
@@ -224,7 +226,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
if (!event.recoverable || verbose) {
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error),
|
||||
text: formatCliErrorMessage(event.error, { modelId }),
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -289,6 +291,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
addUsageDelta,
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
modelId,
|
||||
closeToolEntry,
|
||||
finalizeDanglingCompactionEntry,
|
||||
flushPendingCompactionEntries,
|
||||
|
||||
@@ -38,6 +38,7 @@ export function usePromptInputController(input: {
|
||||
onSubmit: TuiProps["onSubmit"];
|
||||
initialPrompt?: string;
|
||||
providerId: string;
|
||||
modelId?: string;
|
||||
configVerbose: boolean;
|
||||
refreshRepoStatus: () => void;
|
||||
setAppView: (view: AppView) => void;
|
||||
@@ -50,6 +51,7 @@ export function usePromptInputController(input: {
|
||||
onSubmit,
|
||||
initialPrompt,
|
||||
providerId,
|
||||
modelId,
|
||||
configVerbose,
|
||||
refreshRepoStatus,
|
||||
setAppView,
|
||||
@@ -377,7 +379,7 @@ export function usePromptInputController(input: {
|
||||
if (!turnErrorReportedRef.current) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(error),
|
||||
text: formatCliErrorMessage(error, { modelId }),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -393,6 +395,7 @@ export function usePromptInputController(input: {
|
||||
clearPasteAttachments,
|
||||
configVerbose,
|
||||
inputHistory,
|
||||
modelId,
|
||||
onSubmit,
|
||||
providerId,
|
||||
refreshRepoStatus,
|
||||
|
||||
@@ -724,6 +724,7 @@ function App(props: TuiProps) {
|
||||
addUsageDelta: session.addUsageDelta,
|
||||
onTurnErrorReported: props.onTurnErrorReported,
|
||||
verbose: props.config.verbose ?? false,
|
||||
modelId: props.config.modelId,
|
||||
});
|
||||
|
||||
const promptInput = usePromptInputController({
|
||||
@@ -733,6 +734,7 @@ function App(props: TuiProps) {
|
||||
onSubmit: props.onSubmit,
|
||||
initialPrompt: props.initialPrompt,
|
||||
providerId: props.config.providerId,
|
||||
modelId: props.config.modelId,
|
||||
configVerbose: props.config.verbose ?? false,
|
||||
refreshRepoStatus,
|
||||
setAppView,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
|
||||
|
||||
describe("buildReadFilesKeys", () => {
|
||||
it("produces unique keys when the same path is read twice", () => {
|
||||
const info = parseReadFilesInput({
|
||||
files: [{ path: "/a/SKILL.md" }, { path: "/a/SKILL.md" }],
|
||||
});
|
||||
const keys = buildReadFilesKeys(info?.files ?? []);
|
||||
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("produces unique keys for duplicate paths from the file_paths shape", () => {
|
||||
const info = parseReadFilesInput({
|
||||
file_paths: ["/a/SKILL.md", "/a/SKILL.md", "/b/SKILL.md"],
|
||||
});
|
||||
const keys = buildReadFilesKeys(info?.files ?? []);
|
||||
|
||||
expect(keys).toHaveLength(3);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("keeps distinct paths in unique keys", () => {
|
||||
const keys = buildReadFilesKeys([{ path: "/a.ts" }, { path: "/b.ts" }]);
|
||||
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
});
|
||||
|
||||
it("returns no keys for an empty list", () => {
|
||||
expect(buildReadFilesKeys([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,12 @@ export function parseReadFilesInput(input: unknown): ReadFilesInfo | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A read_files call can list the same path more than once, so the raw path is
|
||||
// not a unique React key. Prefix the array index to keep keys unique per row.
|
||||
export function buildReadFilesKeys(files: { path: string }[]): string[] {
|
||||
return files.map((f, i) => `${i}:${f.path}`);
|
||||
}
|
||||
|
||||
export interface RunCommandsInfo {
|
||||
commands: string[];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClineFreeModelLimitMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineFreeModelLimitErrorMessage,
|
||||
isClineFreePromotionEndedErrorMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
@@ -48,6 +51,9 @@ describe("cline-pass-errors", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
|
||||
"deepseek-v4-flash",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
@@ -67,4 +73,44 @@ describe("cline-pass-errors", () => {
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("recognizes and formats daily free model limits without usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
|
||||
|
||||
expect(isClineFreeModelLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClineFreeModelLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClineFreeModelLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).not.toContain("Error 429");
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Try again in 23h 59m",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"select another model",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
|
||||
"usage-based billing",
|
||||
);
|
||||
expect(
|
||||
isClineFreeModelLimitErrorMessage(getCliClineFreeModelLimitMessage(raw)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("formats model-not-found errors for removed free models", () => {
|
||||
const raw = new Error("Error 404: model not found");
|
||||
|
||||
expect(
|
||||
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
|
||||
).toContain("Free model promotion ended");
|
||||
expect(
|
||||
isClineFreePromotionEndedErrorMessage(
|
||||
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
formatCliErrorMessage(raw, { modelId: "vendor/retired-model" }),
|
||||
).toBe(raw.message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClineFreeModelLimitResetTime,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineFreeModelLimitError,
|
||||
isClineFreeModelLimitMessage,
|
||||
isClineModelNotFoundMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
@@ -39,6 +43,31 @@ export function getCliClinePassLimitMessage(message: string): string {
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODEL_PREFIX = "cline-free/";
|
||||
const CLINE_FREE_PROMOTION_ENDED_HEADER = "Free model promotion ended";
|
||||
const CLINE_FREE_MODEL_LIMIT_HEADER = "Daily free model limit reached";
|
||||
|
||||
export function getCliClineFreePromotionEndedMessage(): string {
|
||||
return [
|
||||
CLINE_FREE_PROMOTION_ENDED_HEADER,
|
||||
"The free promotion for this model has ended and it is no longer available.",
|
||||
"Select another model to continue.",
|
||||
"Open the model selector with /model.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function getCliClineFreeModelLimitMessage(message: string): string {
|
||||
const resetTime = extractClineFreeModelLimitResetTime(message);
|
||||
return [
|
||||
CLINE_FREE_MODEL_LIMIT_HEADER,
|
||||
"You've reached today's free usage limit for this model.",
|
||||
resetTime
|
||||
? `Try again in ${resetTime} or select another model.`
|
||||
: "Try again later or select another model.",
|
||||
"Open the model selector with /model.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
@@ -114,7 +143,55 @@ export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
// Detects that a deleted free model was requested: the backend answers "model
|
||||
// not found" once a free promotion ends and the cline-free/ model is removed.
|
||||
// The modelId gate keeps regular model-not-found errors on their generic path.
|
||||
export function isClineFreePromotionEndedErrorMessage(
|
||||
error: unknown,
|
||||
modelId?: string,
|
||||
): boolean {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "";
|
||||
if (
|
||||
message
|
||||
.toLowerCase()
|
||||
.includes(CLINE_FREE_PROMOTION_ENDED_HEADER.toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!modelId?.startsWith(CLINE_FREE_MODEL_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
return isClineModelNotFoundMessage(message);
|
||||
}
|
||||
|
||||
export function isClineFreeModelLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClineFreeModelLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineFreeModelLimitError" ||
|
||||
isClineFreeModelLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(error
|
||||
.toLowerCase()
|
||||
.includes(CLINE_FREE_MODEL_LIMIT_HEADER.toLowerCase()) ||
|
||||
isClineFreeModelLimitMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(
|
||||
error: unknown,
|
||||
options?: { modelId?: string },
|
||||
): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
@@ -126,6 +203,14 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (isClineFreeModelLimitErrorMessage(error)) {
|
||||
return getCliClineFreeModelLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (isClineFreePromotionEndedErrorMessage(error, options?.modelId)) {
|
||||
return getCliClineFreePromotionEndedMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@ 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", () => {
|
||||
@@ -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",
|
||||
|
||||
@@ -222,6 +222,37 @@ describe("handleEvent text formatting", () => {
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("formats daily free model limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("Daily free model limit reached");
|
||||
expect(errorOutput).toContain("select another model");
|
||||
expect(errorOutput).not.toContain("usage-based billing");
|
||||
});
|
||||
|
||||
it("formats removed free model errors using the configured model id", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error("Error 404: model not found"),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{ modelId: "cline-free/retired-model" } as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("Free model promotion ended");
|
||||
expect(errorOutput).toContain("Select another model");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -204,7 +204,9 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
writeErr(
|
||||
formatCliErrorMessage(event.error, { modelId: config.modelId }),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -39,6 +39,36 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "cline-free/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "cline-free/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("parseArgs", () => {
|
||||
interactive: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
modeExplicitlySet: false,
|
||||
sandbox: false,
|
||||
acpMode: false,
|
||||
thinking: false,
|
||||
@@ -220,6 +221,15 @@ describe("parseArgs", () => {
|
||||
expect(parsedYolo.autoApproveOverride).toBe(true);
|
||||
});
|
||||
|
||||
it("marks explicit mode flags so persisted settings do not override them", () => {
|
||||
expect(parseArgs([]).modeExplicitlySet).toBe(false);
|
||||
expect(parseArgs(["Audit the repo"]).modeExplicitlySet).toBe(false);
|
||||
expect(parseArgs(["--plan"]).modeExplicitlySet).toBe(true);
|
||||
expect(parseArgs(["--act"]).modeExplicitlySet).toBe(true);
|
||||
expect(parseArgs(["--yolo"]).modeExplicitlySet).toBe(true);
|
||||
expect(parseArgs(["--zen", "do it"]).modeExplicitlySet).toBe(true);
|
||||
});
|
||||
|
||||
it("parses --zen flag for background hub dispatch", () => {
|
||||
const parsedLong = parseArgs(["--zen", "do it"]);
|
||||
expect(parsedLong.mode).toBe("zen");
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { GlobalSettings } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveStartupCompactionMode,
|
||||
resolveStartupMode,
|
||||
resolveStartupToolAutoApprove,
|
||||
} from "./startup-settings";
|
||||
|
||||
function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
autoUpdateEnabled: true,
|
||||
telemetryOptOut: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveStartupMode", () => {
|
||||
it("uses the parsed default when nothing is persisted", () => {
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "act", modeExplicitlySet: false },
|
||||
makeSettings(),
|
||||
),
|
||||
).toBe("act");
|
||||
});
|
||||
|
||||
it("restores the persisted plan/act mode when no mode flag is provided", () => {
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "act", modeExplicitlySet: false },
|
||||
makeSettings({ planActMode: "plan" }),
|
||||
),
|
||||
).toBe("plan");
|
||||
});
|
||||
|
||||
it("prefers an explicit mode flag over the persisted mode", () => {
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "act", modeExplicitlySet: true },
|
||||
makeSettings({ planActMode: "plan" }),
|
||||
),
|
||||
).toBe("act");
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "yolo", modeExplicitlySet: true },
|
||||
makeSettings({ planActMode: "plan" }),
|
||||
),
|
||||
).toBe("yolo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupToolAutoApprove", () => {
|
||||
it("falls back to the built-in default when nothing is persisted", () => {
|
||||
expect(resolveStartupToolAutoApprove({}, makeSettings(), true)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the persisted auto-approve setting", () => {
|
||||
expect(
|
||||
resolveStartupToolAutoApprove(
|
||||
{},
|
||||
makeSettings({ toolAutoApprove: false }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers an explicit --auto-approve flag over the persisted setting", () => {
|
||||
expect(
|
||||
resolveStartupToolAutoApprove(
|
||||
{ autoApproveOverride: true },
|
||||
makeSettings({ toolAutoApprove: false }),
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveStartupToolAutoApprove(
|
||||
{ autoApproveOverride: false },
|
||||
makeSettings({ toolAutoApprove: true }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupCompactionMode", () => {
|
||||
it("returns undefined so Core's default applies when nothing is persisted", () => {
|
||||
expect(resolveStartupCompactionMode({}, makeSettings())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores the persisted strategy", () => {
|
||||
expect(
|
||||
resolveStartupCompactionMode(
|
||||
{},
|
||||
makeSettings({ compactionEnabled: true, compactionStrategy: "basic" }),
|
||||
),
|
||||
).toBe("basic");
|
||||
});
|
||||
|
||||
it("restores the off state even when a strategy is retained on disk", () => {
|
||||
expect(
|
||||
resolveStartupCompactionMode(
|
||||
{},
|
||||
makeSettings({ compactionEnabled: false, compactionStrategy: "basic" }),
|
||||
),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("prefers an explicit --compaction flag over the persisted mode", () => {
|
||||
expect(
|
||||
resolveStartupCompactionMode(
|
||||
{ compactionMode: "agentic" },
|
||||
makeSettings({ compactionEnabled: false }),
|
||||
),
|
||||
).toBe("agentic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GlobalSettings } from "@cline/core";
|
||||
import type { CliAgentMode, CliCompactionMode, ParsedArgs } from "./types";
|
||||
|
||||
/**
|
||||
* Resolves general settings at CLI startup with the precedence
|
||||
* explicit CLI flag -> persisted global setting -> built-in default,
|
||||
* so choices made in the TUI /settings panel survive restarts (see
|
||||
* https://github.com/cline/cline/issues/12158).
|
||||
*/
|
||||
|
||||
export function resolveStartupMode(
|
||||
args: Pick<ParsedArgs, "mode" | "modeExplicitlySet">,
|
||||
settings: GlobalSettings,
|
||||
): CliAgentMode {
|
||||
if (args.modeExplicitlySet) {
|
||||
return args.mode;
|
||||
}
|
||||
return settings.planActMode ?? args.mode;
|
||||
}
|
||||
|
||||
export function resolveStartupToolAutoApprove(
|
||||
args: Pick<ParsedArgs, "autoApproveOverride">,
|
||||
settings: GlobalSettings,
|
||||
defaultToolAutoApprove: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
args.autoApproveOverride ??
|
||||
settings.toolAutoApprove ??
|
||||
defaultToolAutoApprove
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns undefined when neither a flag nor a persisted value exists, so
|
||||
* callers fall through to Core's compaction default.
|
||||
*/
|
||||
export function resolveStartupCompactionMode(
|
||||
args: Pick<ParsedArgs, "compactionMode">,
|
||||
settings: GlobalSettings,
|
||||
): CliCompactionMode | undefined {
|
||||
if (args.compactionMode) {
|
||||
return args.compactionMode;
|
||||
}
|
||||
if (settings.compactionEnabled === false) {
|
||||
return "off";
|
||||
}
|
||||
return settings.compactionStrategy;
|
||||
}
|
||||
@@ -71,6 +71,8 @@ export interface ParsedArgs {
|
||||
interactive: boolean;
|
||||
outputMode: CliOutputMode;
|
||||
mode: CliAgentMode;
|
||||
/** Whether a mode flag (--plan/--act/--yolo/--zen) was explicitly provided */
|
||||
modeExplicitlySet?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
invalidTimeoutSeconds?: string;
|
||||
thinking: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
import {
|
||||
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
|
||||
readConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __test__, configureConnectorCliLaunch } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLaunchSpec === undefined) {
|
||||
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
} else {
|
||||
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the CLI connect command for the detached daemon", () => {
|
||||
const expected = __test__.buildCliConnectCommand([]);
|
||||
|
||||
configureConnectorCliLaunch();
|
||||
|
||||
expect(readConnectorCliLaunchSpec()).toEqual({
|
||||
launcher: expected.launcher,
|
||||
connectArgsPrefix: expected.childArgs,
|
||||
cwd: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
@@ -94,4 +120,47 @@ describe("connector launch command", () => {
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds connector start arguments through the shared platform definition", () => {
|
||||
expect(
|
||||
__test__.buildConnectorStartArgs({
|
||||
channel: "telegram",
|
||||
values: { "-k": " 123456:token " },
|
||||
security: {
|
||||
enabled: true,
|
||||
values: { userId: " 987654321 " },
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
"telegram",
|
||||
"-k",
|
||||
"123456:token",
|
||||
"--allowed-user-id",
|
||||
"987654321",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the atomic restart command for an active connector", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "restart"),
|
||||
).toEqual(["--restart", "telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("starts an inactive connector directly", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "start"),
|
||||
).toEqual(["telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("rejects a channel-wide restart when multiple instances are active", () => {
|
||||
expect(() => __test__.resolveConnectorLaunchMode("telegram", 2)).toThrow(
|
||||
"cannot safely restart telegram: 2 instances are active",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when connector readiness times out", async () => {
|
||||
await expect(
|
||||
__test__.waitForConnectorState(() => false, 0),
|
||||
).rejects.toThrow("connector did not reach expected state within 0ms");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { listActiveConnectors } from "@cline/core";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
buildConnectorConnectArgs,
|
||||
CONNECTOR_PLATFORMS,
|
||||
listConnectorCatalog,
|
||||
setConnectorCliLaunchSpec,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
WebviewConnectorChannel,
|
||||
WebviewConnectorChannelsResponse,
|
||||
@@ -78,12 +79,21 @@ function buildCliConnectCommand(
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function configureConnectorCliLaunch(): void {
|
||||
const command = buildCliConnectCommand([]);
|
||||
setConnectorCliLaunchSpec({
|
||||
launcher: command.launcher,
|
||||
connectArgsPrefix: command.childArgs,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
|
||||
(platform) => supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
@@ -154,12 +164,15 @@ async function waitForConnectorState(
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`connector did not reach expected state within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -177,32 +190,40 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
const securityInput = asRecord(args?.security);
|
||||
const rawSecurityValues = asRecord(securityInput?.values) ?? {};
|
||||
const securityValues: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(rawSecurityValues)) {
|
||||
if (typeof value === "string") {
|
||||
securityValues[key] = value.trim();
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
return [
|
||||
channel,
|
||||
...buildConnectorConnectArgs(platform, fieldValues, {
|
||||
enabled: securityInput?.enabled === true,
|
||||
values: securityValues,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function buildConnectorLaunchArgs(
|
||||
cliArgs: string[],
|
||||
mode: "start" | "restart",
|
||||
): string[] {
|
||||
return mode === "restart" ? ["--restart", ...cliArgs] : cliArgs;
|
||||
}
|
||||
|
||||
function resolveConnectorLaunchMode(
|
||||
channel: string,
|
||||
activeCount: number,
|
||||
): "start" | "restart" {
|
||||
if (activeCount > 1) {
|
||||
throw new Error(
|
||||
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
|
||||
);
|
||||
}
|
||||
return cliArgs;
|
||||
return activeCount === 1 ? "restart" : "start";
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
@@ -210,7 +231,13 @@ export async function startConnectorChannel(
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
const activeCount = listActiveConnectors().filter(
|
||||
(connector) => connector.type === channel,
|
||||
).length;
|
||||
const mode = resolveConnectorLaunchMode(channel, activeCount);
|
||||
const result = await runCliConnectCommand(
|
||||
buildConnectorLaunchArgs(cliArgs, mode),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
@@ -227,7 +254,11 @@ export async function startConnectorChannel(
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
buildConnectorLaunchArgs,
|
||||
buildConnectorStartArgs,
|
||||
normalizeConnectorError,
|
||||
resolveConnectorLaunchMode,
|
||||
waitForConnectorState,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
@@ -241,7 +272,9 @@ export async function stopConnectorChannel(
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
// `--stop` must precede the channel: the connect command uses
|
||||
// passThroughOptions, so flags after the channel go to the adapter.
|
||||
const result = await runCliConnectCommand(["--stop", channel]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
rejectAllPendingApprovals,
|
||||
requestToolApprovalFromWebview,
|
||||
} from "./approvals";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import { workspaceRoot } from "./deps";
|
||||
import {
|
||||
formatClientName,
|
||||
@@ -90,6 +91,7 @@ export async function syncHubClientsAndSessions(
|
||||
}
|
||||
|
||||
export async function attachHub(ctx: HubContext): Promise<void> {
|
||||
configureConnectorCliLaunch();
|
||||
const hub = await ensureDetachedHubServer(workspaceRoot);
|
||||
ctx.hubUrl = hub.url;
|
||||
ctx.hubAuthToken = hub.authToken;
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
|
||||
readHubScheduleMode,
|
||||
} from "@cline/shared";
|
||||
import { asTrimmedString, toPositiveInt } from "./utils";
|
||||
|
||||
let scheduleService: HubScheduleService | undefined;
|
||||
@@ -36,6 +42,31 @@ async function clientCommand(
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function routineScheduleTiming(
|
||||
args?: Record<string, unknown>,
|
||||
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
|
||||
if (args?.schedule_type === "once") {
|
||||
const runAt =
|
||||
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
|
||||
return Number.isFinite(runAt)
|
||||
? {
|
||||
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
return cronPattern ? { cronPattern } : undefined;
|
||||
}
|
||||
|
||||
function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const values = value
|
||||
.map((item) => asTrimmedString(item))
|
||||
.filter((item): item is string => item !== undefined);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -76,23 +107,23 @@ export async function handleRoutineScheduleCommand(
|
||||
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
cronPattern,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
mode: readHubScheduleMode(args, "yolo"),
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
systemPrompt: asTrimmedString(args?.system_prompt),
|
||||
@@ -100,12 +131,7 @@ export async function handleRoutineScheduleCommand(
|
||||
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags:
|
||||
Array.isArray(args?.tags) && args.tags.length > 0
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: undefined,
|
||||
tags: asTrimmedStringArray(args?.tags),
|
||||
});
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
@@ -113,25 +139,26 @@ export async function handleRoutineScheduleCommand(
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "update_routine_schedule") {
|
||||
const mode = readHubScheduleMode(args);
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
cronPattern,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd) ?? null,
|
||||
systemPrompt:
|
||||
@@ -148,11 +175,7 @@ export async function handleRoutineScheduleCommand(
|
||||
: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: Array.isArray(args?.tags)
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: [],
|
||||
tags: asTrimmedStringArray(args?.tags) ?? [],
|
||||
});
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
if (
|
||||
normalizedType === "code-sidecar" ||
|
||||
normalizedType === "code-sidecar-approvals" ||
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { listActiveConnectors } from "@cline/core";
|
||||
import type { WebviewHubState } from "../webview-protocol";
|
||||
import {
|
||||
clientSummariesPayload,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
@@ -26,7 +27,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid": "11.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
Circle,
|
||||
Eye,
|
||||
@@ -72,6 +77,7 @@ interface RoutineSchedule {
|
||||
scheduleId: string;
|
||||
name: string;
|
||||
cronPattern: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -145,7 +151,7 @@ interface ProcessContext {
|
||||
}
|
||||
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
cline: [CLINE_DEFAULT_MODEL_ID],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
@@ -170,13 +176,9 @@ interface RoutineFormState {
|
||||
prompt: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: "act" | "plan";
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
maxIterations: string;
|
||||
timeoutSeconds: string;
|
||||
maxParallel: string;
|
||||
tags: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -203,6 +205,15 @@ function formatDateTime(value?: DateTimeValue | null): string {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function getOneTimeScheduleRunAt(
|
||||
schedule: RoutineSchedule,
|
||||
): number | undefined {
|
||||
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
|
||||
return typeof runAt === "number" && Number.isFinite(runAt)
|
||||
? runAt
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatScheduleModel(schedule: RoutineSchedule): string {
|
||||
const provider =
|
||||
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
|
||||
@@ -226,7 +237,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
|
||||
model:
|
||||
schedule.modelSelection?.modelId?.trim() ||
|
||||
schedule.model?.trim() ||
|
||||
"openai/gpt-5.3-codex",
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,20 +252,27 @@ function formatExecutionResult(execution?: RoutineExecution): string {
|
||||
return when === "-" ? status : `${status} at ${when}`;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(text: string): number | undefined {
|
||||
const trimmed = text.trim();
|
||||
function asTrimmedFormString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(value: unknown): number | undefined {
|
||||
const trimmed = asTrimmedFormString(value);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
const parsedValue = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] | undefined {
|
||||
const tags = text
|
||||
function parseTags(value: unknown): string[] | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const tags = value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
@@ -382,14 +400,10 @@ export function RoutineSchedulesContent() {
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
mode: "act",
|
||||
model: CLINE_DEFAULT_MODEL_ID,
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
systemPrompt: "",
|
||||
maxIterations: "",
|
||||
timeoutSeconds: "",
|
||||
maxParallel: "1",
|
||||
tags: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -702,13 +716,9 @@ export function RoutineSchedulesContent() {
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
provider: preferredProvider,
|
||||
model: preferredModel,
|
||||
mode: "act",
|
||||
workspaceRoot: context.workspaceRoot || context.cwd,
|
||||
cwd: context.cwd || "",
|
||||
systemPrompt: "",
|
||||
maxIterations: "",
|
||||
timeoutSeconds: "",
|
||||
maxParallel: "1",
|
||||
tags: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -716,6 +726,9 @@ export function RoutineSchedulesContent() {
|
||||
};
|
||||
|
||||
const openEditDialog = (schedule: RoutineSchedule) => {
|
||||
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
|
||||
return;
|
||||
}
|
||||
const { provider, model } = getScheduleProviderModel(schedule);
|
||||
const parsedCron = parseCronPattern(schedule.cronPattern);
|
||||
setEditingSchedule(schedule);
|
||||
@@ -738,22 +751,12 @@ export function RoutineSchedulesContent() {
|
||||
prompt: schedule.prompt,
|
||||
provider,
|
||||
model,
|
||||
mode: schedule.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: schedule.workspaceRoot ?? "",
|
||||
cwd: schedule.cwd ?? "",
|
||||
systemPrompt: schedule.systemPrompt ?? "",
|
||||
maxIterations:
|
||||
typeof schedule.maxIterations === "number"
|
||||
? String(schedule.maxIterations)
|
||||
: "",
|
||||
timeoutSeconds:
|
||||
typeof schedule.timeoutSeconds === "number"
|
||||
? String(schedule.timeoutSeconds)
|
||||
: "",
|
||||
maxParallel:
|
||||
typeof schedule.maxParallel === "number"
|
||||
? String(schedule.maxParallel)
|
||||
: "1",
|
||||
tags: schedule.tags?.join(",") ?? "",
|
||||
enabled: schedule.enabled,
|
||||
});
|
||||
@@ -761,7 +764,7 @@ export function RoutineSchedulesContent() {
|
||||
};
|
||||
|
||||
const submitCreateForm = async () => {
|
||||
const name = createForm.name.trim();
|
||||
const name = asTrimmedFormString(createForm.name);
|
||||
if (!name) {
|
||||
setCreateFormError("Routine name is required.");
|
||||
return;
|
||||
@@ -775,12 +778,12 @@ export function RoutineSchedulesContent() {
|
||||
setCreateFormError("Select at least one day and a valid time.");
|
||||
return;
|
||||
}
|
||||
const prompt = createForm.prompt.trim();
|
||||
const prompt = asTrimmedFormString(createForm.prompt);
|
||||
if (!prompt) {
|
||||
setCreateFormError("Prompt is required.");
|
||||
return;
|
||||
}
|
||||
const workspaceRoot = createForm.workspaceRoot.trim();
|
||||
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
setCreateFormError("Workspace root is required.");
|
||||
return;
|
||||
@@ -789,18 +792,17 @@ export function RoutineSchedulesContent() {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const provider =
|
||||
normalizeProviderId(createForm.provider) ||
|
||||
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
|
||||
availableProviders[0] ||
|
||||
"cline";
|
||||
const model =
|
||||
createForm.model.trim() ||
|
||||
asTrimmedFormString(createForm.model) ||
|
||||
(visibleProviderModels[provider] ?? [])[0] ||
|
||||
"openai/gpt-5.3-codex";
|
||||
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
|
||||
CLINE_DEFAULT_MODEL_ID;
|
||||
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
|
||||
const timeoutSeconds = parseOptionalPositiveInt(
|
||||
createForm.timeoutSeconds,
|
||||
);
|
||||
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
|
||||
const tags = parseTags(createForm.tags);
|
||||
const command = editingSchedule
|
||||
? "update_routine_schedule"
|
||||
@@ -814,19 +816,16 @@ export function RoutineSchedulesContent() {
|
||||
prompt,
|
||||
provider,
|
||||
model,
|
||||
mode: createForm.mode,
|
||||
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
|
||||
workspace_root: workspaceRoot,
|
||||
cwd: createForm.cwd.trim() || undefined,
|
||||
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
|
||||
system_prompt: editingSchedule
|
||||
? createForm.systemPrompt.trim() || null
|
||||
: createForm.systemPrompt.trim() || undefined,
|
||||
max_iterations: editingSchedule
|
||||
? (maxIterations ?? null)
|
||||
: maxIterations,
|
||||
? systemPrompt || null
|
||||
: systemPrompt || undefined,
|
||||
timeout_seconds: editingSchedule
|
||||
? (timeoutSeconds ?? null)
|
||||
: timeoutSeconds,
|
||||
max_parallel: maxParallel,
|
||||
max_parallel: 1,
|
||||
enabled: createForm.enabled,
|
||||
tags: tags ?? [],
|
||||
});
|
||||
@@ -948,7 +947,9 @@ export function RoutineSchedulesContent() {
|
||||
{schedule.mode}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.cronPattern}
|
||||
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
|
||||
: schedule.cronPattern}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -967,7 +968,10 @@ export function RoutineSchedulesContent() {
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
disabled={
|
||||
isBusy ||
|
||||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -1373,27 +1377,6 @@ export function RoutineSchedulesContent() {
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Mode</Label>
|
||||
<Select
|
||||
value={createForm.mode}
|
||||
onValueChange={(value) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
mode: value === "plan" ? "plan" : "act",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="act">act</SelectItem>
|
||||
<SelectItem value="plan">plan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-workspace">Workspace root</Label>
|
||||
<Input
|
||||
@@ -1408,20 +1391,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-cwd">CWD (optional)</Label>
|
||||
<Input
|
||||
id="routine-cwd"
|
||||
value={createForm.cwd}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-system-prompt">
|
||||
System prompt (optional)
|
||||
@@ -1439,23 +1408,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-max-iterations">
|
||||
Max iterations (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="routine-max-iterations"
|
||||
value={createForm.maxIterations}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
maxIterations: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-timeout">
|
||||
Timeout seconds (optional)
|
||||
@@ -1473,21 +1425,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-max-parallel">Max parallel</Label>
|
||||
<Input
|
||||
id="routine-max-parallel"
|
||||
value={createForm.maxParallel}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
maxParallel: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-tags">
|
||||
Tags (comma-separated, optional)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- New system tray icon showing app status and how many agent sessions are currently running.
|
||||
- Session history is now paginated in ten-session pages, fetching older history only when you reach the end.
|
||||
- You can favorite sessions, and sessions are now ordered by most recent activity with consistent status dot colors across views.
|
||||
- Subagent and teammate runs from a session now show up in the app with their status and results.
|
||||
- Chat polish: tool-specific icons on tool disclosures, elapsed thinking time and restyled reasoning sections, aligned timestamps, and message actions that no longer shift the layout while scrolling stays anchored to the conversation viewport.
|
||||
- Free Cline models are now supported and labeled "(free)" in model pickers, with a clear message — including reset time — when you hit the free-tier limit.
|
||||
- Fixed the China/international endpoint toggles for Qwen, Moonshot, Z AI, and MiniMax being ignored, which silently routed regional users to the wrong host.
|
||||
- Fixed tool calls failing when a model emitted a line number as a string (e.g. `insert_line: "3"`), forcing the agent to waste a round trip retrying.
|
||||
- Refreshed the bundled provider and model catalog.
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
|
||||
- New sidebar update indicator: once an update has been downloaded, an accent-colored icon stays in the sidebar showing the new version with a one-click restart, so the update is still reachable after you dismiss the toast (and restart failures are now surfaced instead of silently doing nothing).
|
||||
- No more appearance flash on launch — the app paints in your saved (or system) light/dark theme before the first frame.
|
||||
- The header and sidebar now show the full workspace name and git branch, and lay out correctly on narrow windows; a transient git lookup no longer wipes a valid branch name back to "no git".
|
||||
- Cleaner collapsed-sidebar settings layout: compact width, left-aligned navigation, stacked account details.
|
||||
- Clarified the auto-update setting — it's now "Keep CLI up to date" and explains that it governs the `cline` terminal command, not the app itself (the app updates separately).
|
||||
- Toggle switches now use a solid accent color when on, for clearer contrast.
|
||||
|
||||
## 0.0.5
|
||||
|
||||
- Major performance overhaul: the app now feels snappy end-to-end. The animated background renders at a locked 60fps instead of ~10fps, typing in the composer no longer stutters (245 slow keystrokes → 3), streaming responses coalesce updates instead of re-rendering the whole chat per token, and app boot fetches the provider catalog once instead of three times.
|
||||
- The native folder picker and command execution no longer freeze the app while the sidecar writes session logs or discovers your editor.
|
||||
- Fixed the composer getting stuck on "Agent is working..." after queued turns finished.
|
||||
- Added a Cline API key path to onboarding, and you can now cancel a pending browser sign-in instead of being stuck waiting for it.
|
||||
- Fixed window dragging.
|
||||
- MCP server cards are now consistent across marketplace views, with a single uninstall action and setup guidance shown on installed servers.
|
||||
- Fixed agentic compaction silently falling back to basic compaction for OpenAI-Compatible providers, and manual /compact never actually reaching the model when auto-compaction was off.
|
||||
|
||||
## 0.0.4
|
||||
|
||||
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
|
||||
- New first-run onboarding flow to get you set up on launch.
|
||||
- Drag and drop files directly onto the chat to attach them.
|
||||
- Image attachments now display inline in the chat transcript.
|
||||
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
|
||||
- New custom overlay title bar with in-app navigation.
|
||||
- Redesigned channel setup as expandable cards.
|
||||
- Added a setting to replay the new-user experience.
|
||||
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
|
||||
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
|
||||
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
|
||||
- Headless routines now default to YOLO mode so they can run unattended.
|
||||
- Fixed request metering for the SAP AI Core provider.
|
||||
|
||||
## 0.0.3
|
||||
|
||||
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
|
||||
|
||||
## 0.0.2
|
||||
|
||||
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
|
||||
- Automatic updates: the app checks on launch and every 2 hours, downloads new versions in the background, and prompts for a one-click restart. Ignored updates apply on the next launch.
|
||||
- Download the DMG once from GitHub Releases — every future release arrives automatically.
|
||||
@@ -16,6 +16,20 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
|
||||
agent-run commands would miss Homebrew-installed tools like `gh` even though
|
||||
they work fine from a terminal. At startup the sidecar asks the user's login
|
||||
shell — read from the account database via `getpwuid`, falling back to
|
||||
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
|
||||
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
|
||||
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
|
||||
API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
|
||||
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
|
||||
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
@@ -25,7 +39,21 @@ Tailwind adapter and shared base styles without depending on the desktop
|
||||
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
|
||||
desktop integration notes.
|
||||
|
||||
## Shareable Desktop Packages
|
||||
## Releases & Auto-Updates
|
||||
|
||||
Releases are built, signed, notarized, and published by the `desktop-publish`
|
||||
GitHub workflow. The step-by-step flow (version bumps, changelog, tag, repo
|
||||
secrets) lives in the `publish-desktop` skill
|
||||
(`.cline/skills/publish-desktop/SKILL.md`).
|
||||
|
||||
Installed apps auto-update via the Tauri updater: they poll the rolling
|
||||
`desktop-latest` release's `latest.json` on launch and every 2 hours, install
|
||||
updates in the background, and prompt for a restart. Two things must never be
|
||||
lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
|
||||
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
|
||||
shipped apps can't verify new updates).
|
||||
|
||||
## Shareable Desktop Packages (manual fallback)
|
||||
|
||||
Tauri desktop bundles are OS-specific, so build each package on the target OS:
|
||||
|
||||
@@ -72,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
|
||||
Startup flow:
|
||||
|
||||
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
|
||||
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
|
||||
2. The desktop backend starts the Bun sidecar, which discovers or starts the
|
||||
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
|
||||
for desktop commands, queries, and pushed events.
|
||||
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
|
||||
4. Tool approval updates are pushed from the backend instead of polled from the UI.
|
||||
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
|
||||
@@ -93,8 +123,8 @@ Desktop transport envelope:
|
||||
## Key Files
|
||||
|
||||
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
|
||||
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
|
||||
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
|
||||
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
|
||||
@@ -108,11 +138,28 @@ Desktop transport envelope:
|
||||
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
|
||||
- Full v1 schema for the persisted messages file, including failure/retry semantics and golden fixtures, is documented in [`packages/core/docs/messages-contract-v1.md`](../../../sdk/packages/core/docs/messages-contract-v1.md).
|
||||
|
||||
## Sidecar observability
|
||||
|
||||
The desktop sidecar sends SDK telemetry through the same configured OpenTelemetry
|
||||
pipeline used by the CLI and writes structured runtime logs to
|
||||
`~/.cline/data/logs/code.log` by default. Telemetry continues to honor the global
|
||||
opt-out setting exposed in the desktop settings UI. The sidecar truncates stale
|
||||
logs and rotates the active file before it exceeds 50 MiB.
|
||||
|
||||
Logging can be configured with the same environment variables as the CLI:
|
||||
|
||||
- `CLINE_LOG_ENABLED=0` disables file logging.
|
||||
- `CLINE_LOG_LEVEL` sets the Pino level (for example, `debug` or `warn`).
|
||||
- `CLINE_LOG_PATH` overrides the log destination.
|
||||
- `CLINE_LOG_NAME` overrides the logger name.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
|
||||
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
|
||||
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
|
||||
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
|
||||
The next desktop or CLI Hub connection will reuse a compatible running Hub or
|
||||
replace an incompatible one through the shared discovery path.
|
||||
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
@@ -16,7 +19,10 @@
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -70,6 +76,7 @@
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
|
||||
@@ -17,13 +17,33 @@ const resolveTargetTriple = async (): Promise<string> => {
|
||||
return host;
|
||||
};
|
||||
|
||||
// Bun cross-compiles --compile binaries, so a CI runner can produce the
|
||||
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
|
||||
// bundle from an arm64 runner). Without an explicit --target, bun always
|
||||
// emits a host-arch binary even when Tauri is building for another triple.
|
||||
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
|
||||
if (targetTriple.startsWith("aarch64-apple-darwin"))
|
||||
return "bun-darwin-arm64";
|
||||
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
|
||||
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
|
||||
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
|
||||
if (targetTriple.startsWith("aarch64-unknown-linux"))
|
||||
return "bun-linux-arm64";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
const extension = targetTriple.includes("windows") ? ".exe" : "";
|
||||
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
|
||||
await $`mkdir -p src-tauri/bin`;
|
||||
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
|
||||
if (bunTarget) {
|
||||
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildUpdateManifest } from "./generate-update-manifest";
|
||||
|
||||
const makeArtifactDir = (): string => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
|
||||
"sig-aarch64\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
|
||||
writeFileSync(
|
||||
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
|
||||
"sig-x86_64\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("buildUpdateManifest", () => {
|
||||
test("maps updater artifacts to darwin platform entries", () => {
|
||||
const dir = makeArtifactDir();
|
||||
const manifest = buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
expect(manifest.platforms["darwin-aarch64"]).toEqual({
|
||||
signature: "sig-aarch64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
|
||||
});
|
||||
expect(manifest.platforms["darwin-x86_64"]).toEqual({
|
||||
signature: "sig-x86_64",
|
||||
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
|
||||
});
|
||||
// The DMG is a first-install artifact, not an updater artifact.
|
||||
expect(Object.keys(manifest.platforms)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("throws when a signature file is missing", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("throws when no updater artifacts exist", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
|
||||
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
|
||||
expect(() =>
|
||||
buildUpdateManifest({
|
||||
version: "0.1.0",
|
||||
tag: "desktop-v0.1.0",
|
||||
dir,
|
||||
repo: "cline/cline",
|
||||
notes: "notes",
|
||||
pubDate: "2026-07-21T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow(/no updater artifacts/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Generates the Tauri updater manifest (latest.json) from the updater
|
||||
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
|
||||
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
|
||||
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
|
||||
// immutable per-version release assets.
|
||||
//
|
||||
// Usage:
|
||||
// bun scripts/generate-update-manifest.ts \
|
||||
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
|
||||
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type UpdaterPlatformEntry = {
|
||||
signature: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UpdateManifest = {
|
||||
version: string;
|
||||
notes: string;
|
||||
pub_date: string;
|
||||
platforms: Record<string, UpdaterPlatformEntry>;
|
||||
};
|
||||
|
||||
// Maps the arch token embedded in artifact file names (see the "Collect
|
||||
// artifacts" workflow step) to the platform keys the Tauri updater requests.
|
||||
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
|
||||
aarch64: "darwin-aarch64",
|
||||
x86_64: "darwin-x86_64",
|
||||
};
|
||||
|
||||
const getArgValue = (args: string[], name: string): string | undefined => {
|
||||
const index = args.indexOf(name);
|
||||
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
|
||||
return args[index + 1];
|
||||
}
|
||||
const prefix = `${name}=`;
|
||||
const inline = args.find((arg) => arg.startsWith(prefix));
|
||||
return inline?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
|
||||
if (!fileName.endsWith(".app.tar.gz")) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
|
||||
fileName.includes(`_${arch}`),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildUpdateManifest = (options: {
|
||||
version: string;
|
||||
tag: string;
|
||||
dir: string;
|
||||
repo: string;
|
||||
notes: string;
|
||||
pubDate: string;
|
||||
}): UpdateManifest => {
|
||||
const platforms: Record<string, UpdaterPlatformEntry> = {};
|
||||
|
||||
for (const fileName of readdirSync(options.dir).sort()) {
|
||||
const arch = archOfUpdaterArtifact(fileName);
|
||||
if (!arch) {
|
||||
continue;
|
||||
}
|
||||
const signaturePath = path.join(options.dir, `${fileName}.sig`);
|
||||
const signature = readFileSync(signaturePath, "utf8").trim();
|
||||
if (!signature) {
|
||||
throw new Error(`empty updater signature at ${signaturePath}`);
|
||||
}
|
||||
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
|
||||
signature,
|
||||
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
throw new Error(
|
||||
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
version: options.version,
|
||||
notes: options.notes,
|
||||
pub_date: options.pubDate,
|
||||
platforms,
|
||||
};
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
const args = process.argv.slice(2);
|
||||
const version = getArgValue(args, "--version");
|
||||
const tag = getArgValue(args, "--tag");
|
||||
const dir = getArgValue(args, "--dir");
|
||||
const out = getArgValue(args, "--out");
|
||||
const repo =
|
||||
getArgValue(args, "--repo") ??
|
||||
process.env.GITHUB_REPOSITORY ??
|
||||
"cline/cline";
|
||||
const notesFile = getArgValue(args, "--notes-file");
|
||||
|
||||
if (!version || !tag || !dir || !out) {
|
||||
throw new Error(
|
||||
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
|
||||
);
|
||||
}
|
||||
|
||||
const notes = notesFile
|
||||
? readFileSync(notesFile, "utf8").trim()
|
||||
: `Cline Code v${version}`;
|
||||
|
||||
const manifest = buildUpdateManifest({
|
||||
version,
|
||||
tag,
|
||||
dir,
|
||||
repo,
|
||||
notes,
|
||||
pubDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
console.log(`wrote ${out}`);
|
||||
for (const [platform, entry] of Object.entries(manifest.platforms)) {
|
||||
console.log(`- ${platform}: ${entry.url}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The sidecar is a single Bun process that handles the desktop backend runtime directly.
|
||||
The sidecar is a Bun process that adapts the desktop UI and native operations to
|
||||
the shared Cline Hub.
|
||||
|
||||
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
|
||||
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
|
||||
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
|
||||
sidecar does not own a private agent runtime Hub.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -14,7 +17,7 @@ sidecar/
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # In-process chat session management
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
├── types.ts # Shared types
|
||||
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Chat Sessions — In-Process via LocalRuntimeHost
|
||||
### 1. Chat Sessions — Shared Hub Client
|
||||
|
||||
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
|
||||
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
|
||||
the same compatible Hub discovered by the CLI or starts the canonical detached
|
||||
Hub when the desktop is the first client:
|
||||
|
||||
```typescript
|
||||
import { LocalRuntimeHost } from "@cline/core";
|
||||
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
// Push approval request to frontend via WebSocket event
|
||||
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Tool Approval — In-Memory Promise Resolution
|
||||
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
No more file-system watchers. Tool approvals use in-memory promise maps:
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
session. Desktop approvals use in-memory promise maps while the webview is
|
||||
online:
|
||||
|
||||
```typescript
|
||||
const pendingApprovals = new Map<string, {
|
||||
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
|
||||
|
||||
### 5. Routine Schedules — Direct Hub Commands
|
||||
|
||||
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
|
||||
Routine operations use the same connected Hub client as chat session
|
||||
observation. They never start a second in-process Hub:
|
||||
|
||||
```typescript
|
||||
import { ensureHubServer, sendHubCommand } from "@cline/core";
|
||||
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
|
||||
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
|
||||
await ctx.hubClient.command("schedule.list", { limit: 200 });
|
||||
```
|
||||
|
||||
### 6. Native Commands
|
||||
@@ -122,7 +138,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | `LocalRuntimeHost` in-process |
|
||||
| `chat_session_command` | shared Hub through `ClineCore` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `save_provider_settings` | `saveLocalProviderSettings` |
|
||||
@@ -137,14 +153,14 @@ Supported commands:
|
||||
| `list_mcp_servers` | Direct file I/O |
|
||||
| `upsert_mcp_server` | Direct file I/O |
|
||||
| `delete_mcp_server` | Direct file I/O |
|
||||
| `get_git_branch` | `execFileSync("git", ...)` |
|
||||
| `list_git_branches` | `execFileSync("git", ...)` |
|
||||
| `checkout_git_branch` | `execFileSync("git", ...)` |
|
||||
| `get_git_branch` | async `execFile("git", ...)` |
|
||||
| `list_git_branches` | async `execFile("git", ...)` |
|
||||
| `checkout_git_branch` | async `execFile("git", ...)` |
|
||||
| `search_workspace_files` | `getFileIndex` |
|
||||
| `get_process_context` | In-memory context |
|
||||
| `poll_tool_approvals` | In-memory pending map |
|
||||
| `respond_tool_approval` | In-memory promise resolution |
|
||||
| `list_routine_schedules` | local hub schedule commands |
|
||||
| `list_routine_schedules` | shared Hub schedule commands |
|
||||
| `list_user_instruction_configs` | Direct core API |
|
||||
| `pick_workspace_directory` | OS native dialog |
|
||||
| `open_mcp_settings_file` | OS `open` command |
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
materializeUserFiles,
|
||||
reconcileQueuedAttachments,
|
||||
sessionAttachmentsDir,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import type { LiveSession } from "./types";
|
||||
|
||||
const sessionId = "attachment-test-session";
|
||||
let previousSessionDataDir: string | undefined;
|
||||
let testSessionDataDir: string;
|
||||
|
||||
function createSession(): LiveSession {
|
||||
return {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("materialized attachment lifecycle", () => {
|
||||
it("only deletes files inside the session attachments dir", () => {
|
||||
const [staged] = materializeUserFiles(sessionId, [
|
||||
{ name: "notes.txt", content: "hello" },
|
||||
]) as string[];
|
||||
const outside = join(testSessionDataDir, "outside.txt");
|
||||
writeFileSync(outside, "keep me", "utf8");
|
||||
|
||||
deleteMaterializedAttachments(sessionId, [staged, outside]);
|
||||
|
||||
expect(existsSync(staged)).toBe(false);
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes consumed files when the turn ends", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: files,
|
||||
},
|
||||
],
|
||||
files,
|
||||
);
|
||||
|
||||
markQueuedAttachmentsSubmitted(session, "pending_1");
|
||||
expect(existsSync(files[0] as string)).toBe(true);
|
||||
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
expect(existsSync(files[0] as string)).toBe(false);
|
||||
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps files for a submitted prompt that gets requeued", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: files,
|
||||
},
|
||||
],
|
||||
files,
|
||||
);
|
||||
markQueuedAttachmentsSubmitted(session, "pending_1");
|
||||
|
||||
// Drain send failed → prompt is back in the queue snapshot.
|
||||
reconcileQueuedAttachments(session, ["pending_1"]);
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
expect(existsSync(files[0] as string)).toBe(true);
|
||||
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
|
||||
});
|
||||
|
||||
it("tracks files as consumed when the prompt is no longer queued", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
|
||||
trackQueuedAttachments(session, [], files);
|
||||
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
|
||||
expect(session.consumedAttachmentFiles?.size).toBe(1);
|
||||
});
|
||||
|
||||
it("discards all tracked files on session end", () => {
|
||||
const session = createSession();
|
||||
const queued = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const consumed = materializeUserFiles(sessionId, [
|
||||
{ name: "consumed.txt", content: "c" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: queued,
|
||||
},
|
||||
],
|
||||
queued,
|
||||
);
|
||||
trackQueuedAttachments(session, [], consumed);
|
||||
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
|
||||
expect(existsSync(queued[0] as string)).toBe(false);
|
||||
expect(existsSync(consumed[0] as string)).toBe(false);
|
||||
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve, sep } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { SessionPendingPrompt } from "@cline/core";
|
||||
import { sharedSessionDataDir } from "./paths";
|
||||
import type { ChatTurnAttachments, LiveSession } from "./types";
|
||||
|
||||
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
|
||||
if (!session.queuedAttachmentFiles) {
|
||||
session.queuedAttachmentFiles = new Map();
|
||||
}
|
||||
return session.queuedAttachmentFiles;
|
||||
}
|
||||
|
||||
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
|
||||
if (!session.consumedAttachmentFiles) {
|
||||
session.consumedAttachmentFiles = new Map();
|
||||
}
|
||||
return session.consumedAttachmentFiles;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Materialized user-attachment lifecycle
|
||||
//
|
||||
// Non-image attachments arrive from the webview as inline content and are
|
||||
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
|
||||
// load them by path at turn start. The sidecar owns these files and must
|
||||
// delete them once consumed (turn completed) or discarded (queued prompt
|
||||
// removed / session ended) — otherwise user data accumulates on disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sessionAttachmentsDir(sessionId: string): string {
|
||||
return join(sharedSessionDataDir(), sessionId, "user-attachments");
|
||||
}
|
||||
|
||||
export function materializeUserFiles(
|
||||
sessionId: string,
|
||||
files: ChatTurnAttachments["userFiles"],
|
||||
): string[] | undefined {
|
||||
if (!files?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const attachmentDir = sessionAttachmentsDir(sessionId);
|
||||
mkdirSync(attachmentDir, { recursive: true });
|
||||
return files.map((file) => {
|
||||
const requestedName = basename(file.name.trim());
|
||||
const safeName =
|
||||
requestedName && requestedName !== "." && requestedName !== ".."
|
||||
? requestedName
|
||||
: "attachment.txt";
|
||||
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
|
||||
writeFileSync(path, file.content, "utf8");
|
||||
return path;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete materialized attachment files. Only paths inside the session's
|
||||
* user-attachments directory are removed, so files referenced from elsewhere
|
||||
* (e.g. `@`-mentions) are never touched.
|
||||
*/
|
||||
export function deleteMaterializedAttachments(
|
||||
sessionId: string,
|
||||
paths: string[] | undefined,
|
||||
): void {
|
||||
if (!paths?.length) return;
|
||||
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
|
||||
for (const path of paths) {
|
||||
if (!resolve(path).startsWith(attachmentDir)) continue;
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; leftover files are removed with the session dir.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track files staged for a queued/steered prompt so they can be deleted once
|
||||
* the prompt is consumed or discarded. If the prompt is no longer in the
|
||||
* queue (already submitted), the files are tracked as consumed and deleted
|
||||
* when the running turn finishes.
|
||||
*/
|
||||
export function trackQueuedAttachments(
|
||||
session: LiveSession | undefined,
|
||||
prompts: SessionPendingPrompt[],
|
||||
userFiles: string[] | undefined,
|
||||
): void {
|
||||
if (!session || !userFiles?.length) return;
|
||||
const match = prompts.find((prompt) =>
|
||||
isDeepStrictEqual(prompt.userFiles, userFiles),
|
||||
);
|
||||
if (match) {
|
||||
queuedFilesMap(session).set(match.id, userFiles);
|
||||
} else {
|
||||
// Not in the queue → already being consumed by the running turn. Key by a
|
||||
// fresh id so it never collides with a prompt-id key used elsewhere in the
|
||||
// consumed bucket.
|
||||
consumedFilesMap(session).set(randomUUID(), userFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a submitted queued prompt's files into the consumed bucket. */
|
||||
export function markQueuedAttachmentsSubmitted(
|
||||
session: LiveSession | undefined,
|
||||
promptId: string,
|
||||
): void {
|
||||
const files = session?.queuedAttachmentFiles?.get(promptId);
|
||||
if (!session || !files) return;
|
||||
session.queuedAttachmentFiles?.delete(promptId);
|
||||
consumedFilesMap(session).set(promptId, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt id reappearing in the queue means a submitted prompt was requeued
|
||||
* (e.g. the drain send failed) — move its files back to the queued bucket so
|
||||
* the turn-end flush does not delete files still pending consumption.
|
||||
*/
|
||||
export function reconcileQueuedAttachments(
|
||||
session: LiveSession | undefined,
|
||||
queuedPromptIds: string[],
|
||||
): void {
|
||||
if (!session?.consumedAttachmentFiles?.size) return;
|
||||
for (const id of queuedPromptIds) {
|
||||
const files = session.consumedAttachmentFiles.get(id);
|
||||
if (!files) continue;
|
||||
session.consumedAttachmentFiles.delete(id);
|
||||
queuedFilesMap(session).set(id, files);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete files for prompts whose turn has finished. */
|
||||
export function flushConsumedAttachments(
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
): void {
|
||||
if (!session?.consumedAttachmentFiles?.size) return;
|
||||
for (const files of session.consumedAttachmentFiles.values()) {
|
||||
deleteMaterializedAttachments(sessionId, files);
|
||||
}
|
||||
session.consumedAttachmentFiles.clear();
|
||||
}
|
||||
|
||||
/** Delete every tracked file for a session (queued prompts are discarded). */
|
||||
export function discardAllTrackedAttachments(
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
): void {
|
||||
if (!session) return;
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
if (!session.queuedAttachmentFiles?.size) return;
|
||||
for (const files of session.queuedAttachmentFiles.values()) {
|
||||
deleteMaterializedAttachments(sessionId, files);
|
||||
}
|
||||
session.queuedAttachmentFiles.clear();
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
prewarmWorkspaceMetadata,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
@@ -87,6 +93,87 @@ describe("shouldUpdateSessionConnection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasProviderChanged", () => {
|
||||
it("distinguishes provider switches from model switches", () => {
|
||||
expect(
|
||||
hasProviderChanged(
|
||||
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
|
||||
{ provider: "openai-codex", model: "gpt-5.3-codex" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasProviderChanged(
|
||||
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
|
||||
{ provider: "cline", model: "openai/gpt-5.3-codex" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a providerId-only update when the stored config uses provider", () => {
|
||||
const current = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
};
|
||||
const update = {
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
};
|
||||
|
||||
expect(hasProviderChanged(current, update)).toBe(true);
|
||||
expect(mergeSessionConfig(current, update)).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
providerId: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathless session starts", () => {
|
||||
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
});
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
sessionManager: { start },
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
enableTools: true,
|
||||
},
|
||||
})) as {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionId: "session-pathless",
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
@@ -100,12 +187,19 @@ describe("first-send connection updates", () => {
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const updateSessionConnection = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
const send = vi.fn(async (_input?: unknown) => ({
|
||||
text: "done",
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
}));
|
||||
const readMessages = vi.fn(async () => [
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
]);
|
||||
const readSessionCompactionState = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const sessionId = "session-connection-test";
|
||||
const start = vi.fn(async (_input?: unknown) => ({ sessionId }));
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
@@ -121,9 +215,29 @@ describe("first-send connection updates", () => {
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessionManager: { send, updateSessionConnection },
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: {
|
||||
readMessages,
|
||||
readSessionCompactionState,
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
pendingPrompts: {
|
||||
list: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, send, sessionId, updateSessionConnection };
|
||||
return {
|
||||
ctx,
|
||||
readMessages,
|
||||
send,
|
||||
sessionId,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips an identical update for a locally-created session", async () => {
|
||||
@@ -140,6 +254,266 @@ describe("first-send connection updates", () => {
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows an image-only user turn", async () => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "",
|
||||
attachments: {
|
||||
userImages: ["data:image/png;base64,aGVsbG8="],
|
||||
userFiles: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery: undefined,
|
||||
userImages: ["data:image/png;base64,aGVsbG8="],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"queue",
|
||||
] as const)("forwards file attachments for %s delivery", async (delivery) => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
|
||||
);
|
||||
let sentFileContent: string | undefined;
|
||||
send.mockImplementation(async (input?: unknown) => {
|
||||
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
|
||||
if (files?.[0]) {
|
||||
sentFileContent = readFileSync(files[0], "utf8");
|
||||
}
|
||||
return { text: "done", finishReason: "completed", messages: [] };
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery,
|
||||
attachments: {
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
const input = send.mock.calls[0]?.[0] as
|
||||
| { userFiles?: string[] }
|
||||
| undefined;
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery,
|
||||
userImages: undefined,
|
||||
userFiles: [expect.stringMatching(/notes\.txt$/)],
|
||||
});
|
||||
expect(sentFileContent).toBe("hello");
|
||||
if (delivery === "queue") {
|
||||
// Queued attachments stay on disk until the prompt is consumed.
|
||||
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
|
||||
} else {
|
||||
// Immediate turns delete the materialized file once the send resolves.
|
||||
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes materialized attachments when a queued prompt is removed", async () => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-remove-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const queue: Array<{
|
||||
id: string;
|
||||
prompt: string;
|
||||
delivery: "queue";
|
||||
attachmentCount: number;
|
||||
userFiles?: string[];
|
||||
}> = [];
|
||||
const manager = ctx.sessionManager as unknown as {
|
||||
send: typeof send;
|
||||
pendingPrompts: {
|
||||
list: (input: unknown) => Promise<unknown[]>;
|
||||
delete: (input: {
|
||||
sessionId: string;
|
||||
promptId: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
manager.send = vi.fn(async (input?: unknown) => {
|
||||
const { prompt, userFiles } = input as {
|
||||
prompt: string;
|
||||
userFiles?: string[];
|
||||
};
|
||||
queue.push({
|
||||
id: "pending_1",
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
attachmentCount: userFiles?.length ?? 0,
|
||||
userFiles,
|
||||
});
|
||||
return undefined;
|
||||
}) as unknown as typeof send;
|
||||
manager.pendingPrompts = {
|
||||
list: vi.fn(async () => [...queue]),
|
||||
delete: vi.fn(async ({ promptId }) => {
|
||||
const index = queue.findIndex((entry) => entry.id === promptId);
|
||||
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
|
||||
return {
|
||||
sessionId,
|
||||
prompts: [...queue],
|
||||
prompt: removed,
|
||||
removed: index >= 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "queued with file",
|
||||
delivery: "queue",
|
||||
attachments: {
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
},
|
||||
});
|
||||
const filePath = queue[0]?.userFiles?.[0] ?? "";
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(
|
||||
ctx.liveSessions
|
||||
.get(sessionId)
|
||||
?.queuedAttachmentFiles?.get("pending_1"),
|
||||
).toEqual([filePath]);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "remove_pending_prompt",
|
||||
sessionId,
|
||||
promptId: "pending_1",
|
||||
});
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
expect(
|
||||
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes tracked attachments when a session is reset", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-reset-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const [consumedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "consumed.txt", content: "c" },
|
||||
]) as string[];
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) throw new Error("missing session");
|
||||
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
|
||||
session.consumedAttachmentFiles = new Map([
|
||||
["pending_2", [consumedFile]],
|
||||
]);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "reset",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(existsSync(consumedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.has(sessionId)).toBe(false);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves tracked attachments across re-attach", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-attach-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) throw new Error("missing session");
|
||||
const queuedMap = new Map([["pending_1", [queuedFile]]]);
|
||||
session.queuedAttachmentFiles = queuedMap;
|
||||
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
|
||||
async () => ({
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "attach",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(true);
|
||||
expect(
|
||||
ctx.liveSessions
|
||||
.get(sessionId)
|
||||
?.queuedAttachmentFiles?.get("pending_1"),
|
||||
).toEqual([queuedFile]);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates a changed connection before sending", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext({
|
||||
config: { ...baseConfig, reasoningEffort: "low" },
|
||||
@@ -158,6 +532,265 @@ describe("first-send connection updates", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rebuilds the same session with its transcript before a provider switch", async () => {
|
||||
const {
|
||||
ctx,
|
||||
readMessages,
|
||||
send,
|
||||
sessionId,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
} = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(stop).toHaveBeenCalledWith(sessionId);
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
sessionId,
|
||||
}),
|
||||
initialMessages: [
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(updateSessionConnection).toHaveBeenCalledWith(sessionId, {
|
||||
providerId: "openai-codex",
|
||||
modelId: "gpt-5.3-codex",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
expect(start.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
send.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks a concurrent send throughout provider-switch preparation", async () => {
|
||||
let resolveMessages:
|
||||
| ((messages: Array<{ role: string; content: string }>) => void)
|
||||
| undefined;
|
||||
const messages = new Promise<Array<{ role: string; content: string }>>(
|
||||
(resolve) => {
|
||||
resolveMessages = resolve;
|
||||
},
|
||||
);
|
||||
const { ctx, readMessages, sessionId } = createContext();
|
||||
readMessages.mockImplementationOnce(async () => await messages);
|
||||
|
||||
const switching = handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "racing prompt",
|
||||
config: { ...baseConfig },
|
||||
}),
|
||||
).rejects.toThrow("A provider switch is already in progress");
|
||||
|
||||
resolveMessages?.([
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
]);
|
||||
await switching;
|
||||
});
|
||||
|
||||
it.each([
|
||||
"queue",
|
||||
"steer",
|
||||
] as const)("locks provider-switch preparation for explicit %s delivery", async (delivery) => {
|
||||
let resolveMessages:
|
||||
| ((messages: Array<{ role: string; content: string }>) => void)
|
||||
| undefined;
|
||||
const messages = new Promise<Array<{ role: string; content: string }>>(
|
||||
(resolve) => {
|
||||
resolveMessages = resolve;
|
||||
},
|
||||
);
|
||||
const { ctx, readMessages, sessionId } = createContext();
|
||||
readMessages.mockImplementationOnce(async () => await messages);
|
||||
|
||||
const switching = handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "queue this for Codex",
|
||||
delivery,
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "racing prompt",
|
||||
config: { ...baseConfig },
|
||||
}),
|
||||
).rejects.toThrow("A provider switch is already in progress");
|
||||
|
||||
resolveMessages?.([
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: "first response" },
|
||||
]);
|
||||
await switching;
|
||||
});
|
||||
|
||||
it("restores the previous provider runtime when replacement startup fails", async () => {
|
||||
const { ctx, send, sessionId, start, stop } = createContext();
|
||||
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
|
||||
const testKanbanDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-provider-rollback-${process.pid}`,
|
||||
);
|
||||
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
|
||||
start
|
||||
.mockRejectedValueOnce(new Error("Codex bootstrap failed"))
|
||||
.mockResolvedValueOnce({ sessionId });
|
||||
|
||||
try {
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
})) as { result?: { finishReason?: string; text?: string } };
|
||||
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
expect(start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(result.result).toEqual({
|
||||
finishReason: "error",
|
||||
text: "Codex bootstrap failed",
|
||||
});
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Cline",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (previousKanbanDataDir === undefined) {
|
||||
delete process.env.CLINE_KANBAN_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
|
||||
}
|
||||
rmSync(testKanbanDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the previous provider when replacement label sync fails", async () => {
|
||||
const { ctx, send, sessionId, start, stop, updateSessionConnection } =
|
||||
createContext();
|
||||
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
|
||||
const testKanbanDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-provider-label-rollback-${process.pid}`,
|
||||
);
|
||||
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
|
||||
try {
|
||||
updateSessionConnection
|
||||
.mockRejectedValueOnce(new Error("manifest write failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Codex",
|
||||
config: {
|
||||
...baseConfig,
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex",
|
||||
},
|
||||
})) as { result?: { finishReason?: string; text?: string } };
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
expect(start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(updateSessionConnection).toHaveBeenNthCalledWith(2, sessionId, {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
thinkingBudgetTokens: null,
|
||||
});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(result.result).toEqual({
|
||||
finishReason: "error",
|
||||
text: "manifest write failed",
|
||||
});
|
||||
expect(ctx.liveSessions.get(sessionId)?.config).toEqual(baseConfig);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "continue with Cline",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
if (previousKanbanDataDir === undefined) {
|
||||
delete process.env.CLINE_KANBAN_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
|
||||
}
|
||||
rmSync(testKanbanDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes hub-attached sessions even when the cached config matches", async () => {
|
||||
const { ctx, sessionId, updateSessionConnection } = createContext({
|
||||
attachedViaHub: true,
|
||||
|
||||
@@ -5,13 +5,22 @@ import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import { buildClineSystemPrompt } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import type {
|
||||
@@ -159,6 +168,8 @@ function createLiveSession(
|
||||
prompt: overrides?.prompt,
|
||||
title: overrides?.title,
|
||||
attachedViaHub: overrides?.attachedViaHub ?? false,
|
||||
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
|
||||
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
|
||||
const workspaceRoot =
|
||||
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
|
||||
const cwd =
|
||||
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
@@ -212,8 +228,11 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
modelId: config.model ?? config.modelId ?? "",
|
||||
mode: config.mode ?? "act",
|
||||
apiKey: config.apiKey ?? config.api_key ?? "",
|
||||
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
...(workspaceRoot ? { workspaceRoot } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
||||
maxIterations: config.maxIterations ?? config.max_iterations,
|
||||
enableTools: config.enableTools ?? config.enable_tools ?? true,
|
||||
@@ -293,6 +312,54 @@ export function shouldUpdateSessionConnection(
|
||||
);
|
||||
}
|
||||
|
||||
function readAliasedString(
|
||||
config: JsonRecord,
|
||||
primaryKey: string,
|
||||
aliasKey: string,
|
||||
): string | undefined {
|
||||
for (const key of [primaryKey, aliasKey]) {
|
||||
if (!Object.hasOwn(config, key)) continue;
|
||||
const value = String(config[key] ?? "").trim();
|
||||
return value || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mergeSessionConfig(
|
||||
currentConfig: JsonRecord,
|
||||
updates: JsonRecord,
|
||||
): JsonRecord {
|
||||
const providerId =
|
||||
readAliasedString(updates, "provider", "providerId") ??
|
||||
readAliasedString(currentConfig, "provider", "providerId");
|
||||
const modelId =
|
||||
readAliasedString(updates, "model", "modelId") ??
|
||||
readAliasedString(currentConfig, "model", "modelId");
|
||||
return {
|
||||
...currentConfig,
|
||||
...updates,
|
||||
...(providerId ? { provider: providerId, providerId } : {}),
|
||||
...(modelId ? { model: modelId, modelId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasProviderChanged(
|
||||
currentConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): boolean {
|
||||
const currentProviderId = readAliasedString(
|
||||
currentConfig,
|
||||
"provider",
|
||||
"providerId",
|
||||
);
|
||||
const nextProviderId = readAliasedString(
|
||||
nextConfig,
|
||||
"provider",
|
||||
"providerId",
|
||||
);
|
||||
return nextProviderId !== undefined && currentProviderId !== nextProviderId;
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
const cwd = String(
|
||||
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
@@ -348,7 +415,13 @@ function sendPromptsInQueueSnapshot(
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
sendEvent(ctx, "prompts_in_queue_state", {
|
||||
sessionId,
|
||||
items: session?.promptsInQueue ?? [],
|
||||
items:
|
||||
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
})) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,6 +431,7 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
|
||||
prompt: item.prompt,
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount: item.attachmentCount,
|
||||
userImages: item.userImages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -372,7 +446,12 @@ function applyPendingPrompts(
|
||||
session.promptsInQueue = mapped;
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return mapped;
|
||||
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function getSessionManager(ctx: SidecarContext): ClineCore {
|
||||
@@ -412,11 +491,12 @@ async function handleStart(
|
||||
// the frontend call the separate "send" action to dispatch the prompt.
|
||||
// This avoids a double-execution bug where start() would run the turn AND
|
||||
// the subsequent manager.send() fire-and-forget would run it again.
|
||||
console.error(
|
||||
`[sidecar:handleStart] calling manager.start provider=${coreConfig.providerId} model=${coreConfig.modelId}`,
|
||||
);
|
||||
ctx.logger?.log("Starting desktop chat session", {
|
||||
providerId: String(coreConfig.providerId ?? ""),
|
||||
modelId: String(coreConfig.modelId ?? ""),
|
||||
});
|
||||
const startResult = await manager.start({
|
||||
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages
|
||||
@@ -425,19 +505,24 @@ async function handleStart(
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
|
||||
const session = createLiveSession(request.config, {
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
: undefined,
|
||||
title: requestedSessionId
|
||||
? readSessionMetadataTitle(requestedSessionId)
|
||||
: undefined,
|
||||
status: "idle",
|
||||
});
|
||||
const workspaceRoot = startResult.manifest.workspace_root;
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
const session = createLiveSession(
|
||||
{ ...request.config, cwd, workspaceRoot },
|
||||
{
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
: undefined,
|
||||
title: requestedSessionId
|
||||
? readSessionMetadataTitle(requestedSessionId)
|
||||
: undefined,
|
||||
status: "idle",
|
||||
},
|
||||
);
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
return { sessionId };
|
||||
return { sessionId, cwd, workspaceRoot };
|
||||
}
|
||||
|
||||
async function handleAttach(
|
||||
@@ -495,6 +580,11 @@ async function handleAttach(
|
||||
existing?.title,
|
||||
endedAt: isoTimestampToMs(session.endedAt),
|
||||
attachedViaHub: true,
|
||||
// Preserve tracked attachment files so re-attach (called on every
|
||||
// webview hydrate) does not orphan materialized files still awaiting
|
||||
// cleanup.
|
||||
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
|
||||
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -510,80 +600,251 @@ async function handleAttach(
|
||||
};
|
||||
}
|
||||
|
||||
async function startRebuiltSession(
|
||||
manager: ClineCore,
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
compactionState: SessionCompactionState | undefined,
|
||||
): Promise<void> {
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
const restarted = await manager.start({
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
initialMessages: messages,
|
||||
...(projectedMessages
|
||||
? {
|
||||
initialCompactionState: createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
toolPolicies: resolveToolPolicies(config),
|
||||
});
|
||||
if (restarted.sessionId !== sessionId) {
|
||||
throw new Error(
|
||||
`Provider switch changed session id from ${sessionId} to ${restarted.sessionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildSessionForProviderChange(
|
||||
ctx: SidecarContext,
|
||||
manager: ClineCore,
|
||||
sessionId: string,
|
||||
previousConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): Promise<void> {
|
||||
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
|
||||
await Promise.all([
|
||||
manager.readMessages(sessionId),
|
||||
manager.readSessionCompactionState(sessionId).catch((error) => {
|
||||
ctx.logger?.log?.("Failed to read desktop session compaction state", {
|
||||
sessionId,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
return undefined;
|
||||
}),
|
||||
resolveSystemPrompt(previousConfig),
|
||||
resolveSystemPrompt(nextConfig),
|
||||
]);
|
||||
|
||||
await manager.stop(sessionId);
|
||||
let replacementStarted = false;
|
||||
try {
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
nextConfig,
|
||||
nextSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
);
|
||||
replacementStarted = true;
|
||||
// Reusing a session id preserves its existing manifest. Treat refreshing
|
||||
// its connection label as part of the replacement transaction so a
|
||||
// persistence failure cannot leave runtime and cached state diverged.
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
} catch (replacementError) {
|
||||
try {
|
||||
if (replacementStarted) {
|
||||
await manager.stop(sessionId);
|
||||
}
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
previousSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
);
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(previousConfig),
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[replacementError, rollbackError],
|
||||
"Provider switch and rollback both failed",
|
||||
);
|
||||
}
|
||||
throw replacementError;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const prompt = request.prompt?.trim() ?? "";
|
||||
const hasAttachments =
|
||||
(request.attachments?.userImages?.length ?? 0) > 0 ||
|
||||
(request.attachments?.userFiles?.length ?? 0) > 0;
|
||||
if (!prompt && !hasAttachments) {
|
||||
throw new Error("prompt or attachment is required");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (request.config) {
|
||||
if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, request.config)
|
||||
) {
|
||||
const connectionUpdate = buildSessionConnectionUpdate(request.config);
|
||||
await manager.updateSessionConnection(sessionId, connectionUpdate);
|
||||
}
|
||||
if (session) {
|
||||
session.config = { ...session.config, ...request.config };
|
||||
}
|
||||
if (session?.transitioningProvider) {
|
||||
throw new Error("A provider switch is already in progress");
|
||||
}
|
||||
|
||||
// Determine effective delivery mode.
|
||||
// When the session is busy and no explicit delivery was requested, queue it
|
||||
// via Core so that Core's own pending-prompts mechanism handles draining.
|
||||
// This avoids a sidecar-only local queue that never calls manager.send().
|
||||
let delivery = request.delivery;
|
||||
if (!delivery && session?.busy) {
|
||||
delivery = "queue";
|
||||
}
|
||||
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
}
|
||||
// Delegate queuing to Core — it will drain the prompt once the current
|
||||
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
const prompts = await manager.pendingPrompts.list({ sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
};
|
||||
const nextConfig = request.config
|
||||
? mergeSessionConfig(session?.config ?? {}, request.config)
|
||||
: undefined;
|
||||
const providerChanged = Boolean(
|
||||
session &&
|
||||
request.config &&
|
||||
hasProviderChanged(session.config, request.config),
|
||||
);
|
||||
if (providerChanged && session?.busy) {
|
||||
throw new Error("Cannot switch providers while a turn is running");
|
||||
}
|
||||
|
||||
const ownsBusyState = Boolean(
|
||||
session && delivery !== "queue" && delivery !== "steer",
|
||||
);
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
session.busy = true;
|
||||
session.status = "running";
|
||||
if (ownsBusyState) {
|
||||
session.prompt = prompt;
|
||||
session.busy = true;
|
||||
session.status = "running";
|
||||
}
|
||||
if (providerChanged) {
|
||||
session.transitioningProvider = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
console.error(
|
||||
`[sidecar:handleSend] calling manager.send sessionId=${sessionId} prompt=${prompt.slice(0, 80)}`,
|
||||
);
|
||||
const result = await manager.send({
|
||||
if (request.config && nextConfig) {
|
||||
if (providerChanged && session) {
|
||||
await rebuildSessionForProviderChange(
|
||||
ctx,
|
||||
manager,
|
||||
sessionId,
|
||||
session.config,
|
||||
nextConfig,
|
||||
);
|
||||
} else if (
|
||||
!session ||
|
||||
session.attachedViaHub ||
|
||||
shouldUpdateSessionConnection(session.config, nextConfig)
|
||||
) {
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
);
|
||||
}
|
||||
if (session) {
|
||||
session.config = nextConfig;
|
||||
if (providerChanged) {
|
||||
session.attachedViaHub = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userFiles = materializeUserFiles(
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
console.error(
|
||||
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
}
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
});
|
||||
const prompts = await manager.pendingPrompts.list({ sessionId });
|
||||
trackQueuedAttachments(session, prompts, userFiles);
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.logger?.debug("Sending desktop chat prompt", {
|
||||
sessionId,
|
||||
promptLength: prompt.length,
|
||||
delivery,
|
||||
});
|
||||
let result: Awaited<ReturnType<ClineCore["send"]>>;
|
||||
try {
|
||||
result = await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
});
|
||||
} catch (error) {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
throw error;
|
||||
}
|
||||
if (result === undefined) {
|
||||
// The runtime queued or steered the prompt instead of running it
|
||||
// (busy interactive session / steer delivery) — track the files so
|
||||
// they are deleted once the prompt is consumed or discarded.
|
||||
if (userFiles?.length) {
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
await manager.pendingPrompts.list({ sessionId }),
|
||||
userFiles,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
}
|
||||
ctx.logger?.log("Desktop chat prompt completed", {
|
||||
sessionId,
|
||||
finishReason: result?.finishReason,
|
||||
textLength: result?.text?.length ?? 0,
|
||||
});
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "idle";
|
||||
if (result?.messages) session.messages = result.messages as unknown[];
|
||||
}
|
||||
@@ -602,11 +863,8 @@ async function handleSend(
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[sidecar:handleSend] manager.send THREW sessionId=${sessionId} error=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
ctx.logger?.error?.("Desktop chat prompt failed", { sessionId, error });
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "error";
|
||||
}
|
||||
emitChunk(
|
||||
@@ -626,6 +884,15 @@ async function handleSend(
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (session) {
|
||||
if (ownsBusyState) {
|
||||
session.busy = false;
|
||||
}
|
||||
if (providerChanged) {
|
||||
session.transitioningProvider = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,7 +999,7 @@ async function handleFork(
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
initialMessages: sourceMessages,
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -741,6 +1008,10 @@ async function handleFork(
|
||||
toolPolicies: resolveToolPolicies(forkConfig),
|
||||
});
|
||||
const newSessionId = startResult.sessionId;
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
@@ -780,6 +1051,7 @@ async function handleReset(
|
||||
) {
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
@@ -818,7 +1090,7 @@ async function handleRestoreCheckpoint(
|
||||
buildCoreSessionConfig({
|
||||
...request.config,
|
||||
systemPrompt: await resolveSystemPrompt(request.config),
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -830,6 +1102,10 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
@@ -931,6 +1207,10 @@ async function handleRemovePendingPrompt(
|
||||
sessionId,
|
||||
promptId,
|
||||
});
|
||||
if (result.removed === true) {
|
||||
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
|
||||
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
removed: result.removed === true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
|
||||
readConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __test__, configureConnectorCliLaunch } from "./connectors";
|
||||
|
||||
describe("desktop connector lifecycle", () => {
|
||||
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLaunchSpec === undefined) {
|
||||
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
} else {
|
||||
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the CLI connect command for a desktop-started daemon", () => {
|
||||
const workspaceRoot = "/repo";
|
||||
const expected = __test__.buildCliConnectCommand(workspaceRoot, []);
|
||||
|
||||
configureConnectorCliLaunch(workspaceRoot);
|
||||
|
||||
expect(readConnectorCliLaunchSpec()).toEqual({
|
||||
launcher: expected.launcher,
|
||||
connectArgsPrefix: expected.childArgs,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the atomic restart command for an active channel", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true),
|
||||
).toEqual(["--restart", "telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("starts an inactive channel directly", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], false),
|
||||
).toEqual(["telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("rejects a channel-wide restart when multiple instances are active", () => {
|
||||
expect(() => __test__.shouldRestartConnector("telegram", 2)).toThrow(
|
||||
"cannot safely restart telegram: 2 instances are active",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { listActiveConnectors } from "@cline/core";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
buildConnectorConnectArgs,
|
||||
CONNECTOR_PLATFORMS,
|
||||
listConnectorCatalog,
|
||||
setConnectorCliLaunchSpec,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
@@ -75,6 +76,16 @@ function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function asStringRecord(value: unknown): Record<string, string> {
|
||||
const record = asRecord(value);
|
||||
if (!record) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
@@ -125,12 +136,21 @@ function buildCliConnectCommand(
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function configureConnectorCliLaunch(workspaceRoot: string): void {
|
||||
const command = buildCliConnectCommand(workspaceRoot, []);
|
||||
setConnectorCliLaunchSpec({
|
||||
launcher: command.launcher,
|
||||
connectArgsPrefix: command.childArgs,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
|
||||
(platform) => supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
@@ -211,7 +231,7 @@ async function waitForConnectorState(
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -219,42 +239,36 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
if (!supported.has(platform.id)) {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
return [
|
||||
channel,
|
||||
...buildConnectorConnectArgs(
|
||||
platform,
|
||||
asStringRecord(args?.values),
|
||||
security
|
||||
? {
|
||||
enabled: security.enabled === true,
|
||||
values: asStringRecord(security.values),
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildConnectorLaunchArgs(
|
||||
cliArgs: string[],
|
||||
isRestart: boolean,
|
||||
): string[] {
|
||||
return isRestart ? ["--restart", ...cliArgs] : cliArgs;
|
||||
}
|
||||
|
||||
function shouldRestartConnector(channel: string, activeCount: number): boolean {
|
||||
if (activeCount > 1) {
|
||||
throw new Error(
|
||||
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
|
||||
);
|
||||
}
|
||||
return cliArgs;
|
||||
return activeCount === 1;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
@@ -263,7 +277,14 @@ export async function startConnectorChannel(
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
const activeCount = listActiveConnectors().filter(
|
||||
(connector) => connector.type === channel,
|
||||
).length;
|
||||
const isRestart = shouldRestartConnector(channel, activeCount);
|
||||
const result = await runCliConnectCommand(
|
||||
workspaceRoot,
|
||||
buildConnectorLaunchArgs(cliArgs, isRestart),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
@@ -278,6 +299,12 @@ export async function startConnectorChannel(
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
buildConnectorLaunchArgs,
|
||||
shouldRestartConnector,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -290,7 +317,7 @@ export async function stopConnectorChannel(
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RuntimeCapabilities } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidecarContext } from "./types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import type { LiveSession, SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubCommandMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -17,19 +24,16 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
command = hubCommandMock;
|
||||
getConnectionError = hubGetConnectionErrorMock;
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
@@ -53,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
ensureCompatibleLocalHubUrlMock.mockReset();
|
||||
hubCommandMock.mockReset();
|
||||
hubGetConnectionErrorMock.mockReset();
|
||||
hubGetUrlMock.mockReset();
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
);
|
||||
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
|
||||
hubGetConnectionErrorMock.mockReturnValue(null);
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -83,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -102,22 +98,130 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const hubOptions = createCoreMock.mock.calls[0][0].hub;
|
||||
expect(hubOptions).not.toHaveProperty("endpoint");
|
||||
expect(hubOptions).not.toHaveProperty("authToken");
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const telemetry = { capture: vi.fn() };
|
||||
const ctx = createSidecarContext("/workspace/project", {
|
||||
logger,
|
||||
telemetry: telemetry as never,
|
||||
});
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: "cline-code",
|
||||
logger,
|
||||
telemetry,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the connected shared Hub endpoint in process context", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
const session = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
} satisfies LiveSession;
|
||||
ctx.liveSessions.set("running-session", session);
|
||||
ctx.liveSessions.set("idle-session", {
|
||||
...session,
|
||||
busy: false,
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
runningSessionCount: 1,
|
||||
hub: {
|
||||
status: "connected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts or reuses the shared Hub when a command needs a client", async () => {
|
||||
const { createSidecarContext, ensureSharedHubClient } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
expect(hubClient).toBe(ctx.hubClient);
|
||||
|
||||
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
});
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
}),
|
||||
);
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serializes queued image data when a queued prompt starts", async () => {
|
||||
const { serializeQueuedPromptStart } = await import("./context");
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
serializeQueuedPromptStart({
|
||||
promptId: "queued-prompt-1",
|
||||
prompt: "Describe this",
|
||||
attachmentCount: 1,
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
promptId: "queued-prompt-1",
|
||||
prompt: "Describe this",
|
||||
attachmentCount: 1,
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -192,8 +296,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
@@ -253,4 +356,79 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "pause_routine_schedule", {
|
||||
schedule_id: "schedule-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
let previousSessionDataDir: string | undefined;
|
||||
let testSessionDataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("deletes tracked attachments for all live sessions on shutdown", async () => {
|
||||
const { createSidecarContext, disposeSidecarContext } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const sessionId = "dispose-session";
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const session: LiveSession = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
|
||||
};
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
|
||||
await disposeSidecarContext(ctx, "test_shutdown");
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
type BasicLogger,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
type CoreSessionEvent,
|
||||
ensureCompatibleLocalHubUrl,
|
||||
type ITelemetryService,
|
||||
NodeHubClient,
|
||||
resolveHubOwnerContext,
|
||||
type RuntimeCapabilities,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { AgentEvent } from "@cline/shared";
|
||||
import {
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
reconcileQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { sessionLogPath } from "./paths";
|
||||
import type {
|
||||
LiveSession,
|
||||
@@ -26,6 +32,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -49,6 +59,11 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Session log appends are chained per session so writes stay ordered, but
|
||||
// they run asynchronously: a synchronous write per streamed token would stall
|
||||
// the sidecar event loop (and therefore every pending UI command) under load.
|
||||
const sessionLogWriteTails = new Map<string, Promise<void>>();
|
||||
|
||||
function appendSessionChunk(
|
||||
sessionId: string,
|
||||
stream: string,
|
||||
@@ -56,9 +71,21 @@ function appendSessionChunk(
|
||||
ts: number,
|
||||
): void {
|
||||
const path = sessionLogPath(sessionId);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify({ ts, stream, chunk })}\n`, {
|
||||
flag: "a",
|
||||
const line = `${JSON.stringify({ ts, stream, chunk })}\n`;
|
||||
const tail = sessionLogWriteTails.get(sessionId) ?? Promise.resolve();
|
||||
const next = tail
|
||||
.then(async () => {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, line);
|
||||
})
|
||||
.catch(() => {
|
||||
// Session logs are best-effort diagnostics; never fail the stream.
|
||||
});
|
||||
sessionLogWriteTails.set(sessionId, next);
|
||||
void next.finally(() => {
|
||||
if (sessionLogWriteTails.get(sessionId) === next) {
|
||||
sessionLogWriteTails.delete(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +136,29 @@ export function broadcastChunk(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
|
||||
return session.promptsInQueue;
|
||||
return session.promptsInQueue.map(
|
||||
({ id, prompt, steer, attachmentCount, userImages }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function serializeQueuedPromptStart(input: {
|
||||
promptId: string;
|
||||
prompt: string;
|
||||
attachmentCount?: number;
|
||||
userImages?: string[];
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
promptId: input.promptId,
|
||||
prompt: input.prompt,
|
||||
attachmentCount: input.attachmentCount ?? 0,
|
||||
userImages: input.userImages,
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(
|
||||
@@ -301,9 +350,16 @@ function handleCoreSessionEvent(
|
||||
prompt: item.prompt ?? "",
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount: item.attachmentCount ?? 0,
|
||||
userImages: item.userImages,
|
||||
}))
|
||||
.filter((item) => item.id && item.prompt);
|
||||
.filter(
|
||||
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
|
||||
);
|
||||
if (session) {
|
||||
reconcileQueuedAttachments(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
const previous = session.promptsInQueue;
|
||||
session.promptsInQueue = mapped;
|
||||
if (
|
||||
@@ -315,9 +371,11 @@ function handleCoreSessionEvent(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
serializeQueuedPromptStart({
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -326,14 +384,18 @@ function handleCoreSessionEvent(
|
||||
break;
|
||||
}
|
||||
case "pending_prompt_submitted": {
|
||||
const { sessionId, prompt, attachmentCount } = event.payload;
|
||||
const { sessionId, id, prompt, attachmentCount, userImages } =
|
||||
event.payload;
|
||||
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
serializeQueuedPromptStart({
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -346,6 +408,7 @@ function handleCoreSessionEvent(
|
||||
session.endedAt = nowMs();
|
||||
session.status = reason || "ended";
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
|
||||
break;
|
||||
}
|
||||
@@ -365,6 +428,10 @@ function handleCoreSessionEvent(
|
||||
if (session) {
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
if (status !== "running") {
|
||||
// The turn that consumed submitted attachments has finished.
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
}
|
||||
}
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
break;
|
||||
@@ -380,7 +447,13 @@ function handleCoreSessionEvent(
|
||||
// Context factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
export function createSidecarContext(
|
||||
workspaceRoot: string,
|
||||
observability: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
} = {},
|
||||
): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
streamIndices: new Map(),
|
||||
@@ -389,8 +462,9 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
}
|
||||
@@ -404,6 +478,11 @@ export async function disposeSidecarContext(
|
||||
ctx.unsubscribeSessionEvents?.();
|
||||
ctx.unsubscribeSessionEvents = null;
|
||||
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
}
|
||||
ctx.liveSessions.clear();
|
||||
|
||||
for (const client of ctx.wsClients) {
|
||||
try {
|
||||
client.close?.();
|
||||
@@ -434,12 +513,6 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -692,19 +765,14 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -717,25 +785,64 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
|
||||
const runtimeAddress = sessionManager.runtimeAddress?.trim();
|
||||
let hubClient: NodeHubClient | null = null;
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
try {
|
||||
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
export async function ensureSharedHubClient(
|
||||
ctx: SidecarContext,
|
||||
preferredUrl?: string,
|
||||
): Promise<NodeHubClient> {
|
||||
if (ctx.hubClient) {
|
||||
return ctx.hubClient;
|
||||
}
|
||||
const pending = hubClientInitialization.get(ctx);
|
||||
if (pending) {
|
||||
return await pending;
|
||||
}
|
||||
|
||||
const initialization = (async () => {
|
||||
const url =
|
||||
preferredUrl?.trim() ||
|
||||
(await ensureCompatibleLocalHubUrl({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
}));
|
||||
if (!url) {
|
||||
throw new Error("Unable to start or connect to the shared Cline Hub.");
|
||||
}
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
hubClientInitialization.delete(ctx);
|
||||
});
|
||||
|
||||
hubClientInitialization.set(ctx, initialization);
|
||||
return await initialization;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { homedir } from "node:os";
|
||||
import { setHomeDirIfUnset } from "@cline/core";
|
||||
import { isHubDaemonProcess } from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
initializeSessionManager,
|
||||
} from "./context";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
let activeObservability:
|
||||
| ReturnType<typeof createDesktopObservability>
|
||||
| undefined;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -32,19 +41,49 @@ async function main() {
|
||||
throw new Error("sidecar must be run with Bun");
|
||||
}
|
||||
|
||||
// When launched from Finder/the Dock the app inherits launchd's minimal
|
||||
// PATH, so agent-spawned processes can't find shell-profile-installed
|
||||
// tools like `gh`. Kick resolution off first so it overlaps the rest of
|
||||
// startup, but await it before the session manager exists — that's what
|
||||
// spawns children (agent sessions, MCP servers, scheduled runs).
|
||||
const shellPathPromise = ensureLoginShellPath();
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
|
||||
const ctx = createSidecarContext(workspaceRoot);
|
||||
setHomeDirIfUnset(homedir());
|
||||
configureConnectorCliLaunch(workspaceRoot);
|
||||
const observability = createDesktopObservability();
|
||||
activeObservability = observability;
|
||||
const ctx = createSidecarContext(workspaceRoot, observability);
|
||||
observability.logger.log("Desktop sidecar starting", {
|
||||
workspaceRoot,
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
await shellPathPromise,
|
||||
);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
let shuttingDown = false;
|
||||
let handlingFatalError = false;
|
||||
const shutdown = async (reason = "code_sidecar_shutdown"): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
await withTimeout(disposeSidecarContext(ctx, reason), SHUTDOWN_TIMEOUT_MS);
|
||||
observability.logger.log("Desktop sidecar shutting down", { reason });
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
try {
|
||||
await disposeSidecarContext(ctx, reason);
|
||||
} finally {
|
||||
await observability.dispose();
|
||||
}
|
||||
})(),
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
};
|
||||
|
||||
const shutdownAndExit = (signal: string): void => {
|
||||
@@ -55,11 +94,32 @@ async function main() {
|
||||
|
||||
process.once("SIGINT", () => shutdownAndExit("SIGINT"));
|
||||
process.once("SIGTERM", () => shutdownAndExit("SIGTERM"));
|
||||
const handleFatalError = (kind: string, error: unknown): void => {
|
||||
if (handlingFatalError) {
|
||||
process.exit(1);
|
||||
}
|
||||
handlingFatalError = true;
|
||||
observability.logger.error?.("Desktop sidecar process error", {
|
||||
kind,
|
||||
error,
|
||||
});
|
||||
void shutdown(`code_sidecar_${kind}`).finally(() => process.exit(1));
|
||||
};
|
||||
process.on("uncaughtException", (error) => {
|
||||
handleFatalError("uncaught_exception", error);
|
||||
});
|
||||
process.on("unhandledRejection", (error) => {
|
||||
handleFatalError("unhandled_rejection", error);
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void shutdown("code_sidecar_before_exit");
|
||||
});
|
||||
|
||||
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
|
||||
observability.logger.log("Desktop sidecar ready", {
|
||||
port,
|
||||
mode: SIDECAR_MODE,
|
||||
});
|
||||
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
@@ -76,8 +136,20 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
async function runEntrypoint(): Promise<void> {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
await main();
|
||||
}
|
||||
|
||||
runEntrypoint().catch(async (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
activeObservability?.logger.error?.("Desktop sidecar process failed", {
|
||||
error,
|
||||
});
|
||||
await activeObservability?.dispose();
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user