mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed821a6456 | |||
| f36b59c9cb | |||
| 311959e757 | |||
| f21e6faf1c | |||
| 1cf19304f4 | |||
| 09aec528d0 | |||
| 4ec2b68c7c | |||
| 56fd6bb1ce | |||
| 49c7a89882 | |||
| b47851791c | |||
| c0a966c46a | |||
| cf3f3e08eb | |||
| 7712e44468 | |||
| f77f584a32 | |||
| 2771760305 | |||
| 16d0d04573 | |||
| 3590b425eb | |||
| 12404f0a4b | |||
| 6cb43f0309 | |||
| 3058563f37 | |||
| 39de7479b8 | |||
| 86c2600a8a | |||
| f4230e475b | |||
| 078abcd055 | |||
| afc1229ab0 | |||
| 892837d352 | |||
| 078d9f63f0 | |||
| fba27b3c81 | |||
| 6549bdbacc | |||
| 55ccbb7d51 | |||
| f039b1419f | |||
| 97c558ffe8 | |||
| 8f6f1652e1 | |||
| 26ee3abf82 | |||
| d2b674bb9d | |||
| 2ce4facd9d | |||
| ac4724166d | |||
| 2131bbba0c | |||
| 792738eeed | |||
| 0ac7d0bdb5 | |||
| 4fa42771a7 | |||
| c91cd4be59 | |||
| 851ee033bc | |||
| 192e6ffab2 | |||
| 6b668fefdc | |||
| 8224ad1634 | |||
| 96e8f51436 | |||
| 5e5c4475bc | |||
| 66228fb30b | |||
| 7b8798c996 | |||
| dfd22bf79e | |||
| 307707fd00 | |||
| 2a47c6ca08 | |||
| 704e953b69 | |||
| c5661f8835 | |||
| 60c5a24eef | |||
| 3f9ed573db | |||
| ac432c87f0 | |||
| 95b841a2dd | |||
| 7d63376d98 | |||
| 912c467818 | |||
| c39c6d4479 | |||
| 071e5451b1 | |||
| 159961b3a3 | |||
| 4a3f1ce310 | |||
| 10a658a767 | |||
| 9d63bfcb31 | |||
| daa32ee138 | |||
| a51fa646bb | |||
| 5213070f67 | |||
| 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 | |||
| 131e25e1a1 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -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 hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -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,176 @@
|
||||
---
|
||||
name: publish-extension
|
||||
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
|
||||
---
|
||||
|
||||
# VS Code Extension Release
|
||||
|
||||
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
|
||||
|
||||
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
|
||||
|
||||
## The current era: combined A/B rollout
|
||||
|
||||
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
|
||||
|
||||
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
|
||||
|
||||
### The listings and the workflows
|
||||
|
||||
| Channel | Marketplace ID | Workflow | Trigger | Version |
|
||||
|---|---|---|---|---|
|
||||
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
|
||||
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
|
||||
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
|
||||
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
|
||||
|
||||
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish` → `Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
|
||||
|
||||
## Golden rules (read before any release)
|
||||
|
||||
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
-d '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
|
||||
```
|
||||
|
||||
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
|
||||
(async () => {
|
||||
let t = 0, n = 200;
|
||||
for (let i = 0; i < n; i += 20) {
|
||||
const rs = await Promise.all(Array.from({length: 20}, (_, j) =>
|
||||
fetch("https://data.cline.bot/decide?v=3", { method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({api_key: KEY, distinct_id: `probe-${i+j}-${Math.random()}`})
|
||||
}).then(r => r.json())));
|
||||
for (const r of rs) if ((r.featureFlags||{})["ext-sdk-bundle-rollout"] === true) t++;
|
||||
}
|
||||
console.log(`~${(100*t/n).toFixed(1)}% (${t}/${n})`);
|
||||
})()' "$KEY"
|
||||
```
|
||||
|
||||
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
|
||||
|
||||
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
|
||||
|
||||
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
|
||||
|
||||
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. A run left `waiting` on environment approval blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
|
||||
## Stable release (combined A/B VSIX) — the current stable path
|
||||
|
||||
### Pre-flight
|
||||
|
||||
```bash
|
||||
# 1. What's live, and what version comes next (must exceed it — rule 1)
|
||||
# 2. Flag percentage (rule 2) — decide where it should be for this release
|
||||
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
|
||||
git fetch origin main legacy-extension
|
||||
git log --oneline -3 origin/legacy-extension
|
||||
|
||||
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
|
||||
# hard-fails if views/viewsContainers/configuration diverged between branches.
|
||||
git show origin/main:apps/vscode/package.json > /tmp/next.json
|
||||
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
|
||||
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
|
||||
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
|
||||
```
|
||||
|
||||
Release prep on `main` (PR, not direct push):
|
||||
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
|
||||
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
|
||||
|
||||
### Dispatch
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
|
||||
# publish=false builds an installable .vsix artifact without publishing, but the
|
||||
# package job still requires the same Publish environment approval — an
|
||||
# unapproved rehearsal sits in `waiting` and blocks that version's concurrency
|
||||
# group (rule 5).
|
||||
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
|
||||
```
|
||||
|
||||
Both test suites run first (no approval needed); the gated `package` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
```
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1).
|
||||
2. This workflow does **not** tag or create a GitHub release — do it manually:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<curated notes from CHANGELOG>"
|
||||
```
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
### Known caveats of this path
|
||||
|
||||
- **Marketplace only** — no Open VSX step (both standalone workflows have one). Open VSX users stay on the last standalone version until a standalone publish or the cutover.
|
||||
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
|
||||
- A red run can still mean a successful publish on paths that tag (see Gotchas).
|
||||
|
||||
## Nightly release
|
||||
|
||||
Happens automatically (cron 12:00 UTC). Manual cut:
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
|
||||
gh run watch <run-id> --exit-status --interval 60
|
||||
```
|
||||
|
||||
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
|
||||
|
||||
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
|
||||
|
||||
## Legacy hotfix release (and emergency full rollback)
|
||||
|
||||
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
|
||||
|
||||
```bash
|
||||
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
|
||||
# highest version ever published to the listing (rule 1 — including combined
|
||||
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main \
|
||||
-f release-type=release -f branch=legacy-extension
|
||||
```
|
||||
|
||||
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
|
||||
|
||||
## Cutover: retiring the A/B machinery (the endgame)
|
||||
|
||||
When the next bundle has held at 100% long enough to trust:
|
||||
|
||||
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
|
||||
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
|
||||
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX (this also heals the Open VSX gap).
|
||||
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
|
||||
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
|
||||
6. Update this skill: delete the combined-era sections and keep the standalone flow.
|
||||
|
||||
## Gotchas index
|
||||
|
||||
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
|
||||
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
|
||||
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
|
||||
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package) block their version's concurrency group.
|
||||
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
|
||||
@@ -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) || '' }}"
|
||||
@@ -37,22 +37,123 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
|
||||
# standalone publish paths (nightly gates on the bun suite via the same
|
||||
# reusable workflow; the legacy publish inlines the npm suite). The gated
|
||||
# `package` job requests its `publish` environment approval only after both
|
||||
# suites pass.
|
||||
#
|
||||
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
|
||||
# DISPATCH revision — main's tip at dispatch, since this workflow is only
|
||||
# dispatched from main — not `next-ref`. The package job therefore pins the
|
||||
# default next-ref checkout to that same revision (tested == built) and
|
||||
# refuses publish=true for any other next-ref; build-only artifact runs may
|
||||
# still build untested refs.
|
||||
test-next:
|
||||
name: Test next (SDK) bundle
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
# The legacy branch is the npm codebase, so the bun-based reusable workflow
|
||||
# cannot test it. Inlined npm steps, kept in sync with the `test` job in
|
||||
# ext-vscode-publish-legacy.yml (same suite, different ref input name).
|
||||
test-legacy:
|
||||
name: Test legacy bundle
|
||||
runs-on: ubuntu-latest
|
||||
# The tested revision, exported so the package job builds EXACTLY what
|
||||
# this suite ran against. legacy-ref is a mutable branch name and the
|
||||
# package job starts much later (test phase + environment-approval wait,
|
||||
# potentially days) — re-resolving the name there could pick up commits
|
||||
# this gate never saw.
|
||||
outputs:
|
||||
tested-sha: ${{ steps.rev.outputs.sha }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
|
||||
- name: Record tested revision
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
needs: [test-next, test-legacy]
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
# Refuse to publish a next bundle the test-next gate did not cover.
|
||||
# The reusable suite tests the dispatch revision (main), so publishing
|
||||
# any other next-ref would ship an untested bundle. Build-only runs
|
||||
# (publish=false) may still use arbitrary next-refs for artifact
|
||||
# rehearsals.
|
||||
- name: Refuse to publish an untested next-ref
|
||||
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
|
||||
run: |
|
||||
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
|
||||
exit 1
|
||||
|
||||
# For the default next-ref (main), pin the checkout to the exact
|
||||
# revision the test-next gate ran against: a moving branch name could
|
||||
# otherwise drift past the tested commit during the test phase.
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
# Pin to the revision test-legacy actually tested (see that job's
|
||||
# outputs comment) — never re-resolve the mutable branch name here.
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
ref: ${{ needs.test-legacy.outputs.tested-sha }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
|
||||
@@ -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,150 @@
|
||||
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 --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
|
||||
|
||||
# The desktop chat test imports @cline/shared/browser, which resolves to
|
||||
# dist output that nothing else in this job builds.
|
||||
- name: Build shared package
|
||||
run: bun -F @cline/shared 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,54 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.48
|
||||
|
||||
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
|
||||
- Connector threads (Slack, Discord, Telegram, Linear, Google Chat, WhatsApp) now recover when the session they were bound to is gone — the stale binding is dropped and the turn replays against a new session, instead of failing with "session not found" until `threads.json` is edited by hand
|
||||
- `cline --help` now reports the real default `--config` and `--data-dir` paths
|
||||
- The per-server `timeout` in `cline_mcp_settings.json` is now honored for `initialize`, `tools/list`, and `tools/call`, so slow MCP servers no longer fail against a hardcoded 5s limit (from SDK v0.0.67)
|
||||
- Reasoning controls are now routed from the models.dev catalog across providers, with clamped budgets and correct per-provider encoding (from SDK v0.0.67)
|
||||
- OpenRouter now defaults to `anthropic/claude-sonnet-5` (from SDK v0.0.67)
|
||||
- Fixed the China and international endpoint toggles being ignored for Qwen, Moonshot, and Z AI (from SDK v0.0.67)
|
||||
- Legacy API keys are now migrated for every secret-backed provider (from SDK v0.0.67)
|
||||
- Legacy OpenAI Compatible model-info overrides now survive into the seeded `models.json` (from SDK v0.0.67)
|
||||
- Fixed auto-compaction state being rejected as stale, which added a redundant summarizer call on every turn past the compaction trigger (from SDK v0.0.67)
|
||||
- Fixed checkpoint restores across session resumes (from SDK v0.0.67)
|
||||
- Tool calls that pass line numbers as strings (`insert_line`, `read_files` bounds) are now accepted instead of erroring (from SDK v0.0.67)
|
||||
- A legacy single-file `.clinerules` no longer aborts the config scan (from SDK v0.0.67)
|
||||
- Plugins can now emit telemetry through `ctx.telemetry` (from SDK v0.0.67)
|
||||
|
||||
## 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.48",
|
||||
"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"
|
||||
|
||||
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
"seed history session",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryDelete: vi.fn(async () => 0),
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryList: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
vi.mock("./history", () => historyMocks);
|
||||
|
||||
import { registerHistoryCommand } from "./history-command";
|
||||
|
||||
function createHarness(isInteractiveTTY: boolean) {
|
||||
const program = new Command()
|
||||
.exitOverride()
|
||||
.option("--json", "Output as JSON");
|
||||
program.configureOutput({
|
||||
writeOut: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
});
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
const setExitCode = vi.fn();
|
||||
const setStartupTarget = vi.fn();
|
||||
registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode,
|
||||
setStartupTarget,
|
||||
isInteractiveTTY: () => isInteractiveTTY,
|
||||
});
|
||||
return { program, io, setExitCode, setStartupTarget };
|
||||
}
|
||||
|
||||
describe("registerHistoryCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens the in-app history picker for an interactive text terminal", async () => {
|
||||
const { program, setExitCode, setStartupTarget } = createHarness(true);
|
||||
|
||||
await program.parseAsync(["history"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).toHaveBeenCalledOnce();
|
||||
expect(setStartupTarget).toHaveBeenCalledWith("history");
|
||||
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
||||
expect(setExitCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps explicit JSON output non-interactive even when a TTY is attached", async () => {
|
||||
const { program, io, setExitCode, setStartupTarget } = createHarness(true);
|
||||
|
||||
await program.parseAsync(["history", "--json"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).not.toHaveBeenCalled();
|
||||
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
outputMode: "json",
|
||||
io,
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("prints text history when no interactive terminal is attached", async () => {
|
||||
const { program, io, setExitCode, setStartupTarget } = createHarness(false);
|
||||
|
||||
await program.parseAsync(["history", "--limit", "12"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).not.toHaveBeenCalled();
|
||||
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
|
||||
limit: 12,
|
||||
outputMode: "text",
|
||||
io,
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("returns an error when delete is missing --session-id", async () => {
|
||||
const { program, io, setExitCode } = createHarness(false);
|
||||
|
||||
await program.parseAsync(["history", "delete"], { from: "user" });
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"history delete requires --session-id <id>",
|
||||
);
|
||||
expect(historyMocks.runHistoryDelete).not.toHaveBeenCalled();
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Command } from "commander";
|
||||
import type { TuiStartupTarget } from "../tui/types";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
import {
|
||||
runHistoryDelete,
|
||||
runHistoryExport,
|
||||
runHistoryList,
|
||||
runHistoryUpdate,
|
||||
} from "./history";
|
||||
|
||||
type HistoryCommandIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
type RegisterHistoryCommandOptions = {
|
||||
program: Command;
|
||||
io: HistoryCommandIo;
|
||||
setExitCode: (code: number) => void;
|
||||
setStartupTarget: (target: TuiStartupTarget) => void;
|
||||
isInteractiveTTY?: () => boolean;
|
||||
};
|
||||
|
||||
function resolveHistoryOutputMode(
|
||||
program: Command,
|
||||
historyCmd: Command,
|
||||
): CliOutputMode {
|
||||
return program.opts().json || historyCmd.opts().json ? "json" : "text";
|
||||
}
|
||||
|
||||
export function registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode,
|
||||
setStartupTarget,
|
||||
isInteractiveTTY = () =>
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true,
|
||||
}: RegisterHistoryCommandOptions): void {
|
||||
const historyCmd = program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List session history or manage saved sessions")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--limit <count>", "Maximum number of sessions to show", "50")
|
||||
.option("--page <number>", "Page number for paginated results")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const opts = historyCmd.opts();
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
if (outputMode === "text" && isInteractiveTTY()) {
|
||||
setStartupTarget("history");
|
||||
return;
|
||||
}
|
||||
setExitCode(
|
||||
await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
io,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const historyDeleteCmd = historyCmd
|
||||
.command("delete")
|
||||
.description("Delete a session from history")
|
||||
.option("--session-id <id>", "Session ID to delete")
|
||||
.action(async () => {
|
||||
const opts = historyDeleteCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
io.writeErr("history delete requires --session-id <id>");
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(await runHistoryDelete(opts.sessionId, outputMode, io));
|
||||
});
|
||||
|
||||
const historyUpdateCmd = historyCmd
|
||||
.command("update")
|
||||
.description("Update a session in history")
|
||||
.option("--metadata <json>", "Metadata as JSON string")
|
||||
.option("--prompt <text>", "New prompt text")
|
||||
.option("--session-id <id>", "Session ID to update")
|
||||
.option("--title <text>", "New title")
|
||||
.action(async () => {
|
||||
const opts = historyUpdateCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
io.writeErr("history update requires --session-id <id>");
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(
|
||||
await runHistoryUpdate(
|
||||
opts.sessionId,
|
||||
opts.prompt,
|
||||
opts.title,
|
||||
opts.metadata,
|
||||
outputMode,
|
||||
io,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const historyExportCmd = historyCmd
|
||||
.command("export <sessionId>")
|
||||
.description("Export a session as a standalone HTML file")
|
||||
.option("-o, --output <path>", "Output HTML file path")
|
||||
.action(async (sessionId: string) => {
|
||||
const opts = historyExportCmd.opts();
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(
|
||||
await runHistoryExport(sessionId, opts.output, outputMode, io),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SessionHistoryRecord } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import {
|
||||
formatCheckpointDetail,
|
||||
formatHistoryListLine,
|
||||
@@ -16,18 +17,12 @@ vi.mock("../session/session", () => ({
|
||||
readSessionMessagesArtifact: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../tui/history-standalone", () => ({
|
||||
renderHistoryStandalone: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
import { listSessions, readSessionMessagesArtifact } from "../session/session";
|
||||
import { renderHistoryStandalone } from "../tui/history-standalone";
|
||||
|
||||
const mockedReadSessionMessagesArtifact = vi.mocked(
|
||||
readSessionMessagesArtifact,
|
||||
);
|
||||
const mockedListSessions = vi.mocked(listSessions);
|
||||
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
|
||||
|
||||
function createHistoryRow(
|
||||
overrides: Partial<SessionHistoryRecord> = {},
|
||||
@@ -200,8 +195,11 @@ describe("runHistoryList", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
|
||||
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
|
||||
it("requests hydrated text history rows so titles can come from messages", async () => {
|
||||
const row = createHistoryRow({
|
||||
prompt: undefined,
|
||||
metadata: { title: "hydrated title", totalCost: 0.25 },
|
||||
});
|
||||
mockedListSessions.mockResolvedValue([row]);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
@@ -218,8 +216,8 @@ describe("runHistoryList", () => {
|
||||
expect(mockedListSessions).toHaveBeenCalledWith(25, {
|
||||
hydrate: true,
|
||||
});
|
||||
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rows: [row] }),
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hydrated title"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -313,6 +311,42 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("writes structured JSON from a persisted messages artifact", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
systemPrompt: "Be helpful",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "world" }],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
|
||||
const targetPath = await exportHistorySession({
|
||||
sessionId: "sess_1",
|
||||
format: "json",
|
||||
outputDirectory: tempDir,
|
||||
});
|
||||
|
||||
expect(targetPath).toBe(join(tempDir, "sess_1.json"));
|
||||
await expect(
|
||||
readFile(targetPath, "utf8").then((contents) => JSON.parse(contents)),
|
||||
).resolves.toEqual(artifact);
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { generateConversationHTML } from "../session/export";
|
||||
import {
|
||||
deleteSession,
|
||||
listSessions,
|
||||
readSessionMessagesArtifact,
|
||||
updateSession,
|
||||
} from "../session/session";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import { deleteSession, listSessions, updateSession } from "../session/session";
|
||||
import { formatHistoryListLine } from "../utils/history-format";
|
||||
import { writeln } from "../utils/output";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
|
||||
@@ -22,22 +15,6 @@ type HistoryIo = {
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
async function exportHistorySession(
|
||||
sessionId: string,
|
||||
outputPath?: string,
|
||||
): Promise<string> {
|
||||
const data = await readSessionMessagesArtifact(sessionId);
|
||||
if (!data) {
|
||||
throw new Error(`Session ${sessionId} not found or has no messages.json`);
|
||||
}
|
||||
|
||||
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
|
||||
const html = generateConversationHTML(data, sessionId);
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, html, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function runHistoryDelete(
|
||||
sessionId: string | undefined,
|
||||
outputMode: CliOutputMode,
|
||||
@@ -136,7 +113,11 @@ async function runHistoryExport(
|
||||
}
|
||||
|
||||
try {
|
||||
const targetPath = await exportHistorySession(sessionId, outputPath);
|
||||
const targetPath = await exportHistorySession({
|
||||
sessionId,
|
||||
format: "html",
|
||||
outputPath,
|
||||
});
|
||||
|
||||
if (outputMode === "json") {
|
||||
process.stdout.write(
|
||||
@@ -161,7 +142,7 @@ export async function runHistoryList(input: {
|
||||
outputMode: CliOutputMode;
|
||||
workspaceRoot?: string;
|
||||
io?: HistoryIo;
|
||||
}): Promise<number | string> {
|
||||
}): Promise<number> {
|
||||
const io = input.io ?? {
|
||||
writeln,
|
||||
writeErr: (text: string) => process.stderr.write(`${text}\n`),
|
||||
@@ -186,23 +167,10 @@ export async function runHistoryList(input: {
|
||||
return 0;
|
||||
}
|
||||
|
||||
disableOpenTuiGraphicsProbe();
|
||||
const { renderHistoryStandalone } = await import("../tui/history-standalone");
|
||||
return await renderHistoryStandalone({
|
||||
rows,
|
||||
refreshRows: async () =>
|
||||
await listSessions(limit, {
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
hydrate: false,
|
||||
}),
|
||||
onExport: async (sessionId: string) =>
|
||||
await exportHistorySession(sessionId, undefined),
|
||||
});
|
||||
for (const row of rows) {
|
||||
io.writeln(formatHistoryListLine(row));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export {
|
||||
exportHistorySession,
|
||||
runHistoryDelete,
|
||||
runHistoryExport,
|
||||
runHistoryUpdate,
|
||||
};
|
||||
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
|
||||
|
||||
@@ -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({
|
||||
@@ -1103,6 +1120,8 @@ class DiscordConnector extends ConnectorBase<
|
||||
},
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -1135,6 +1154,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
resolveMuteTarget: ({ target }) => resolveDiscordMuteTarget(target),
|
||||
createEmptyRuntimeReplyResolver:
|
||||
@@ -1274,9 +1294,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -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
|
||||
@@ -591,6 +615,8 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -613,6 +639,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -674,9 +701,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -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({
|
||||
@@ -626,6 +638,8 @@ class LinearConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -650,6 +664,7 @@ class LinearConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -711,9 +726,7 @@ class LinearConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -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({
|
||||
@@ -827,6 +839,8 @@ class SlackConnector extends ConnectorBase<
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await withSlackTeamBotToken({
|
||||
@@ -855,6 +869,7 @@ class SlackConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (
|
||||
currentThread,
|
||||
@@ -939,9 +954,7 @@ class SlackConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -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({
|
||||
@@ -789,6 +817,8 @@ class TelegramConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -813,6 +843,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
forceDisableTools: !options.enableTools,
|
||||
postFinalReply: async ({
|
||||
@@ -924,9 +955,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -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({
|
||||
@@ -598,6 +609,8 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -622,6 +635,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -698,9 +712,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SentMessage } from "chat";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { enqueueThreadTurn } from "./chat-runtime";
|
||||
import { handleConnectorUserTurn } from "./connector-host";
|
||||
|
||||
vi.mock("./hooks", () => ({
|
||||
@@ -43,7 +44,22 @@ function createThread(initialState: TestState = {}, isDM = true) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
async post(message: unknown) {
|
||||
posts.push(message);
|
||||
// Real thread implementations drain async-iterable replies; the
|
||||
// connector streams runtime output straight into post() for
|
||||
// transports without a custom final-reply hook.
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
Symbol.asyncIterator in message
|
||||
) {
|
||||
let streamed = "";
|
||||
for await (const chunk of message as AsyncIterable<string>) {
|
||||
streamed += chunk;
|
||||
}
|
||||
posts.push(streamed);
|
||||
} else {
|
||||
posts.push(message);
|
||||
}
|
||||
const sentMessage = {
|
||||
edit: async (nextMessage: unknown) => {
|
||||
posts.push(nextMessage);
|
||||
@@ -99,13 +115,21 @@ function createRuntimeClient(
|
||||
);
|
||||
const abortRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const sendRuntimeSession = vi.fn(async () => ({
|
||||
result: {
|
||||
text: responseText,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
}));
|
||||
const sendRuntimeSession = vi.fn(
|
||||
async (
|
||||
_sessionId: string,
|
||||
_request?: unknown,
|
||||
_options?: unknown,
|
||||
): Promise<{
|
||||
result?: { text: string; finishReason: string; iterations: number };
|
||||
}> => ({
|
||||
result: {
|
||||
text: responseText,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const readMessages = vi.fn(async () => messages);
|
||||
return {
|
||||
client: {
|
||||
@@ -138,6 +162,10 @@ function messageText(message: unknown): string {
|
||||
return String(message);
|
||||
}
|
||||
|
||||
async function runTurnImmediately(work: () => Promise<void>): Promise<void> {
|
||||
await work();
|
||||
}
|
||||
|
||||
describe("handleConnectorUserTurn", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -542,6 +570,316 @@ describe("handleConnectorUserTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers from a stale thread session mapping by starting a new session", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("recovered reply");
|
||||
// The hub still reports the persisted session row, so the connector reuses
|
||||
// the stale mapping...
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
// ...but sending input to the dead session fails with session_not_found
|
||||
// until a fresh session id is used.
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "dead-session") {
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
text: "recovered reply",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "are you there?",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
});
|
||||
|
||||
expect(
|
||||
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
|
||||
).toEqual(["dead-session", "fresh-session"]);
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("session not found"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry forever when the replacement session is also missing", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("never delivered");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "also-dead-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async () => {
|
||||
throw Object.assign(new Error("session not found"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "are you there?",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
}),
|
||||
).rejects.toThrow(/session not found/);
|
||||
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts a new session when steering an active turn hits a dead session", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("recovered reply");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "dead-session") {
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
text: "recovered reply",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
const activeTurns = new Map([
|
||||
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "actually do this instead",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns: activeTurns as never,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("Steering current task."),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes concurrent recovery from the same stale active turn", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("unused");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
|
||||
let releaseStaleSteers = () => {};
|
||||
const bothStaleSteersStarted = new Promise<void>((resolve) => {
|
||||
releaseStaleSteers = resolve;
|
||||
});
|
||||
let staleSteerCount = 0;
|
||||
runtime.sendRuntimeSession.mockImplementation(
|
||||
async (sessionId: string, request?: unknown) => {
|
||||
if (sessionId === "dead-session") {
|
||||
staleSteerCount += 1;
|
||||
if (staleSteerCount === 2) {
|
||||
releaseStaleSteers();
|
||||
}
|
||||
await bothStaleSteersStarted;
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
const prompt =
|
||||
request && typeof request === "object" && "prompt" in request
|
||||
? String((request as { prompt?: unknown }).prompt)
|
||||
: "";
|
||||
return {
|
||||
result: {
|
||||
text: `recovered: ${prompt}`,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const activeTurns = new Map([
|
||||
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
|
||||
]);
|
||||
const threadQueues = new Map<string, Promise<void>>();
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, "thread-1", work);
|
||||
const commonInput = {
|
||||
thread: thread as never,
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: "thread-1",
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
handleConnectorUserTurn({
|
||||
...commonInput,
|
||||
text: "first recovery message",
|
||||
}),
|
||||
handleConnectorUserTurn({
|
||||
...commonInput,
|
||||
text: "second recovery message",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
|
||||
).toEqual([
|
||||
"dead-session",
|
||||
"dead-session",
|
||||
"fresh-session",
|
||||
"fresh-session",
|
||||
]);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(activeTurns.size).toBe(0);
|
||||
expect(posts.map(messageText)).toEqual([
|
||||
"recovered: first recovery message",
|
||||
"recovered: second recovery message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates schedules with forced-disabled runtime options", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -977,6 +1315,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "current-participant",
|
||||
});
|
||||
|
||||
@@ -1225,6 +1564,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
resolveMuteTarget: () => ({
|
||||
participantKey: "discord:user:bob",
|
||||
participantLabel: "<@bob>",
|
||||
@@ -1426,6 +1766,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
|
||||
@@ -1476,6 +1817,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
@@ -1526,6 +1868,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
HubSessionClient,
|
||||
UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { isSessionNotFoundError } from "@cline/core";
|
||||
import type { SentMessage, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import {
|
||||
buildThreadStartRequest,
|
||||
clearSession,
|
||||
forgetThreadSession,
|
||||
getOrCreateSessionId,
|
||||
} from "./session-runtime";
|
||||
import {
|
||||
@@ -51,6 +53,20 @@ export type ActiveConnectorTurn = {
|
||||
participantKey?: string;
|
||||
};
|
||||
|
||||
type ConnectorTurnQueue = (work: () => Promise<void>) => Promise<void>;
|
||||
|
||||
type ConnectorTurnCoordination =
|
||||
| {
|
||||
activeTurns: Map<string, ActiveConnectorTurn>;
|
||||
enqueueTurn: ConnectorTurnQueue;
|
||||
turnKey?: string;
|
||||
}
|
||||
| {
|
||||
activeTurns?: undefined;
|
||||
enqueueTurn?: undefined;
|
||||
turnKey?: string;
|
||||
};
|
||||
|
||||
type EmptyRuntimeReplyResolver = () => Promise<string | undefined>;
|
||||
|
||||
type EmptyRuntimeReplyResolverFactory = (input: {
|
||||
@@ -135,6 +151,45 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
|
||||
await postConnectorText(thread, transport, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a thread's stale session mapping after the hub reported the mapped
|
||||
* session no longer exists, so the next turn starts a fresh session instead of
|
||||
* failing forever against a dead session id.
|
||||
*/
|
||||
async function forgetStaleThreadSession<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
logger: CliLoggerAdapter;
|
||||
transport: string;
|
||||
sessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const forgotten = await forgetThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
expectedSessionId: input.sessionId,
|
||||
});
|
||||
if (!forgotten) {
|
||||
return false;
|
||||
}
|
||||
input.logger.core.log(
|
||||
"Connector thread session no longer exists; starting a new session",
|
||||
{
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: input.sessionId,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyForcedToolDisable<TState extends ConnectorThreadState>(
|
||||
state: TState,
|
||||
forceDisableTools: boolean | undefined,
|
||||
@@ -200,9 +255,7 @@ function formatMuteTargetList(targets: ConnectorMuteTarget[]): string {
|
||||
return targets.map(formatMuteTargetLabel).join(", ");
|
||||
}
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
|
||||
thread: Thread<TState>;
|
||||
text: string;
|
||||
runtimeText?: string;
|
||||
@@ -231,8 +284,6 @@ export async function handleConnectorUserTurn<
|
||||
firstContactMessage?: string | ((currentState: TState) => string | undefined);
|
||||
chatCommandHost?: ChatCommandHost;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
activeTurns?: Map<string, ActiveConnectorTurn>;
|
||||
turnKey?: string;
|
||||
resolveMuteTarget?: (input: {
|
||||
target: string;
|
||||
thread: Thread<TState>;
|
||||
@@ -276,7 +327,11 @@ export async function handleConnectorUserTurn<
|
||||
threadId: string;
|
||||
error: Error;
|
||||
}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
} & ConnectorTurnCoordination;
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: ConnectorUserTurnInput<TState>): Promise<void> {
|
||||
const resolvedInput = input.text.trim();
|
||||
if (!resolvedInput) {
|
||||
return;
|
||||
@@ -892,30 +947,61 @@ export async function handleConnectorUserTurn<
|
||||
input.baseStartRequest,
|
||||
effectiveCurrentState,
|
||||
);
|
||||
const activeTurn =
|
||||
input.activeTurns?.get(turnKey) ??
|
||||
(input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.values()).find(
|
||||
(turn) =>
|
||||
const keyedActiveTurn = input.activeTurns?.get(turnKey);
|
||||
const activeTurnEntry = keyedActiveTurn
|
||||
? ([turnKey, keyedActiveTurn] as const)
|
||||
: input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.entries()).find(
|
||||
([, turn]) =>
|
||||
turn.sessionId === currentState.sessionId?.trim() &&
|
||||
turn.threadId === input.thread.id,
|
||||
)
|
||||
: undefined);
|
||||
if (activeTurn?.sessionId?.trim()) {
|
||||
: undefined;
|
||||
if (activeTurnEntry?.[1].sessionId?.trim()) {
|
||||
const [activeTurnKey, activeTurn] = activeTurnEntry;
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
);
|
||||
await input.client.sendRuntimeSession(
|
||||
activeTurn.sessionId,
|
||||
{
|
||||
config: startRequest,
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
activeTurn.sessionId,
|
||||
{
|
||||
config: startRequest,
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// The tracked turn points at a session the hub no longer knows about.
|
||||
// Remove only the entry we attempted to steer, then route recovery
|
||||
// through the normal per-thread queue. Concurrent messages that saw
|
||||
// the same stale turn will line up behind this one instead of creating
|
||||
// independent replacement sessions.
|
||||
if (input.activeTurns?.get(activeTurnKey) === activeTurn) {
|
||||
input.activeTurns.delete(activeTurnKey);
|
||||
}
|
||||
const enqueueTurn = input.enqueueTurn;
|
||||
if (!enqueueTurn) {
|
||||
throw new Error(
|
||||
"Active connector turns require a per-thread turn queue",
|
||||
);
|
||||
}
|
||||
await enqueueTurn(() =>
|
||||
runConnectorRuntimeTurnWithRecovery({
|
||||
input,
|
||||
runtimeInput,
|
||||
turnKey,
|
||||
staleSessionId: activeTurn.sessionId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await postConnectorText(
|
||||
input.thread,
|
||||
input.transport,
|
||||
@@ -923,25 +1009,44 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionId = await getOrCreateSessionId({
|
||||
thread: input.thread,
|
||||
client: input.client,
|
||||
startRequest,
|
||||
logger: input.logger,
|
||||
clientId: input.clientId,
|
||||
transport: input.transport,
|
||||
bindingsPath: input.bindingsPath,
|
||||
errorLabel: input.errorLabel,
|
||||
hookCommand: input.hookCommand,
|
||||
hookBotUserName: input.botUserName,
|
||||
sessionMetadata: input.getSessionMetadata(
|
||||
input.thread,
|
||||
input.clientId,
|
||||
currentState,
|
||||
),
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
|
||||
await runConnectorRuntimeTurnWithRecovery({
|
||||
input,
|
||||
runtimeInput,
|
||||
turnKey,
|
||||
currentState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a queued connector turn, replacing a stale session mapping at most once
|
||||
* before replaying the user's input.
|
||||
*/
|
||||
async function runConnectorRuntimeTurnWithRecovery<
|
||||
TState extends ConnectorThreadState,
|
||||
>(params: {
|
||||
input: ConnectorUserTurnInput<TState>;
|
||||
runtimeInput: string;
|
||||
turnKey: string;
|
||||
currentState?: TState;
|
||||
staleSessionId?: string;
|
||||
}): Promise<void> {
|
||||
const { input, runtimeInput, turnKey } = params;
|
||||
const currentState =
|
||||
params.currentState ??
|
||||
(await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
));
|
||||
const effectiveCurrentState = applyForcedToolDisable(
|
||||
currentState,
|
||||
input.forceDisableTools,
|
||||
);
|
||||
const startRequest = buildThreadStartRequest(
|
||||
input.baseStartRequest,
|
||||
effectiveCurrentState,
|
||||
);
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
@@ -951,16 +1056,107 @@ export async function handleConnectorUserTurn<
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
};
|
||||
if (params.staleSessionId) {
|
||||
await forgetStaleThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
logger: input.logger,
|
||||
transport: input.transport,
|
||||
sessionId: params.staleSessionId,
|
||||
});
|
||||
}
|
||||
// A thread binding can outlive its runtime session (hub restart, session
|
||||
// deletion, retention cleanup). When that happens the turn fails with
|
||||
// `session_not_found`; drop the stale mapping and replay the turn once
|
||||
// against a brand new session instead of wedging the thread forever.
|
||||
const resolveSessionId = () =>
|
||||
getOrCreateSessionId({
|
||||
thread: input.thread,
|
||||
client: input.client,
|
||||
startRequest,
|
||||
logger: input.logger,
|
||||
clientId: input.clientId,
|
||||
transport: input.transport,
|
||||
bindingsPath: input.bindingsPath,
|
||||
errorLabel: input.errorLabel,
|
||||
hookCommand: input.hookCommand,
|
||||
hookBotUserName: input.botUserName,
|
||||
sessionMetadata: input.getSessionMetadata(
|
||||
input.thread,
|
||||
input.clientId,
|
||||
currentState,
|
||||
),
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
});
|
||||
let sessionId = await resolveSessionId();
|
||||
let allowStaleSessionRetry = params.staleSessionId === undefined;
|
||||
for (;;) {
|
||||
try {
|
||||
await runConnectorRuntimeTurn({
|
||||
input,
|
||||
sessionId,
|
||||
request,
|
||||
currentState,
|
||||
turnKey,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!allowStaleSessionRetry || !isSessionNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
allowStaleSessionRetry = false;
|
||||
await forgetStaleThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
logger: input.logger,
|
||||
transport: input.transport,
|
||||
sessionId,
|
||||
});
|
||||
sessionId = await resolveSessionId();
|
||||
}
|
||||
}
|
||||
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...currentState,
|
||||
sessionId,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single connector turn against an already-resolved session and streams
|
||||
* the reply back into the thread.
|
||||
*/
|
||||
async function runConnectorRuntimeTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(params: {
|
||||
input: ConnectorUserTurnInput<TState>;
|
||||
sessionId: string;
|
||||
request: ChatRunTurnRequest;
|
||||
currentState: TState;
|
||||
turnKey: string;
|
||||
}): Promise<void> {
|
||||
const { input, sessionId, request, currentState, turnKey } = params;
|
||||
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
|
||||
client: input.client,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
input.activeTurns?.set(turnKey, {
|
||||
const activeTurn: ActiveConnectorTurn = {
|
||||
sessionId,
|
||||
threadId: input.thread.id,
|
||||
participantKey: currentState.participantKey,
|
||||
});
|
||||
};
|
||||
input.activeTurns?.set(turnKey, activeTurn);
|
||||
await input.thread.startTyping();
|
||||
let toolStatusMessage: SentMessage | undefined;
|
||||
const postFinalReply = input.postFinalReply
|
||||
@@ -1025,21 +1221,13 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
} finally {
|
||||
input.pendingApprovals.delete(input.thread.id);
|
||||
input.activeTurns?.delete(turnKey);
|
||||
if (input.activeTurns?.get(turnKey) === activeTurn) {
|
||||
input.activeTurns.delete(turnKey);
|
||||
}
|
||||
if (toolStatusMessage) {
|
||||
await toolStatusMessage.delete().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...currentState,
|
||||
sessionId,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function maybeHandleConnectorApprovalReply<
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -283,6 +283,43 @@ export async function getOrCreateSessionId<
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a thread's session mapping without touching the runtime session, but
|
||||
* only when it still points at the session the caller observed as stale.
|
||||
*
|
||||
* Used when the hub reports the mapped session no longer exists: the thread
|
||||
* binding may have been recovered concurrently, so a newer session id must
|
||||
* never be cleared by an older failure.
|
||||
*/
|
||||
export async function forgetThreadSession<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
expectedSessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const threadState = await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
);
|
||||
if (threadState.sessionId?.trim() !== input.expectedSessionId.trim()) {
|
||||
return false;
|
||||
}
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: undefined,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearSession<TState extends ConnectorThreadState>(input: {
|
||||
thread: Thread<TState>;
|
||||
client: HubSessionClient;
|
||||
|
||||
@@ -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");
|
||||
|
||||
+277
-72
@@ -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<
|
||||
(
|
||||
@@ -86,16 +96,11 @@ const worktreeMocks = vi.hoisted(() => ({
|
||||
createTaskWorktree: vi.fn(),
|
||||
}));
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryList: vi.fn<() => Promise<number | string>>(async () => 0),
|
||||
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
|
||||
runHistoryDelete: vi.fn(async () => 0),
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
const historyResumeMocks = vi.hoisted(() => ({
|
||||
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
const loggingMocks = vi.hoisted(() => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
@@ -198,18 +203,27 @@ 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);
|
||||
vi.mock("./utils/history-resume", () => historyResumeMocks);
|
||||
vi.mock("./logging/adapter", () => loggingMocks);
|
||||
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
||||
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;
|
||||
@@ -221,8 +235,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
historyMocks.runHistoryExport.mockResolvedValue(0);
|
||||
historyMocks.runHistoryUpdate.mockReset();
|
||||
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
||||
historyResumeMocks.spawnHistoryResume.mockReset();
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
|
||||
sessionMocks.getSessionRow.mockReset();
|
||||
sessionMocks.getSessionRow.mockResolvedValue({
|
||||
sessionId: "sess_123",
|
||||
@@ -273,6 +285,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();
|
||||
@@ -294,11 +316,25 @@ describe("runCli lightweight command dispatch", () => {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 +369,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 () => {
|
||||
@@ -391,7 +476,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
@@ -528,10 +613,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
it("creates a worktree for default interactive mode", async () => {
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "--worktree"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -634,7 +715,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialView: undefined,
|
||||
startupTarget: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -645,10 +726,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -678,10 +755,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/test-model",
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -786,7 +859,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialPrompt: "sup",
|
||||
initialView: undefined,
|
||||
startupTarget: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -848,6 +921,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"];
|
||||
|
||||
@@ -860,65 +1099,33 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.anything(),
|
||||
"sess_123",
|
||||
expect.objectContaining({
|
||||
initialView: "chat",
|
||||
startupTarget: "chat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes a history-picked session in a child process", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess_from_history",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates the child exit code when resuming from history picker", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces chat view when the history-picker child cannot launch", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
|
||||
it("opens history inside the interactive TUI for the history picker", async () => {
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
||||
expect(
|
||||
migrationNoticeMocks.getClineCliMigrationNotice,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
"sess_from_history",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialPrompt: undefined,
|
||||
initialView: "chat",
|
||||
startupTarget: "history",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1302,7 +1509,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
@@ -1389,7 +1595,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 +1610,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
|
||||
+90
-162
@@ -5,6 +5,7 @@ import type { ToolPolicy } from "@cline/core";
|
||||
|
||||
import { registerDisposable } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { registerHistoryCommand } from "./commands/history-command";
|
||||
import {
|
||||
CommanderError,
|
||||
commanderToParsedArgs,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import type { TuiStartupTarget } from "./tui/types";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
@@ -43,6 +45,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,
|
||||
@@ -131,11 +138,19 @@ function writePromptArgError(args: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function startupTargetTakesPrecedenceOverMigrationNotice(
|
||||
target: TuiStartupTarget | undefined,
|
||||
): boolean {
|
||||
return target === "config" || target === "history";
|
||||
}
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
installStreamErrorGuards();
|
||||
autoUpdateOnStartup();
|
||||
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const isFullTTY =
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
const configDir = resolveConfigDirArg(cliArgs);
|
||||
const { setClineDir, setHomeDir } = await import("@cline/shared/storage");
|
||||
if (configDir) {
|
||||
@@ -149,11 +164,13 @@ export async function runCli(): Promise<void> {
|
||||
// `--config <dir>` rather than the default home/config location.
|
||||
captureCliExtensionActivated();
|
||||
|
||||
let launchConfigView = false;
|
||||
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
|
||||
|
||||
// Subcommand routing via Commander
|
||||
const ctx: { exitCode?: number; resumeSessionId?: string } = {};
|
||||
const ctx: {
|
||||
exitCode?: number;
|
||||
startupTarget?: TuiStartupTarget;
|
||||
} = {};
|
||||
const io = { writeln, writeErr };
|
||||
const program = createProgram();
|
||||
// Re-enable built-in help/version output for the routing program
|
||||
@@ -244,7 +261,7 @@ export async function runCli(): Promise<void> {
|
||||
ctx.exitCode = code;
|
||||
},
|
||||
() => {
|
||||
launchConfigView = true;
|
||||
ctx.startupTarget = "config";
|
||||
},
|
||||
);
|
||||
return configCmd;
|
||||
@@ -362,6 +379,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 +394,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).
|
||||
@@ -390,7 +428,7 @@ export async function runCli(): Promise<void> {
|
||||
connectCmd.args.slice(1),
|
||||
io,
|
||||
);
|
||||
} else if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
} else if (isFullTTY) {
|
||||
ctx.exitCode = await runConnectWizard();
|
||||
} else {
|
||||
writeln(`\nAdapters:\n${formatAdapterList()}`);
|
||||
@@ -402,7 +440,7 @@ export async function runCli(): Promise<void> {
|
||||
.command("mcp")
|
||||
.description("Manage MCP servers")
|
||||
.action(async () => {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
if (isFullTTY) {
|
||||
ctx.exitCode = await runMcpWizard();
|
||||
} else {
|
||||
writeln(
|
||||
@@ -467,107 +505,17 @@ export async function runCli(): Promise<void> {
|
||||
await doctorCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
|
||||
const historyCmd = program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List session history or manage saved sessions")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--limit <count>", "Maximum number of sessions to show", "50")
|
||||
.option("--page <number>", "Page number for paginated results")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const opts = historyCmd.opts();
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const outputMode =
|
||||
program.opts().json || opts.json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryList } = await import("./commands/history");
|
||||
const result = await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
io,
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
ctx.resumeSessionId = result;
|
||||
// JSON listing should never return a session id; if it does, still exit here so
|
||||
// we never fall through to agent bootstrap (which can block on stdin in CI).
|
||||
if (outputMode === "json") {
|
||||
ctx.exitCode = 0;
|
||||
}
|
||||
} else {
|
||||
// Always set exit code for numeric results so `ctx.exitCode` is never left
|
||||
// undefined (that would fall through and load the full CLI runtime).
|
||||
ctx.exitCode = result ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
const historyDeleteCmd = historyCmd
|
||||
.command("delete")
|
||||
.description("Delete a session from history")
|
||||
.option("--session-id <id>", "Session ID to delete")
|
||||
.action(async () => {
|
||||
const opts = historyDeleteCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
writeErr("history delete requires --session-id <id>");
|
||||
ctx.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryDelete } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryDelete(opts.sessionId, outputMode, io);
|
||||
});
|
||||
|
||||
const historyUpdateCmd = historyCmd
|
||||
.command("update")
|
||||
.description("Update a session in history")
|
||||
.option("--metadata <json>", "Metadata as JSON string")
|
||||
.option("--prompt <text>", "New prompt text")
|
||||
.option("--session-id <id>", "Session ID to update")
|
||||
.option("--title <text>", "New title")
|
||||
.action(async () => {
|
||||
const opts = historyUpdateCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
writeErr("history update requires --session-id <id>");
|
||||
ctx.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryUpdate } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryUpdate(
|
||||
opts.sessionId,
|
||||
opts.prompt,
|
||||
opts.title,
|
||||
opts.metadata,
|
||||
outputMode,
|
||||
io,
|
||||
);
|
||||
});
|
||||
|
||||
const historyExportCmd = historyCmd
|
||||
.command("export <sessionId>")
|
||||
.description("Export a session as a standalone HTML file")
|
||||
.option("-o, --output <path>", "Output HTML file path")
|
||||
.action(async (sessionId: string) => {
|
||||
const opts = historyExportCmd.opts();
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryExport } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryExport(
|
||||
sessionId,
|
||||
opts.output,
|
||||
outputMode,
|
||||
io,
|
||||
);
|
||||
});
|
||||
registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode: (code) => {
|
||||
ctx.exitCode = code;
|
||||
},
|
||||
setStartupTarget: (target) => {
|
||||
ctx.startupTarget = target;
|
||||
},
|
||||
isInteractiveTTY: () => isFullTTY,
|
||||
});
|
||||
|
||||
program
|
||||
.command("hook")
|
||||
@@ -599,11 +547,7 @@ export async function runCli(): Promise<void> {
|
||||
.allowExcessArguments()
|
||||
.passThroughOptions()
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
if (
|
||||
cmd.args.length === 0 &&
|
||||
process.stdin.isTTY &&
|
||||
process.stdout.isTTY
|
||||
) {
|
||||
if (cmd.args.length === 0 && isFullTTY) {
|
||||
ctx.exitCode = await runScheduleWizard();
|
||||
return;
|
||||
}
|
||||
@@ -751,30 +695,8 @@ export async function runCli(): Promise<void> {
|
||||
// Default flow: no subcommand matched, or fall-through from config/history.
|
||||
let args = commanderToParsedArgs(program);
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
// The history picker already created (and tore down) an OpenTUI renderer
|
||||
// in this process; starting the interactive TUI here would create a
|
||||
// second one, which can crash natively during teardown. Resume in a
|
||||
// fresh `cline --id <session-id>` child process instead.
|
||||
const { spawnHistoryResume } = await import("./utils/history-resume");
|
||||
const childExitCode = await spawnHistoryResume({
|
||||
sessionId: resumeSessionId,
|
||||
normalizedArgs,
|
||||
remainingArgs: program.args,
|
||||
configDir,
|
||||
});
|
||||
if (childExitCode !== undefined) {
|
||||
process.exitCode = childExitCode;
|
||||
return;
|
||||
}
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let startupTarget = ctx.startupTarget;
|
||||
let resumeSessionId: string | undefined;
|
||||
if (args.id !== undefined) {
|
||||
const sessionId = args.id.trim();
|
||||
if (!sessionId) {
|
||||
@@ -783,16 +705,12 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
resumeSessionId = sessionId;
|
||||
startupTarget = "chat";
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = "1";
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
};
|
||||
} else {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
}
|
||||
if (launchConfigView) {
|
||||
if (startupTarget) {
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
@@ -844,14 +762,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(
|
||||
@@ -874,7 +784,7 @@ export async function runCli(): Promise<void> {
|
||||
!args.prompt &&
|
||||
!resumeSessionId &&
|
||||
!stdinHasPipedInput() &&
|
||||
(!process.stdin.isTTY || !process.stdout.isTTY)
|
||||
!isFullTTY
|
||||
) {
|
||||
writeErr("--worktree without a prompt requires an interactive terminal.");
|
||||
process.exitCode = 1;
|
||||
@@ -928,6 +838,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 +1017,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 +1031,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),
|
||||
@@ -1200,12 +1131,6 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const runInteractive = await loadInteractiveRuntimeModule();
|
||||
let initialView: "chat" | "config" | undefined;
|
||||
if (launchConfigView) {
|
||||
initialView = "config";
|
||||
} else if (resumeSessionId) {
|
||||
initialView = "chat";
|
||||
}
|
||||
const initialClineProviderSettings =
|
||||
provider === "cline" ? selectedProviderSettings : undefined;
|
||||
let initialNotice:
|
||||
@@ -1216,7 +1141,10 @@ export async function runCli(): Promise<void> {
|
||||
notice: import("./kanban-migration/notice").CliMigrationNotice,
|
||||
) => void)
|
||||
| undefined;
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
if (
|
||||
!startupTargetTakesPrecedenceOverMigrationNotice(startupTarget) &&
|
||||
isFullTTY
|
||||
) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
@@ -1232,7 +1160,7 @@ export async function runCli(): Promise<void> {
|
||||
initialPrompt: args.prompt,
|
||||
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
|
||||
clineProviderSettings: initialClineProviderSettings,
|
||||
initialView,
|
||||
startupTarget,
|
||||
initialNotice,
|
||||
onInitialNoticeShown: markInitialNoticeShown,
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1012,9 +1012,9 @@ Review with the bundled skill.`,
|
||||
const linear = data.mcp.find((item) => item.name === "linear");
|
||||
const docs = data.mcp.find((item) => item.name === "docs");
|
||||
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error");
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
|
||||
expect(linear?.loadError).toBe("OAuth authorization failed");
|
||||
expect(docs?.description).toBe("sse, oauth authorized");
|
||||
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
|
||||
expect(docs?.loadError).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
assertHistorySessionIsDeletable,
|
||||
resolveReasoningForModelChange,
|
||||
resumeInteractiveSession,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("assertHistorySessionIsDeletable", () => {
|
||||
it("rejects deleting the active interactive session", () => {
|
||||
expect(() => assertHistorySessionIsDeletable("sess_1", "sess_1")).toThrow(
|
||||
"Cannot delete the active session",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows deleting another or pre-startup session", () => {
|
||||
expect(() =>
|
||||
assertHistorySessionIsDeletable("sess_1", "sess_2"),
|
||||
).not.toThrow();
|
||||
expect(() => assertHistorySessionIsDeletable("sess_1", "")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
@@ -108,3 +125,74 @@ describe("applyInteractiveModelChange", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeInteractiveSession", () => {
|
||||
const originalAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAgentResume === undefined) {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
} else {
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = originalAgentResume;
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the selected session directly without ensuring an empty session first", async () => {
|
||||
const messages = [
|
||||
{ id: "message-1", role: "user" as const, content: "hello" },
|
||||
];
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const resumeSession = vi.fn(async () => {
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
return messages;
|
||||
});
|
||||
const getAccumulatedUsage = vi.fn(async () => ({
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
totalCost: 0.42,
|
||||
}));
|
||||
const sessionRuntime = {
|
||||
ensureReady,
|
||||
resumeSession,
|
||||
getAccumulatedUsage,
|
||||
};
|
||||
|
||||
const result = await resumeInteractiveSession(
|
||||
sessionRuntime,
|
||||
"session-selected",
|
||||
);
|
||||
|
||||
expect(ensureReady).not.toHaveBeenCalled();
|
||||
expect(resumeSession).toHaveBeenCalledOnce();
|
||||
expect(resumeSession).toHaveBeenCalledWith("session-selected");
|
||||
expect(getAccumulatedUsage).toHaveBeenCalledWith({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
messages,
|
||||
totalCost: 0.42,
|
||||
});
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
});
|
||||
|
||||
it("restores the hook state when the selected session cannot resume", async () => {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
const resumeSession = vi.fn(async () => {
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
throw new Error("resume failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeInteractiveSession(
|
||||
{
|
||||
resumeSession,
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
},
|
||||
"session-missing",
|
||||
),
|
||||
).rejects.toThrow("resume failed");
|
||||
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@ import {
|
||||
getCurrentContextSize,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
setCompactionModeGlobally,
|
||||
setPlanActModeGlobally,
|
||||
setToolAutoApproveGlobally,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import { deleteSession } from "../session/session";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
loadIndividualSubscriptionPlans,
|
||||
@@ -23,7 +28,7 @@ import {
|
||||
resolveClineWelcomeLine,
|
||||
} from "../tui/interactive-welcome";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import type { QueuedPromptItem, TuiStartupTarget } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
@@ -70,6 +75,17 @@ type ModelChangeReasoningConfig = {
|
||||
reasoningEffort?: Config["reasoningEffort"];
|
||||
};
|
||||
|
||||
export function assertHistorySessionIsDeletable(
|
||||
sessionId: string,
|
||||
activeSessionId: string,
|
||||
): void {
|
||||
if (activeSessionId && sessionId === activeSessionId) {
|
||||
throw new Error(
|
||||
"Cannot delete the active session. Start or resume another session first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReasoningForModelChange(
|
||||
config: ModelChangeReasoningConfig,
|
||||
existing: Pick<ProviderSettings, "reasoning">,
|
||||
@@ -127,6 +143,37 @@ export async function applyInteractiveModelChange(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function resumeInteractiveSession(
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
"resumeSession" | "getAccumulatedUsage"
|
||||
>,
|
||||
sessionId: string,
|
||||
) {
|
||||
const previousAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = "1";
|
||||
let messages: Awaited<ReturnType<typeof sessionRuntime.resumeSession>>;
|
||||
try {
|
||||
messages = await sessionRuntime.resumeSession(sessionId);
|
||||
} catch (error) {
|
||||
if (previousAgentResume === undefined) {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
} else {
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = previousAgentResume;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
totalCost: usage.totalCost,
|
||||
currentContextSize: getCurrentContextSize(messages),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -134,7 +181,7 @@ export async function runInteractive(
|
||||
options?: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
initialView?: "chat" | "config";
|
||||
startupTarget?: TuiStartupTarget;
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
@@ -467,7 +514,7 @@ export async function runInteractive(
|
||||
|
||||
tuiApp = await renderOpenTui({
|
||||
config,
|
||||
initialView: options?.initialView,
|
||||
startupTarget: options?.startupTarget,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
initialNotice: options?.initialNotice,
|
||||
onInitialNoticeShown: options?.onInitialNoticeShown,
|
||||
@@ -712,15 +759,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";
|
||||
@@ -756,18 +808,23 @@ export async function runInteractive(
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onResumeSession: async (sessionId: string) => {
|
||||
await sessionRuntime.ensureReady();
|
||||
const messages = await sessionRuntime.resumeSession(sessionId);
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
totalCost: usage.totalCost,
|
||||
currentContextSize: getCurrentContextSize(messages),
|
||||
};
|
||||
// resumeSession initializes the manager and starts the selected session
|
||||
// directly. Ensuring a session first would mint an empty history entry
|
||||
// when the TUI was launched through `cline history`.
|
||||
onResumeSession: async (sessionId: string) =>
|
||||
await resumeInteractiveSession(sessionRuntime, sessionId),
|
||||
onExportHistorySession: async (sessionId, format) =>
|
||||
await exportHistorySession({
|
||||
sessionId,
|
||||
format,
|
||||
outputDirectory: config.cwd,
|
||||
}),
|
||||
onDeleteHistorySession: async (sessionId) => {
|
||||
assertHistorySessionIsDeletable(
|
||||
sessionId,
|
||||
sessionRuntime.getActiveSessionId(),
|
||||
);
|
||||
return (await deleteSession(sessionId)).deleted;
|
||||
},
|
||||
onCompact: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
@@ -796,7 +853,7 @@ export async function runInteractive(
|
||||
},
|
||||
});
|
||||
|
||||
if (!loadDeferredInitialMessages) {
|
||||
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
|
||||
setTimeout(() => {
|
||||
void sessionRuntime.ensureReady().catch((error) => {
|
||||
if (sessionRuntime.isShutdownRequested() || startupErrorReported) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { generateConversationHTML } from "./export";
|
||||
import { readSessionMessagesArtifact } from "./session";
|
||||
|
||||
export type HistoryExportFormat = "html" | "json";
|
||||
|
||||
export async function exportHistorySession(input: {
|
||||
sessionId: string;
|
||||
format: HistoryExportFormat;
|
||||
outputPath?: string;
|
||||
outputDirectory?: string;
|
||||
}): Promise<string> {
|
||||
const { sessionId, format, outputPath, outputDirectory } = input;
|
||||
const data = await readSessionMessagesArtifact(sessionId);
|
||||
if (!data) {
|
||||
throw new Error(`Session ${sessionId} not found or has no messages.json`);
|
||||
}
|
||||
|
||||
const targetPath = outputPath?.trim()
|
||||
? resolve(outputPath)
|
||||
: resolve(
|
||||
outputDirectory?.trim() || process.cwd(),
|
||||
`${sessionId}.${format}`,
|
||||
);
|
||||
const contents =
|
||||
format === "html"
|
||||
? generateConversationHTML(data, sessionId)
|
||||
: `${JSON.stringify(data, null, 2)}\n`;
|
||||
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, contents, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { SessionHistoryRecord } from "@cline/core";
|
||||
import { createCliRenderer } from "@opentui/core";
|
||||
import { createRoot } from "@opentui/react";
|
||||
import React from "react";
|
||||
import { deleteSession } from "../session/session";
|
||||
import { HistoryStandaloneContent } from "./views/history-view";
|
||||
|
||||
export async function renderHistoryStandalone(input: {
|
||||
rows: SessionHistoryRecord[];
|
||||
onExport: (sessionId: string) => Promise<string | undefined>;
|
||||
refreshRows?: () => Promise<SessionHistoryRecord[]>;
|
||||
}): Promise<number | string> {
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true,
|
||||
autoFocus: false,
|
||||
enableMouseMovement: true,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let result: number | string = 0;
|
||||
let resolved = false;
|
||||
let destroyStarted = false;
|
||||
let unmounted = false;
|
||||
const root = createRoot(renderer);
|
||||
|
||||
const unmountRoot = () => {
|
||||
if (unmounted) {
|
||||
return;
|
||||
}
|
||||
unmounted = true;
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
// Resolve only once teardown has finished, so callers never run while
|
||||
// the renderer is still restoring the terminal.
|
||||
renderer.on("destroy", () => {
|
||||
unmountRoot();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (destroyStarted) {
|
||||
return;
|
||||
}
|
||||
destroyStarted = true;
|
||||
result = value;
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(HistoryStandaloneContent, {
|
||||
rows: input.rows,
|
||||
onResolve: (sessionId: string) => settle(sessionId),
|
||||
onExport: input.onExport,
|
||||
refreshRows: input.refreshRows,
|
||||
onDelete: async (sessionId: string) => {
|
||||
const result = await deleteSession(sessionId);
|
||||
return result.deleted;
|
||||
},
|
||||
onDismiss: () => settle(0),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ export function useLocalCommandActions(input: {
|
||||
setAppView: (view: AppView) => void;
|
||||
onClearConversation: () => Promise<void>;
|
||||
onResumeSession: TuiProps["onResumeSession"];
|
||||
onExportHistorySession: TuiProps["onExportHistorySession"];
|
||||
onDeleteHistorySession: TuiProps["onDeleteHistorySession"];
|
||||
onCompact: TuiProps["onCompact"];
|
||||
onFork: TuiProps["onFork"];
|
||||
onUndo: () => Promise<void>;
|
||||
@@ -47,6 +49,8 @@ export function useLocalCommandActions(input: {
|
||||
setAppView,
|
||||
onClearConversation,
|
||||
onResumeSession,
|
||||
onExportHistorySession,
|
||||
onDeleteHistorySession,
|
||||
onCompact,
|
||||
onFork,
|
||||
onUndo,
|
||||
@@ -58,7 +62,11 @@ export function useLocalCommandActions(input: {
|
||||
size: "large",
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
<HistoryDialogContent {...ctx} />
|
||||
<HistoryDialogContent
|
||||
{...ctx}
|
||||
onExport={onExportHistorySession}
|
||||
onDelete={onDeleteHistorySession}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (sessionId) {
|
||||
@@ -98,6 +106,8 @@ export function useLocalCommandActions(input: {
|
||||
refocusTextarea();
|
||||
}, [
|
||||
dialog,
|
||||
onDeleteHistorySession,
|
||||
onExportHistorySession,
|
||||
onResumeSession,
|
||||
refocusTextarea,
|
||||
session,
|
||||
@@ -244,5 +254,5 @@ export function useLocalCommandActions(input: {
|
||||
],
|
||||
);
|
||||
|
||||
return { handleSlashCommand };
|
||||
return { handleSlashCommand, openHistory };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMcpDescription } from "./interactive-config";
|
||||
|
||||
describe("getMcpDescription", () => {
|
||||
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
});
|
||||
|
||||
it("shows one configured timeout when it also applies to initialize", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: 120,
|
||||
}),
|
||||
).toBe("stdio, local, timeout 120s");
|
||||
});
|
||||
|
||||
it("shows the request default for URL transports", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "remote",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.example.test",
|
||||
},
|
||||
}),
|
||||
).toBe("streamableHttp, no auth, timeout 60s");
|
||||
});
|
||||
|
||||
it("reports malformed programmatic timeouts as unconfigured", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: Number.NaN,
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
type UserInstructionConfigService,
|
||||
type WorkflowConfig,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isMcpTimeoutConfigured,
|
||||
resolveMcpTimeoutSeconds,
|
||||
} from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import {
|
||||
@@ -174,8 +178,14 @@ function getMcpAuthLabel(registration: McpServerRegistration): string {
|
||||
return "no auth";
|
||||
}
|
||||
|
||||
function getMcpDescription(registration: McpServerRegistration): string {
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}`;
|
||||
export function getMcpDescription(registration: McpServerRegistration): string {
|
||||
const timeoutSeconds = resolveMcpTimeoutSeconds(registration.timeoutSeconds);
|
||||
const timeoutDescription =
|
||||
registration.transport.type === "stdio" &&
|
||||
!isMcpTimeoutConfigured(registration.timeoutSeconds)
|
||||
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
|
||||
: `timeout ${timeoutSeconds}s`;
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
|
||||
}
|
||||
|
||||
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
|
||||
+25
-10
@@ -54,7 +54,7 @@ import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
|
||||
import { useSlashCommands } from "./hooks/use-slash-commands";
|
||||
import { TerminalColorsContext } from "./hooks/use-terminal-background";
|
||||
import { useTerminalTitle } from "./hooks/use-terminal-title";
|
||||
import type { AppView, TuiProps } from "./types";
|
||||
import type { AppView, TuiProps, TuiStartupTarget } from "./types";
|
||||
import { hydrateSessionMessages } from "./utils/hydrate-messages";
|
||||
import { isProviderConfigured } from "./utils/provider-configured";
|
||||
import { createSelectionCopyHandler } from "./utils/selection-copy";
|
||||
@@ -64,6 +64,13 @@ import { ChatView } from "./views/chat-view";
|
||||
import { HomeView } from "./views/home-view";
|
||||
import { type OnboardingResult, OnboardingView } from "./views/onboarding";
|
||||
|
||||
function isChatBackedStartupTarget(
|
||||
target: TuiStartupTarget | undefined,
|
||||
): boolean {
|
||||
// History is a dialog layered over chat, so dismissing it should reveal chat.
|
||||
return target === "chat" || target === "history";
|
||||
}
|
||||
|
||||
function App(props: TuiProps) {
|
||||
const session = useSession();
|
||||
const renderer = useRenderer();
|
||||
@@ -84,7 +91,8 @@ function App(props: TuiProps) {
|
||||
const [appView, setAppView] = useState<AppView>(() => {
|
||||
if (process.env.CLINE_FORCE_ONBOARDING === "1") return "onboarding";
|
||||
if (!isProviderConfigured(props.config)) return "onboarding";
|
||||
return props.initialView === "chat" || session.entries.length > 0
|
||||
return isChatBackedStartupTarget(props.startupTarget) ||
|
||||
session.entries.length > 0
|
||||
? "chat"
|
||||
: "home";
|
||||
});
|
||||
@@ -627,14 +635,7 @@ function App(props: TuiProps) {
|
||||
setSessionLastTotalTokens,
|
||||
]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
||||
useEffect(() => {
|
||||
if (props.initialView === "config") {
|
||||
openConfig();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { handleSlashCommand } = useLocalCommandActions({
|
||||
const { handleSlashCommand, openHistory } = useLocalCommandActions({
|
||||
slashCommandRegistry,
|
||||
canForkSession,
|
||||
openAccount,
|
||||
@@ -646,12 +647,24 @@ function App(props: TuiProps) {
|
||||
setAppView,
|
||||
onClearConversation: clearConversation,
|
||||
onResumeSession: props.onResumeSession,
|
||||
onExportHistorySession: props.onExportHistorySession,
|
||||
onDeleteHistorySession: props.onDeleteHistorySession,
|
||||
onCompact: props.onCompact,
|
||||
onFork: props.onFork,
|
||||
onUndo: openCheckpointRestore,
|
||||
onExit: exitCline,
|
||||
});
|
||||
|
||||
const startupActionsRef = useRef({ openConfig, openHistory });
|
||||
startupActionsRef.current = { openConfig, openHistory };
|
||||
useEffect(() => {
|
||||
if (props.startupTarget === "config") {
|
||||
void startupActionsRef.current.openConfig();
|
||||
} else if (props.startupTarget === "history") {
|
||||
void startupActionsRef.current.openHistory();
|
||||
}
|
||||
}, [props.startupTarget]);
|
||||
|
||||
const runCommandPaletteResult = useCallback(
|
||||
async (result: CommandPaletteResult) => {
|
||||
if (result.action === "change-provider") {
|
||||
@@ -724,6 +737,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 +747,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,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../runtime/session-events";
|
||||
import type { HistoryExportFormat } from "../session/history-export";
|
||||
import type { RepoStatus } from "../utils/repo-status";
|
||||
import type { CliCompactionMode, Config } from "../utils/types";
|
||||
import type { ClineAccountSnapshot } from "./cline-account";
|
||||
@@ -123,6 +124,7 @@ export interface PendingPromptMutationResult {
|
||||
}
|
||||
|
||||
export type AppView = "onboarding" | "home" | "chat";
|
||||
export type TuiStartupTarget = "chat" | "config" | "history";
|
||||
|
||||
export type RuntimeToolInteraction =
|
||||
| {
|
||||
@@ -139,7 +141,7 @@ export type RuntimeToolInteraction =
|
||||
|
||||
export interface TuiProps {
|
||||
config: Config;
|
||||
initialView?: "chat" | "config";
|
||||
startupTarget?: TuiStartupTarget;
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
@@ -193,6 +195,11 @@ export interface TuiProps {
|
||||
onSessionRestart: () => Promise<void>;
|
||||
onAccountChange: () => Promise<void>;
|
||||
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
|
||||
onExportHistorySession: (
|
||||
sessionId: string,
|
||||
format: HistoryExportFormat,
|
||||
) => Promise<string>;
|
||||
onDeleteHistorySession: (sessionId: string) => Promise<boolean>;
|
||||
onCompact: () => Promise<InteractiveCompactionResult>;
|
||||
onFork: () => Promise<
|
||||
| {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
resolveHistoryExportPickerAction,
|
||||
} from "./history-export-picker";
|
||||
|
||||
describe("history export picker", () => {
|
||||
it("offers HTML and JSON exports", () => {
|
||||
expect(HISTORY_EXPORT_OPTIONS.map((option) => option.format)).toEqual([
|
||||
"html",
|
||||
"json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects JSON with the down arrow and Enter", () => {
|
||||
const initialState = { sessionId: "sess_1", selectedIndex: 0 };
|
||||
const navigation = resolveHistoryExportPickerAction(initialState, {
|
||||
name: "down",
|
||||
});
|
||||
expect(navigation).toEqual({
|
||||
kind: "update",
|
||||
state: { sessionId: "sess_1", selectedIndex: 1 },
|
||||
});
|
||||
if (navigation.kind !== "update") {
|
||||
throw new Error("Expected export picker navigation");
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveHistoryExportPickerAction(navigation.state, { name: "enter" }),
|
||||
).toEqual({
|
||||
kind: "export",
|
||||
sessionId: "sess_1",
|
||||
format: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps selection and supports cancelling", () => {
|
||||
const initialState = { sessionId: "sess_1", selectedIndex: 0 };
|
||||
|
||||
expect(
|
||||
resolveHistoryExportPickerAction(initialState, { name: "up" }),
|
||||
).toEqual({
|
||||
kind: "update",
|
||||
state: { sessionId: "sess_1", selectedIndex: 1 },
|
||||
});
|
||||
expect(
|
||||
resolveHistoryExportPickerAction(initialState, { name: "escape" }),
|
||||
).toEqual({ kind: "cancel" });
|
||||
});
|
||||
|
||||
it("includes available history actions in the footer", () => {
|
||||
expect(
|
||||
buildHistoryFooterText({ canDelete: true, canExport: true }),
|
||||
).toContain("\u2190 delete, \u2192 export");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { HistoryExportFormat } from "../../session/history-export";
|
||||
|
||||
export const HISTORY_EXPORT_OPTIONS = [
|
||||
{
|
||||
format: "html",
|
||||
label: "HTML",
|
||||
description: "Standalone, readable conversation",
|
||||
},
|
||||
{
|
||||
format: "json",
|
||||
label: "JSON",
|
||||
description: "Structured session messages and metadata",
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
format: HistoryExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
export type HistoryExportPickerState = {
|
||||
sessionId: string;
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export type HistoryPickerKey = {
|
||||
name?: string;
|
||||
ctrl?: boolean;
|
||||
};
|
||||
|
||||
export type HistoryExportPickerAction =
|
||||
| { kind: "cancel" }
|
||||
| {
|
||||
kind: "export";
|
||||
sessionId: string;
|
||||
format: HistoryExportFormat;
|
||||
}
|
||||
| { kind: "update"; state: HistoryExportPickerState }
|
||||
| { kind: "ignore" };
|
||||
|
||||
export function resolveHistoryExportPickerAction(
|
||||
state: HistoryExportPickerState,
|
||||
key: HistoryPickerKey,
|
||||
): HistoryExportPickerAction {
|
||||
if (key.name === "escape") {
|
||||
return { kind: "cancel" };
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const option = HISTORY_EXPORT_OPTIONS[state.selectedIndex];
|
||||
return option
|
||||
? {
|
||||
kind: "export",
|
||||
sessionId: state.sessionId,
|
||||
format: option.format,
|
||||
}
|
||||
: { kind: "ignore" };
|
||||
}
|
||||
if (
|
||||
key.name === "up" ||
|
||||
key.name === "left" ||
|
||||
(key.ctrl && key.name === "p")
|
||||
) {
|
||||
return {
|
||||
kind: "update",
|
||||
state: {
|
||||
...state,
|
||||
selectedIndex:
|
||||
state.selectedIndex <= 0
|
||||
? HISTORY_EXPORT_OPTIONS.length - 1
|
||||
: state.selectedIndex - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
key.name === "down" ||
|
||||
key.name === "right" ||
|
||||
(key.ctrl && key.name === "n")
|
||||
) {
|
||||
return {
|
||||
kind: "update",
|
||||
state: {
|
||||
...state,
|
||||
selectedIndex:
|
||||
state.selectedIndex >= HISTORY_EXPORT_OPTIONS.length - 1
|
||||
? 0
|
||||
: state.selectedIndex + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
|
||||
export function buildHistoryFooterText(input: {
|
||||
canDelete: boolean;
|
||||
canExport: boolean;
|
||||
}): string {
|
||||
return [
|
||||
"\u2191/\u2193 navigate",
|
||||
"Enter to resume",
|
||||
input.canDelete ? "\u2190 delete" : undefined,
|
||||
input.canExport ? "\u2192 export" : undefined,
|
||||
"Esc to close",
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join(", ");
|
||||
}
|
||||
@@ -6,15 +6,22 @@ import {
|
||||
formatHumanReadableDate,
|
||||
truncateStr,
|
||||
} from "@cline/shared";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HistoryExportFormat } from "../../session/history-export";
|
||||
import { listSessions } from "../../session/session";
|
||||
import { mergeHistoryStatusRows } from "../../utils/history-format";
|
||||
import { formatUsd } from "../../utils/output";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import { palette } from "../palette";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
type HistoryExportPickerState,
|
||||
resolveHistoryExportPickerAction,
|
||||
} from "./history-export-picker";
|
||||
|
||||
function hasForkMetadata(row: SessionHistoryRecord): boolean {
|
||||
const fork = row.metadata?.fork;
|
||||
@@ -51,7 +58,10 @@ const DEFAULT_REFRESH_INTERVAL_MS = 2000;
|
||||
type HistoryListActions = {
|
||||
onResolve: (sessionId: string) => void;
|
||||
onDismiss: () => void;
|
||||
onExport?: (sessionId: string) => Promise<string | undefined>;
|
||||
onExport?: (
|
||||
sessionId: string,
|
||||
format: HistoryExportFormat,
|
||||
) => Promise<string>;
|
||||
onDelete?: (sessionId: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -81,7 +91,7 @@ function HistoryListContent({
|
||||
onExport,
|
||||
onDelete,
|
||||
emptyMessage = "No sessions found",
|
||||
footerText = "\u2191/\u2193 navigate, Enter to resume, Esc to close",
|
||||
footerText,
|
||||
title = "Session History",
|
||||
loadRows = false,
|
||||
refreshRows,
|
||||
@@ -96,9 +106,16 @@ function HistoryListContent({
|
||||
const [loading, setLoading] = useState(loadRows);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [exportPicker, setExportPickerState] =
|
||||
useState<HistoryExportPickerState | null>(null);
|
||||
const exportPickerRef = useRef(exportPicker);
|
||||
const handlerRef = useRef<(key: HistoryKeyEvent | undefined) => void>(
|
||||
() => {},
|
||||
);
|
||||
const setExportPicker = (next: HistoryExportPickerState | null) => {
|
||||
exportPickerRef.current = next;
|
||||
setExportPickerState(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialRows = () => listSessions(50, { hydrate: true });
|
||||
@@ -188,6 +205,26 @@ function HistoryListContent({
|
||||
return { items: rows.slice(start, end), startIndex: start };
|
||||
}, [rows, safeSelected]);
|
||||
|
||||
const exportSelectedSession = (
|
||||
sessionId: string,
|
||||
format: HistoryExportFormat,
|
||||
) => {
|
||||
if (!onExport) {
|
||||
return;
|
||||
}
|
||||
setExportPicker(null);
|
||||
setStatusMessage(`Exporting ${sessionId} as ${format.toUpperCase()}...`);
|
||||
void onExport(sessionId, format)
|
||||
.then((path) => {
|
||||
setStatusMessage(`Exported ${sessionId} to ${path}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatusMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
handlerRef.current = (key: HistoryKeyEvent | undefined) => {
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -196,6 +233,18 @@ function HistoryListContent({
|
||||
onDismiss();
|
||||
return;
|
||||
}
|
||||
const currentExportPicker = exportPickerRef.current;
|
||||
if (currentExportPicker) {
|
||||
const action = resolveHistoryExportPickerAction(currentExportPicker, key);
|
||||
if (action.kind === "cancel") {
|
||||
setExportPicker(null);
|
||||
} else if (action.kind === "update") {
|
||||
setExportPicker(action.state);
|
||||
} else if (action.kind === "export") {
|
||||
exportSelectedSession(action.sessionId, action.format);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (confirmDelete) {
|
||||
if (key.name === "y" || (key.shift && key.name === "y")) {
|
||||
const sessionId = confirmDelete;
|
||||
@@ -265,20 +314,11 @@ function HistoryListContent({
|
||||
if (key.name === "right") {
|
||||
const row = rowsRef.current[selectedRef.current];
|
||||
if (row?.sessionId && onExport) {
|
||||
setStatusMessage(`Exporting ${row.sessionId}...`);
|
||||
void onExport(row.sessionId)
|
||||
.then((path) => {
|
||||
setStatusMessage(
|
||||
path
|
||||
? `Exported ${row.sessionId} to ${path}`
|
||||
: `Exported ${row.sessionId}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatusMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
setStatusMessage(null);
|
||||
setExportPicker({
|
||||
sessionId: row.sessionId,
|
||||
selectedIndex: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -295,6 +335,53 @@ function HistoryListContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (exportPicker) {
|
||||
const selectedRow = rows.find(
|
||||
(row) => row.sessionId === exportPicker.sessionId,
|
||||
);
|
||||
const exportTitle = selectedRow
|
||||
? formatTitle(selectedRow, Math.max(20, width - 12))
|
||||
: exportPicker.sessionId;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text>Export Session</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
{exportTitle}
|
||||
</text>
|
||||
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{HISTORY_EXPORT_OPTIONS.map((option, index) => {
|
||||
const isSelected = index === exportPicker.selectedIndex;
|
||||
return (
|
||||
<box
|
||||
key={option.format}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
>
|
||||
<text fg={isSelected ? palette.textOnSelection : undefined}>
|
||||
{isSelected ? "\u276f " : " "}
|
||||
{option.label}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : "gray"}
|
||||
paddingLeft={2}
|
||||
>
|
||||
{option.description}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>Arrow keys choose, Enter to export, Esc to go back</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
@@ -309,6 +396,12 @@ function HistoryListContent({
|
||||
|
||||
const aboveCount = window.startIndex;
|
||||
const belowCount = rows.length - window.startIndex - window.items.length;
|
||||
const resolvedFooterText =
|
||||
footerText ??
|
||||
buildHistoryFooterText({
|
||||
canDelete: onDelete !== undefined,
|
||||
canExport: onExport !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
@@ -399,14 +492,17 @@ function HistoryListContent({
|
||||
)}
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>{footerText}</em>
|
||||
<em>{resolvedFooterText}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryDialogContent(props: ChoiceContext<string>) {
|
||||
const { resolve, dismiss, dialogId } = props;
|
||||
export function HistoryDialogContent(
|
||||
props: ChoiceContext<string> &
|
||||
Pick<HistoryListActions, "onExport" | "onDelete">,
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, onExport, onDelete } = props;
|
||||
const [keyHandler, setKeyHandler] = useState<
|
||||
((key: HistoryKeyEvent | undefined) => void) | undefined
|
||||
>();
|
||||
@@ -424,46 +520,8 @@ export function HistoryDialogContent(props: ChoiceContext<string>) {
|
||||
loadRows
|
||||
onResolve={resolve}
|
||||
onDismiss={dismiss}
|
||||
registerKeyHandler={registerKeyHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryStandaloneContent(
|
||||
props: HistoryListActions & {
|
||||
rows: SessionHistoryRecord[];
|
||||
title?: string;
|
||||
footerText?: string;
|
||||
refreshRows?: () => Promise<SessionHistoryRecord[]>;
|
||||
refreshIntervalMs?: number;
|
||||
},
|
||||
) {
|
||||
const [keyHandler, setKeyHandler] = useState<
|
||||
((key: HistoryKeyEvent | undefined) => void) | undefined
|
||||
>();
|
||||
const registerKeyHandler = useCallback(
|
||||
(handler: (key: HistoryKeyEvent | undefined) => void) => {
|
||||
setKeyHandler(() => handler);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useKeyboard((key) => keyHandler?.(key));
|
||||
|
||||
return (
|
||||
<HistoryListContent
|
||||
initialRows={props.rows}
|
||||
onResolve={props.onResolve}
|
||||
onDismiss={props.onDismiss}
|
||||
onExport={props.onExport}
|
||||
onDelete={props.onDelete}
|
||||
refreshRows={props.refreshRows}
|
||||
refreshIntervalMs={props.refreshIntervalMs}
|
||||
title={props.title ?? "History"}
|
||||
footerText={
|
||||
props.footerText ??
|
||||
"\u2191/\u2193 navigate, Enter to resume, \u2190 delete, \u2192 export, Esc to close"
|
||||
}
|
||||
onExport={onExport}
|
||||
onDelete={onDelete}
|
||||
registerKeyHandler={registerKeyHandler}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user