Compare commits

..

6 Commits

Author SHA1 Message Date
Dominic Cooney 18e2bdf194 fix(vscode): align plugin command loading with CLI 2026-07-14 09:08:39 +09:00
Dominic Cooney b83c9ec240 chore(vscode): update bun lock for plugin dependencies after rebase 2026-07-14 09:08:39 +09:00
Dominic Cooney 711a8c6ec0 fix(vscode): fix compile errors in plugin slash command coordinator
CI caught two real TypeScript errors that had gone unnoticed locally
(the dev environment's @cline/core build artifacts were stale, masking
them):

1. sdk-plugin-commands.ts: createContributionRegistry() was called
   without type arguments, defaulting TMessage to `unknown`. This
   doesn't match AgentExtension's setup() signature, which expects
   Message[] (per @cline/shared), causing a type mismatch. Mirror the
   CLI's equivalent call in plugin-chat-commands.ts, which explicitly
   parameterizes <Extension, AgentTool, Message[]>.

2. SdkController.ts: emitSessionEvents(messages, event) requires two
   arguments, but the plugin-command reply path only passed one. Add
   the missing status event, matching the pattern used elsewhere in
   this file (e.g. the provider-failure error path) and in
   sdk-followup-coordinator.ts.
2026-07-14 09:08:39 +09:00
Dominic Cooney d68badae38 fix(vscode): thread plugin slash commands into autocomplete menu and navigation
ChatTextArea/SlashCommandMenu destructure pluginSlashCommands from
ExtensionState and pass it to validateSlashCommand() for input
highlighting, but never pass it to getMatchingSlashCommands() for
arrow-key navigation, Enter/Tab selection, or the rendered
SlashCommandMenu itself. Plugin-registered commands (e.g. /goal) are
discovered correctly on the backend and shipped to the webview, but
never appear in the actual autocomplete dropdown.

Thread pluginSlashCommands through all three remaining call sites and
add it to the relevant useCallback/useLayoutEffect dependency arrays
so the menu updates once plugin discovery resolves asynchronously
after mount.
2026-07-14 09:08:39 +09:00
Dominic Cooney d67c403392 feat(vscode): surface plugin commands in slash command autocomplete (CLINE-2584) 2026-07-14 09:08:39 +09:00
Dominic Cooney 02f405f6e2 fix(vscode): bundle plugin sandbox bootstrap and surface plugin commands (CLINE-2584) 2026-07-14 09:08:39 +09:00
1038 changed files with 46127 additions and 182258 deletions
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-extension
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
-5
View File
@@ -1,5 +0,0 @@
---
"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)
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
-5
View File
@@ -1,5 +0,0 @@
---
"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
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
-5
View File
@@ -1,5 +0,0 @@
---
"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".
-5
View File
@@ -1,5 +0,0 @@
---
"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
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
-5
View File
@@ -1,5 +0,0 @@
---
"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
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-extension
-127
View File
@@ -1,127 +0,0 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
## Release contract
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
2. Collect release commits.
```sh
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Repo secrets (one-time setup)
The workflow needs these repository secrets. The Apple ones come from the same
Apple Developer account used for manual signing (see the app README's "macOS
signing & notarization" section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
OTEL settings) are shared with the CLI publish workflow and already configured.
-178
View File
@@ -1,178 +0,0 @@
---
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) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs.
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
```bash
git tag v<VERSION> <main-sha-built> # ask before pushing
git push origin v<VERSION>
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
```
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
### Known caveats of this path
- **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.
-158
View File
@@ -1,158 +0,0 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -1,4 +0,0 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+2 -3
View File
@@ -8,9 +8,8 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
-1
View File
@@ -16,7 +16,6 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
-310
View File
@@ -1,310 +0,0 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
needs: validate
runs-on: macos-latest
timeout-minutes: 90
strategy:
fail-fast: true
matrix:
include:
- target: aarch64-apple-darwin
arch: aarch64
- target: x86_64-apple-darwin
arch: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
env:
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: |
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
-410
View File
@@ -1,410 +0,0 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
# 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
# contents: write is required by the post-publish bookkeeping (tag +
# GitHub Release), mirroring the standalone publish workflows.
permissions:
contents: write
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 == 'main' && github.sha || github.event.inputs.next-ref }}
path: next-src
lfs: true
# Fail fast (before the ~20-min build) if a real publish is missing
# its changelog entry — same contract the standalone publish
# workflows enforce. Build-only rehearsals are exempt.
- name: Verify changelog entry
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: next-src
run: |
EXPECTED_HEADING="## [${{ github.event.inputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing (found '$FIRST_HEADING')."
exit 1
fi
echo "Found changelog entry for ${{ github.event.inputs.version }}"
# 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: ${{ needs.test-legacy.outputs.tested-sha }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
# Mirrors the standalone publish workflows. Every step here is
# continue-on-error: the Marketplace publish above already happened,
# and a red run after a successful publish is exactly the confusion
# the nightly workflow taught us to avoid (tag pushes fail whenever
# the built commit touches .github/workflows/** — no grantable
# permission fixes that; push the tag manually in that case, see
# the publish-extension skill).
- name: Extract changelog entry
id: changelog
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
working-directory: next-src
run: |
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
{
echo "content<<CHANGELOG_EOF"
echo "$CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve previous release tag
id: prev_tag
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
working-directory: next-src
run: |
# ls-remote needs no local tag objects; take the highest v* tag
# below the one being released.
PREV=$(git ls-remote --tags origin 'v*' \
| awk -F/ '{print $NF}' | grep -v '\^{}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vx "v${{ github.event.inputs.version }}" \
| sort -V | tail -1)
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
- name: Create and push release tag
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
working-directory: next-src
run: |
TAG="v${{ github.event.inputs.version }}"
git tag "$TAG" HEAD
git push origin "refs/tags/$TAG"
echo "Pushed $TAG at $(git rev-parse HEAD)"
- name: Create GitHub Release
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ github.event.inputs.version }}
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline v${{ github.event.inputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline v${{ github.event.inputs.version }}*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
+47 -211
View File
@@ -1,40 +1,17 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -43,7 +20,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline'
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
permissions:
contents: read
pull-requests: read
@@ -53,79 +30,60 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout next (SDK) source
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
- name: Show build source
working-directory: ${{ github.workspace }}
run: |
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: next-src
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -135,24 +93,20 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Build next bundle
working-directory: next-src/apps/vscode
- name: Publish Nightly Extension
env:
CLINE_ENVIRONMENT: production
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -160,129 +114,12 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
working-directory: ${{ github.workspace }}
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -290,11 +127,10 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.101.0
VSCODE_TEST_VERSION: 1.103.0
strategy:
fail-fast: false
matrix:
@@ -1,60 +0,0 @@
# 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}`)
@@ -1,65 +0,0 @@
# 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}`)
-58
View File
@@ -260,41 +260,6 @@ jobs:
git push origin "refs/tags/${TAG}"
done
- name: Get Previous SDK Tag
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: prev_tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# The checkout is shallow and tagless, so fetch the release tags explicitly.
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: changelog
run: |
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
DELIMITER=$(openssl rand -hex 8)
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: softprops/action-gh-release@v1
with:
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
name: "SDK v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
@@ -315,26 +280,3 @@ jobs:
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
- name: Post release to Slack
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline SDK v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline SDK v${{ steps.version.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
-150
View File
@@ -1,150 +0,0 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --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}'"
-2
View File
@@ -42,8 +42,6 @@ event names. It exports:
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
**All events should be named using snake_case and so should their properties**
## The Activation Funnel
The canonical funnel that downstream analytics depends on:
+2 -4
View File
@@ -51,8 +51,7 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "staging",
"CLINE_DIR": "${userHome}/.cline_staging"
"CLINE_ENVIRONMENT": "staging"
}
},
{
@@ -76,8 +75,7 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "local",
"CLINE_DIR": "${userHome}/.cline_local"
"CLINE_ENVIRONMENT": "local"
}
},
{
-32
View File
@@ -1,32 +0,0 @@
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.
-128
View File
@@ -1,133 +1,5 @@
# Changelog
## [4.1.2]
### Added
- Show which extension variant is active — "Legacy" or "Next" — next to the version in the settings About page, in both bundles of the combined rollout package.
## [4.1.1]
### Changed
- Remove vestigial MCP server-key machinery from McpHub — native MCP tool calls now route by server name instead of a random in-memory uid, so routing survives restarts and server list changes.
## [4.1.0]
### Changed
- Convert the stable extension to a combined A/B package: one VSIX containing both the current (legacy) extension and the new SDK-based extension, plus a loader that activates exactly one per window via a staged remote rollout. For nearly all users nothing changes — the loader activates the same extension as 4.0.12; a small percentage (starting at 1%) is gradually opted into the SDK-based extension. If the new extension fails to activate, the loader falls back to the current one in the same window. Settings and credentials are shared between the two.
## [4.0.12]
### Added
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
### Fixed
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
## [4.0.11]
### Added
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
- Add Moonshot Kimi K3 support.
- Include the host plugin version in telemetry events.
### Fixed
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
- Enable native tool calling for Kimi K3 models, fixing empty responses.
## [4.0.10]
### Added
- Add telemetry to track when Cline reaches the consecutive mistake limit.
## [4.0.9]
### Added
- Add GPT-5.6 ChatGPT subscription models.
### Changed
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
### Fixed
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
## [4.0.8]
### Added
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [4.0.0]
### Added
+1 -1
View File
@@ -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'
```
-67
View File
@@ -1,72 +1,5 @@
# 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)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
+2 -17
View File
@@ -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 `agentic`; use `basic` for local truncation or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (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 |
@@ -346,24 +346,9 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
-281
View File
@@ -1,281 +0,0 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
-42
View File
@@ -23,48 +23,6 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.48",
"version": "3.0.40",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -78,19 +78,19 @@
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.4.3",
"@opentui/react": "0.4.3",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.7",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.33.0",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
+1 -57
View File
@@ -28,15 +28,11 @@ import {
ProviderSettingsManager,
SessionSource,
} from "@cline/core";
import { isLikelyAuthError, type Message } from "@cline/shared";
import type { Message } from "@cline/shared";
import { getPersistedProviderApiKey } from "../commands/auth";
import { resolveSystemPrompt } from "../runtime/prompt";
import { subscribeToAgentEvents } from "../runtime/session-events";
import { createCliCore } from "../session/session";
import {
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../utils/cline-pass-errors";
import { getCliBuildInfo } from "../utils/common";
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
import type { Config } from "../utils/types";
@@ -49,7 +45,6 @@ import {
} from "./auth";
import { requestAcpToolApproval } from "./permissions";
import {
describeAgentError,
forwardAgentEvent,
sendConfigOptionUpdate,
sendCurrentModeUpdate,
@@ -74,15 +69,6 @@ interface SessionState {
abortController?: AbortController;
/** Unsubscribe function for the agent event listener. */
unsubscribe?: () => void;
/**
* Most recent unrecoverable agent error for the in-flight turn.
*
* The runtime reports fatal failures (bad credentials, subscription
* restrictions, provider outages) as an `error` event and still resolves
* `send()` normally, so the message has to be stashed here for `prompt()` to
* turn into an error response.
*/
fatalError?: Error;
/** Messages to inject into the next session manager for conversation continuity. */
pendingInitialMessages?: Message[];
}
@@ -207,7 +193,6 @@ export class AcpAgent implements Agent {
const abortController = new AbortController();
session.abortController = abortController;
session.fatalError = undefined;
// If cancel() was already called before prompt() started, bail early.
if (abortController.signal.aborted) {
@@ -257,17 +242,6 @@ export class AcpAgent implements Agent {
updatedAt: new Date().toISOString(),
});
// A cancelled turn always reports `cancelled`: the ACP spec
// requires agents to convert abort failures into the cancelled stop reason
// so clients don't show cancellations as errors.
if (stopReason !== "cancelled") {
const fatalError = session.fatalError;
session.fatalError = undefined;
if (fatalError) {
throw toAcpPromptError(fatalError);
}
}
return { stopReason };
}
@@ -517,13 +491,6 @@ export class AcpAgent implements Agent {
session.unsubscribe = subscribeToAgentEvents(
sessionManager,
(event: AgentEvent) => {
// Remember unrecoverable failures so prompt() can fail the turn.
if (event.type === "error" && !event.recoverable) {
session.fatalError =
event.error instanceof Error
? event.error
: new Error(describeAgentError(event.error));
}
forwardAgentEvent(this.conn, acpSessionId, event);
},
);
@@ -593,29 +560,6 @@ export class AcpAgent implements Agent {
}
}
/**
* Convert a fatal agent error into a JSON-RPC error for the prompt response.
*
* Credential/subscription problems map to `auth_required` (-32000) so clients
* can offer a re-auth affordance rather than just printing text; everything
* else is an internal error.
*
* Classification goes through the shared CLI helpers, which check the error's
* type *and* its name/message. That matters because the runtime re-wraps errors
* as it forwards them across the event boundary, so `instanceof` alone fails on
* the object ACP actually receives.
*/
function toAcpPromptError(error: Error): RequestError {
const message = describeAgentError(error);
const isAuthProblem =
isLikelyAuthError(error) ||
isClinePassSubscriptionError(error) ||
isClineOrgIndividualInferenceSubscriptionErrorMessage(error);
return isAuthProblem
? RequestError.authRequired({ message }, message)
: RequestError.internalError({ message }, message);
}
async function buildProviderConfigOption(
currentProviderId: string,
): Promise<SessionConfigOption> {
-6
View File
@@ -4,7 +4,6 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import { getErrorMessage } from "@cline/shared";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
@@ -82,11 +81,6 @@ function translateContentStart(
}
}
export function describeAgentError(error: unknown): string {
const message = getErrorMessage(error).trim();
return message || "The agent reported an unknown error.";
}
function translateContentEnd(
event: AgentEvent & { type: "content_end" },
): SessionUpdate[] {
-367
View File
@@ -1,367 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+1 -1
View File
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
"claude-sonnet-4-6",
"-k",
"test-key",
"seed history session",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
+2 -3
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,7 +15,6 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -210,7 +209,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
-494
View File
@@ -1,494 +0,0 @@
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();
});
});
+15 -203
View File
@@ -1,29 +1,10 @@
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,
ConnectRunContext,
ConnectStopResult,
} from "../connectors/types";
const HELP_FLAGS = new Set(["-h", "--help"]);
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
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()) {
@@ -37,209 +18,42 @@ export async function stopAllConnectors(
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
return { stoppedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
const { stoppedProcesses, 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} failed=${failedProcesses} sessions=${stoppedSessions}`,
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
);
return failedProcesses === 0 ? 0 : 1;
return 0;
}
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;
}
const stop = options.instanceId
? connector.stopInstance
? () => connector.stopInstance?.(options.instanceId ?? "", io)
: undefined
: connector.stopAll
? () => connector.stopAll?.(io)
: undefined;
if (!stop) {
if (!connector.stopAll) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
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);
}
const result: ConnectStopResult = await connector.stopAll(io);
io.writeln(
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
);
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 };
return 0;
}
export async function runConnectAdapter(
@@ -247,14 +61,12 @@ export async function runConnectAdapter(
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
? 0
: result.exitCode;
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
return connector.run(passthroughArgs, io);
}
export function formatAdapterList(): string {
-41
View File
@@ -9,7 +9,6 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -22,7 +21,6 @@ const {
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
@@ -51,10 +49,8 @@ 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,
})),
@@ -73,7 +69,6 @@ vi.mock("@cline/core", () => ({
readHubDiscovery: mockReadHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
}));
vi.mock("../connectors/common", () => ({
@@ -104,7 +99,6 @@ describe("runDoctorCommand", () => {
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
@@ -180,40 +174,6 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
@@ -288,7 +248,6 @@ describe("runDoctorCommand", () => {
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
failedProcesses: 0,
stoppedSessions: 5,
executed: 3,
});
+5 -13
View File
@@ -4,7 +4,6 @@ import { dirname, join } from "node:path";
import {
clearHubDiscovery,
ensureFileExists,
listActiveConnectors,
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
@@ -12,15 +11,14 @@ import {
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import {
type ActiveConnectorRecord,
formatUptime,
resolveClineBuildEnv,
} from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
listActiveConnectors,
} from "../connectors/status";
import { getCliBuildInfo } from "../utils/common";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
@@ -51,8 +49,6 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -341,8 +337,6 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -425,8 +419,6 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
@@ -1,94 +0,0 @@
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);
});
});
-117
View File
@@ -1,117 +0,0 @@
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),
);
});
}
+10 -44
View File
@@ -3,7 +3,6 @@ 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,
@@ -17,12 +16,18 @@ 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> = {},
@@ -195,11 +200,8 @@ describe("runHistoryList", () => {
vi.clearAllMocks();
});
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 },
});
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
mockedListSessions.mockResolvedValue([row]);
const io = {
writeln: vi.fn(),
@@ -216,8 +218,8 @@ describe("runHistoryList", () => {
expect(mockedListSessions).toHaveBeenCalledWith(25, {
hydrate: true,
});
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("hydrated title"),
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
expect.objectContaining({ rows: [row] }),
);
});
@@ -311,42 +313,6 @@ 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");
+46 -14
View File
@@ -1,6 +1,13 @@
import { exportHistorySession } from "../session/history-export";
import { deleteSession, listSessions, updateSession } from "../session/session";
import { formatHistoryListLine } from "../utils/history-format";
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 { writeln } from "../utils/output";
import type { CliOutputMode } from "../utils/types";
@@ -15,6 +22,22 @@ 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,
@@ -113,11 +136,7 @@ async function runHistoryExport(
}
try {
const targetPath = await exportHistorySession({
sessionId,
format: "html",
outputPath,
});
const targetPath = await exportHistorySession(sessionId, outputPath);
if (outputMode === "json") {
process.stdout.write(
@@ -142,7 +161,7 @@ export async function runHistoryList(input: {
outputMode: CliOutputMode;
workspaceRoot?: string;
io?: HistoryIo;
}): Promise<number> {
}): Promise<number | string> {
const io = input.io ?? {
writeln,
writeErr: (text: string) => process.stderr.write(`${text}\n`),
@@ -167,10 +186,23 @@ export async function runHistoryList(input: {
return 0;
}
for (const row of rows) {
io.writeln(formatHistoryListLine(row));
}
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),
});
}
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
export {
exportHistorySession,
runHistoryDelete,
runHistoryExport,
runHistoryUpdate,
};
-4
View File
@@ -34,7 +34,6 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -64,7 +63,6 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -90,8 +88,6 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
-3
View File
@@ -9,7 +9,6 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -135,8 +134,6 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
-61
View File
@@ -1,61 +0,0 @@
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})`,
);
});
});
+5 -3
View File
@@ -64,10 +64,13 @@ export function addRootOptions(cmd: Command): Command {
"--acp",
"Run in Agent Client Protocol (ACP) mode for editor integration",
)
.option("--config <path>", "Configuration directory (default: ~/.cline)")
.option(
"--config <path>",
"Configuration directory (default: ~/.cline/data/settings)",
)
.option(
"--data-dir <path>",
"Use isolated local state at this directory path (default: ~/.cline/data)",
"Use isolated local state at this directory path (default: ~/.cline)",
)
.option(
"--hooks-dir <path>",
@@ -133,7 +136,6 @@ 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,
+2 -4
View File
@@ -148,10 +148,8 @@ export function isJsonPath(path: string): boolean {
return path.toLowerCase().endsWith(".json");
}
export function parseMode(
raw: string | undefined,
): "act" | "plan" | "yolo" | undefined {
if (raw === "act" || raw === "plan" || raw === "yolo") {
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
if (raw === "act" || raw === "plan") {
return raw;
}
return undefined;
+3 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -10,7 +9,6 @@ import {
mergeScheduleMetadata,
parseJsonObjectFlag,
parseList,
parseMode,
resolveAddress,
toPositiveInt,
} from "./common";
@@ -65,8 +63,8 @@ export function registerScheduleCommands(
.option("--disabled", "Create in disabled state")
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--mode <act|plan>", "Execution mode")
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
.option("--provider <id>", "Provider ID", "cline")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
@@ -98,7 +96,7 @@ export function registerScheduleCommands(
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
mode: parseMode(opts.mode) ?? "yolo",
mode: opts.mode === "plan" ? "plan" : "act",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
systemPrompt: opts.systemPrompt,
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -40,7 +39,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
"openai/gpt-5.3-codex",
).trim();
return { provider, model };
}
@@ -166,10 +165,7 @@ export function registerScheduleImportCommand(
prompt: String(parsed.prompt ?? "").trim(),
provider,
model,
mode:
parseMode(
typeof parsed.mode === "string" ? parsed.mode : undefined,
) ?? "yolo",
mode: parsed.mode === "plan" ? "plan" : "act",
workspaceRoot,
cwd: String(parsed.cwd ?? "").trim() || undefined,
systemPrompt:
@@ -233,7 +229,7 @@ export function registerScheduleUpdateCommand(
.option("--enabled", "Enable the schedule")
.option("--max-parallel <n>", "New max parallel executions")
.option("--metadata-json <json>", "New metadata as JSON object")
.option("--mode <act|plan|yolo>", "New execution mode")
.option("--mode <act|plan>", "New execution mode")
.option("--model <model>", "New model")
.option("--name <name>", "New name")
.option("--pause", "Pause the schedule")
+25 -43
View File
@@ -59,7 +59,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -938,18 +937,9 @@ class DiscordConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopDiscordConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
protected override async runWithOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
): Promise<number> {
if (!options.applicationId) {
@@ -970,16 +960,7 @@ 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(
@@ -990,24 +971,26 @@ class DiscordConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<DiscordThreadState>(bindingsPath);
}
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;
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 loggerAdapter = createCliLoggerAdapter({
@@ -1120,8 +1103,6 @@ class DiscordConnector extends ConnectorBase<
},
) => {
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -1154,7 +1135,6 @@ class DiscordConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
resolveMuteTarget: ({ target }) => resolveDiscordMuteTarget(target),
createEmptyRuntimeReplyResolver:
@@ -1294,7 +1274,9 @@ class DiscordConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueTurn(runTurn);
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
};
bot.onNewMention(async (thread, message) => {
+55 -80
View File
@@ -55,7 +55,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -410,66 +409,11 @@ 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(
@@ -480,25 +424,26 @@ class GoogleChatConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<GoogleChatThreadState>(bindingsPath);
}
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;
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 loggerAdapter = createCliLoggerAdapter({
@@ -507,7 +452,38 @@ class GoogleChatConnector extends ConnectorBase<
});
const logger = createChatSdkLogger(loggerAdapter);
const consoleLogger = new ConsoleLogger("info", "gchat-connect");
const parsedCredentials = this.parseCredentials(options);
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 endpointUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/gchat`;
const gchat = createGoogleChatAdapter(
parsedCredentials
@@ -615,8 +591,6 @@ 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({
@@ -639,7 +613,6 @@ class GoogleChatConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -701,7 +674,9 @@ class GoogleChatConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueTurn(runTurn);
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
};
bot.onNewMention(async (thread, message) => {
+22 -35
View File
@@ -51,7 +51,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import { getConnectorSystemPrompt } from "./prompts";
@@ -478,23 +477,11 @@ 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(
@@ -505,24 +492,25 @@ class LinearConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<LinearThreadState>(bindingsPath);
}
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;
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 loggerAdapter = createCliLoggerAdapter({
@@ -638,8 +626,6 @@ 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({
@@ -664,7 +650,6 @@ class LinearConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -726,7 +711,9 @@ class LinearConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueTurn(runTurn);
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
};
bot.onNewMention(async (thread, message) => {
+24 -37
View File
@@ -61,7 +61,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -669,23 +668,11 @@ 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);
@@ -697,26 +684,27 @@ class SlackConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<SlackThreadState>(bindingsPath);
}
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;
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 loggerAdapter = createCliLoggerAdapter({
@@ -839,8 +827,6 @@ class SlackConnector extends ConnectorBase<
startRequest,
);
const queueKey = thread.id;
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
try {
await withSlackTeamBotToken({
@@ -869,7 +855,6 @@ class SlackConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (
currentThread,
@@ -954,7 +939,9 @@ class SlackConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueTurn(runTurn);
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
};
bot.onNewMention(async (thread, message) => {
@@ -2,19 +2,9 @@ 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, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
import { afterEach, describe, expect, it, vi } from "vitest";
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 {
@@ -25,11 +15,6 @@ 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);
@@ -168,7 +153,7 @@ describe("telegramConnector", () => {
expect(options.botUsername).toBe("test_bot");
});
it("validates a token before reporting its connector as already running", async () => {
it("does not call getMe when the token-only connector is already running", async () => {
const dataDir = useTempClineDataDir();
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
@@ -182,87 +167,26 @@ describe("telegramConnector", () => {
startedAt: new Date().toISOString(),
}),
);
const fetchImpl = vi.fn(async () =>
Response.json({
ok: true,
result: { username: "resolved_bot" },
}),
);
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected getMe call");
});
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),
},
{
setPersistenceArgs: vi.fn(),
setPersistenceInstanceId: vi.fn(),
},
),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
}),
).resolves.toBe(0);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl).not.toHaveBeenCalled();
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", () => {
+25 -54
View File
@@ -20,7 +20,7 @@ import {
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
import { ConnectorBase } from "../base";
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
import { isProcessRunning } from "../common";
import {
type ActiveConnectorTurn,
handleConnectorUserTurn,
@@ -51,7 +51,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -605,37 +604,10 @@ 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 &&
@@ -649,7 +621,7 @@ class TelegramConnector extends ConnectorBase<
io.writeln(
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
);
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
return 0;
}
}
let resolvedBotUsername: string;
@@ -666,8 +638,6 @@ 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(
@@ -678,24 +648,26 @@ class TelegramConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<TelegramThreadState>(bindingsPath);
}
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;
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 loggerAdapter = createCliLoggerAdapter({
@@ -817,8 +789,6 @@ 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({
@@ -843,7 +813,6 @@ class TelegramConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
forceDisableTools: !options.enableTools,
postFinalReply: async ({
@@ -955,7 +924,9 @@ class TelegramConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueTurn(runTurn);
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
};
bot.onNewMention(async (thread, message) => {
+23 -35
View File
@@ -55,7 +55,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -445,27 +444,15 @@ 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(
@@ -476,24 +463,26 @@ class WhatsAppConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<WhatsAppThreadState>(bindingsPath);
}
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;
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 loggerAdapter = createCliLoggerAdapter({
@@ -609,8 +598,6 @@ 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({
@@ -635,7 +622,6 @@ class WhatsAppConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -712,7 +698,9 @@ class WhatsAppConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueTurn(runTurn);
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
};
bot.onNewMention(async (thread, message) => {
-208
View File
@@ -1,208 +0,0 @@
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 });
});
});
+11 -86
View File
@@ -3,7 +3,6 @@ import { join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
import { Command, CommanderError } from "commander";
import {
CONNECT_ALREADY_RUNNING_EXIT_CODE,
isProcessRunning,
readJsonFile,
removeFile,
@@ -14,19 +13,15 @@ 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,
@@ -46,16 +41,8 @@ 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")) {
@@ -63,11 +50,7 @@ export abstract class ConnectorBase<Options, State>
}
}
async run(
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
async run(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
@@ -80,27 +63,7 @@ export abstract class ConnectorBase<Options, State>
io.writeErr(message);
return 1;
}
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);
return this.runWithOptions(options, rawArgs, io);
}
protected parseArgs(rawArgs: string[]): Options {
@@ -182,15 +145,14 @@ export abstract class ConnectorBase<Options, State>
formatBackgroundStartMessage: (pid: number) => string;
foregroundHint: string;
launchFailureMessage: string;
startupTimeoutMs?: number;
}): Promise<number | undefined> {
}): Promise<boolean> {
if (input.interactive || process.env[input.childEnvVar] === "1") {
return undefined;
return false;
}
const runningState = input.readState(input.statePath);
if (runningState && input.isRunning(runningState)) {
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
return true;
}
const pid = spawnDetachedConnector(
["connect", this.name],
@@ -199,32 +161,11 @@ export abstract class ConnectorBase<Options, State>
);
if (!pid) {
input.io.writeErr(input.launchFailureMessage);
return 1;
return true;
}
input.io.writeln(input.formatBackgroundStartMessage(pid));
input.io.writeln(input.foregroundHint);
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;
return true;
}
protected async stopAllFromStatePaths(
@@ -236,15 +177,13 @@ 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, failedProcesses, stoppedSessions };
return { stoppedProcesses, stoppedSessions };
}
protected async stopManagedProcess(input: {
@@ -259,31 +198,17 @@ export abstract class ConnectorBase<Options, State>
const state = input.readState(input.statePath);
if (!state) {
this.removeStateFile(input.statePath);
return {
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
};
return { stoppedProcesses: 0, stoppedSessions: 0 };
}
const pid = input.getPid(state);
let stoppedProcesses = 0;
if (await terminateProcess(pid)) {
if (await terminateProcess(input.getPid(state))) {
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, failedProcesses: 0, stoppedSessions };
return { stoppedProcesses, stoppedSessions };
}
protected parseOptionalInteger(
-18
View File
@@ -83,24 +83,6 @@ 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", () => {
+5 -29
View File
@@ -10,24 +10,11 @@ import {
import { join } from "node:path";
import type { HubSessionClient, HubSessionRow } from "@cline/core";
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
withResolvedClineBuildEnv,
} from "@cline/shared";
import { 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);
}
@@ -136,19 +123,6 @@ 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,
@@ -216,7 +190,10 @@ export function spawnDetachedConnector(
detachedLogFd === undefined
? "ignore"
: ["ignore", detachedLogFd, detachedLogFd],
env: buildDetachedConnectorEnv(childEnvKey),
env: {
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
@@ -268,7 +245,6 @@ export function spawnDetachedConnector(
export const __test__ = {
buildDetachedConnectorArgs,
buildDetachedConnectorCommand,
buildDetachedConnectorEnv,
};
export function readJsonFile<T>(path: string, fallback: T): T {
+8 -351
View File
@@ -3,7 +3,6 @@ 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", () => ({
@@ -44,22 +43,7 @@ function createThread(initialState: TestState = {}, isDM = true) {
state = { ...nextState };
},
async post(message: unknown) {
// 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);
}
posts.push(message);
const sentMessage = {
edit: async (nextMessage: unknown) => {
posts.push(nextMessage);
@@ -115,21 +99,13 @@ function createRuntimeClient(
);
const abortRuntimeSession = vi.fn(async () => undefined);
const deleteSession = vi.fn(async () => undefined);
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 sendRuntimeSession = vi.fn(async () => ({
result: {
text: responseText,
finishReason: "stop",
iterations: 1,
},
}));
const readMessages = vi.fn(async () => messages);
return {
client: {
@@ -162,10 +138,6 @@ function messageText(message: unknown): string {
return String(message);
}
async function runTurnImmediately(work: () => Promise<void>): Promise<void> {
await work();
}
describe("handleConnectorUserTurn", () => {
const tempDirs: string[] = [];
@@ -570,316 +542,6 @@ 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);
@@ -1315,7 +977,6 @@ describe("handleConnectorUserTurn", () => {
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "current-participant",
});
@@ -1564,7 +1225,6 @@ describe("handleConnectorUserTurn", () => {
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn: runTurnImmediately,
resolveMuteTarget: () => ({
participantKey: "discord:user:bob",
participantLabel: "<@bob>",
@@ -1766,7 +1426,6 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
});
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
@@ -1817,7 +1476,6 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
@@ -1868,7 +1526,6 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
+54 -242
View File
@@ -6,7 +6,6 @@ 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";
@@ -29,7 +28,6 @@ import {
import {
buildThreadStartRequest,
clearSession,
forgetThreadSession,
getOrCreateSessionId,
} from "./session-runtime";
import {
@@ -53,20 +51,6 @@ 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: {
@@ -151,45 +135,6 @@ 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,
@@ -255,7 +200,9 @@ function formatMuteTargetList(targets: ConnectorMuteTarget[]): string {
return targets.map(formatMuteTargetLabel).join(", ");
}
type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: {
thread: Thread<TState>;
text: string;
runtimeText?: string;
@@ -284,6 +231,8 @@ type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
firstContactMessage?: string | ((currentState: TState) => string | undefined);
chatCommandHost?: ChatCommandHost;
userInstructionService?: UserInstructionConfigService;
activeTurns?: Map<string, ActiveConnectorTurn>;
turnKey?: string;
resolveMuteTarget?: (input: {
target: string;
thread: Thread<TState>;
@@ -327,11 +276,7 @@ type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
threadId: string;
error: Error;
}) => Promise<void>;
} & ConnectorTurnCoordination;
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: ConnectorUserTurnInput<TState>): Promise<void> {
}): Promise<void> {
const resolvedInput = input.text.trim();
if (!resolvedInput) {
return;
@@ -947,61 +892,30 @@ export async function handleConnectorUserTurn<
input.baseStartRequest,
effectiveCurrentState,
);
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]) =>
const activeTurn =
input.activeTurns?.get(turnKey) ??
(input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.values()).find(
(turn) =>
turn.sessionId === currentState.sessionId?.trim() &&
turn.threadId === input.thread.id,
)
: undefined;
if (activeTurnEntry?.[1].sessionId?.trim()) {
const [activeTurnKey, activeTurn] = activeTurnEntry;
: undefined);
if (activeTurn?.sessionId?.trim()) {
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
);
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 input.client.sendRuntimeSession(
activeTurn.sessionId,
{
config: startRequest,
prompt,
attachments: buildAttachments({ userImages, userFiles }),
delivery: "steer",
},
{ timeoutMs: null },
);
await postConnectorText(
input.thread,
input.transport,
@@ -1009,44 +923,25 @@ export async function handleConnectorUserTurn<
);
return;
}
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(
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.bindingsPath,
input.baseStartRequest,
));
const effectiveCurrentState = applyForcedToolDisable(
currentState,
input.forceDisableTools,
);
const startRequest = buildThreadStartRequest(
input.baseStartRequest,
effectiveCurrentState,
);
input.clientId,
currentState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
});
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
@@ -1056,107 +951,16 @@ async function runConnectorRuntimeTurnWithRecovery<
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,
});
const activeTurn: ActiveConnectorTurn = {
input.activeTurns?.set(turnKey, {
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
@@ -1221,13 +1025,21 @@ async function runConnectorRuntimeTurn<
);
} finally {
input.pendingApprovals.delete(input.thread.id);
if (input.activeTurns?.get(turnKey) === activeTurn) {
input.activeTurns.delete(turnKey);
}
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,62 +67,6 @@ describe("createConnectorRuntimeTurnStream", () => {
});
});
it("keeps streaming when tool status delivery fails", async () => {
let handlers: StreamHandlers | undefined;
const log = vi.fn();
const statusError = new Error("message_not_found");
const client = {
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
handlers = callbacks;
return () => {};
},
sendRuntimeSession: async () => {
handlers?.onEvent({
eventType: "runtime.chat.tool_call_start",
payload: { toolName: "run_commands" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
handlers?.onEvent({
eventType: "runtime.chat.text_delta",
payload: { text: "Final response" },
});
return {
result: {
text: "Final response",
finishReason: "stop",
iterations: 1,
},
};
},
};
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "hi" },
clientId: "client-1",
logger: { core: { log } } as unknown as CliLoggerAdapter,
transport: "slack",
conversationId: "thread-1",
onToolStatus: async () => {
throw statusError;
},
})) {
chunks.push(chunk);
}
expect(chunks.join("")).toBe("Final response");
expect(log).toHaveBeenCalledWith(
"Connector tool status delivery failed",
expect.objectContaining({
severity: "warn",
transport: "slack",
error: statusError,
}),
);
});
it("treats queued runtime turns as non-error completion", async () => {
const log = vi.fn();
const client = {
+1 -11
View File
@@ -169,17 +169,7 @@ export function createConnectorRuntimeTurnStream(input: {
return;
}
lastStatusMessage = message;
try {
await input.onToolStatus?.(message);
} catch (error) {
input.logger.core.log("Connector tool status delivery failed", {
severity: "warn",
transport: input.transport,
conversationId: input.conversationId,
sessionId: input.sessionId,
error,
});
}
await input.onToolStatus?.(message);
};
const stopStreaming = input.client.streamEvents(
@@ -283,43 +283,6 @@ 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;
@@ -1,38 +1,86 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { ActiveConnectorRecord } from "@cline/shared";
import { listConnectorCatalog } from "@cline/shared";
import { resolveConnectorDataDir } from "@cline/shared/storage";
import { resolveClineDataDir } from "@cline/core";
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export type ActiveConnectorRecord = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
};
function listConnectorStatePaths(
type: ActiveConnectorRecord["type"],
): string[] {
const dir = join(resolveClineDataDir(), "connectors", type);
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter((name) => name.endsWith(".json") && !name.endsWith(".threads.json"))
.map((name) => join(dir, name));
}
function readJsonRecord(path: string): Record<string, unknown> | undefined {
if (!existsSync(path)) {
return undefined;
}
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore malformed connector state.
}
return undefined;
}
type ConnectorFieldKey = keyof Omit<
ActiveConnectorRecord,
"id" | "type" | "instanceId" | "pid" | "hubUrl"
"id" | "type" | "pid" | "hubUrl"
>;
const connectorFieldExtractors: Record<
ConnectorFieldKey,
(record: Record<string, unknown>) => string | number | undefined
(p: Record<string, unknown>) => string | number | undefined
> = {
startedAt: (record) =>
typeof record.startedAt === "string" ? record.startedAt : undefined,
port: (record) => (typeof record.port === "number" ? record.port : undefined),
baseUrl: (record) =>
typeof record.baseUrl === "string" ? record.baseUrl : undefined,
connectionMode: (record) =>
typeof record.connectionMode === "string"
? record.connectionMode
: undefined,
userName: (record) =>
typeof record.userName === "string" ? record.userName : undefined,
botUsername: (record) =>
typeof record.botUsername === "string" ? record.botUsername : undefined,
applicationId: (record) =>
typeof record.applicationId === "string" ? record.applicationId : undefined,
phoneNumberId: (record) =>
typeof record.phoneNumberId === "string" ? record.phoneNumberId : undefined,
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
port: (p) => (typeof p.port === "number" ? p.port : undefined),
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
connectionMode: (p) =>
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
botUsername: (p) =>
typeof p.botUsername === "string" ? p.botUsername : undefined,
applicationId: (p) =>
typeof p.applicationId === "string" ? p.applicationId : undefined,
phoneNumberId: (p) =>
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
};
const connectorActiveStateConfigs: Record<
const connectorConfigs: Record<
string,
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
> = {
@@ -56,33 +104,30 @@ const connectorActiveStateConfigs: Record<
},
};
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
function connectorRecordId(
type: ActiveConnectorRecord["type"],
fields: Partial<
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
>,
pid: number,
): string {
const identity =
fields.botUsername ??
fields.userName ??
fields.applicationId ??
fields.phoneNumberId ??
String(pid);
return `${type}:${identity}`;
}
function readActiveConnectorRecord(
type: string,
type: ActiveConnectorRecord["type"],
statePath: string,
): ActiveConnectorRecord | undefined {
let parsed: Record<string, unknown>;
try {
const value = JSON.parse(readFileSync(statePath, "utf8")) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
parsed = value as Record<string, unknown>;
} catch {
const parsed = readJsonRecord(statePath);
if (!parsed) {
return undefined;
}
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
const hubUrl =
typeof parsed.hubUrl === "string"
@@ -93,8 +138,7 @@ function readActiveConnectorRecord(
if (!pid || !hubUrl || !isProcessRunning(pid)) {
return undefined;
}
const config = connectorActiveStateConfigs[type];
const config = connectorConfigs[type];
if (!config) {
return undefined;
}
@@ -114,22 +158,9 @@ function readActiveConnectorRecord(
(fields as Record<string, unknown>)[key] = value;
}
}
const instanceId =
type === "telegram"
? fields.botUsername
: type === "discord"
? fields.applicationId
: type === "whatsapp" && typeof parsed.instanceKey === "string"
? parsed.instanceKey
: fields.userName;
if (!instanceId) {
return undefined;
}
return {
id: `${type}:${instanceId}`,
id: connectorRecordId(type, fields, pid),
type,
instanceId,
pid,
hubUrl,
...fields,
@@ -137,23 +168,23 @@ function readActiveConnectorRecord(
}
export function listActiveConnectors(): ActiveConnectorRecord[] {
const connectorTypes: ActiveConnectorRecord["type"][] = [
"discord",
"telegram",
"gchat",
"linear",
"slack",
"whatsapp",
];
const records: ActiveConnectorRecord[] = [];
for (const { name } of listConnectorCatalog()) {
const directory = join(resolveConnectorDataDir(), name);
if (!existsSync(directory)) {
continue;
}
for (const filename of readdirSync(directory)) {
if (!filename.endsWith(".json") || filename.endsWith(".threads.json")) {
continue;
}
const record = readActiveConnectorRecord(name, join(directory, filename));
for (const type of connectorTypes) {
for (const statePath of listConnectorStatePaths(type)) {
const record = readActiveConnectorRecord(type, statePath);
if (record) {
records.push(record);
}
}
}
return records.sort((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
+1 -13
View File
@@ -5,25 +5,13 @@ 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,
context: ConnectRunContext,
): Promise<number>;
validate(args: string[], io: ConnectIo): Promise<number>;
run(args: string[], io: ConnectIo): Promise<number>;
showHelp(io: ConnectIo): void;
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
}
+6 -20
View File
@@ -1,19 +1,13 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import {
disposeAll,
initVcr,
isHubDaemonProcess,
setConnectorCliLaunchSpec,
} from "@cline/shared";
import { disposeAll, initVcr, isHubDaemonProcess } 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.
@@ -22,20 +16,7 @@ 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 = () => {
@@ -76,6 +57,11 @@ if (!isMainThread) {
});
void (async () => {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
let exitCode = 0;
try {
const { runCli } = await import("./main");
+72 -277
View File
@@ -1,6 +1,4 @@
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CliMigrationNotice,
@@ -20,7 +18,6 @@ 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,
@@ -64,13 +61,6 @@ 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<
(
@@ -96,11 +86,16 @@ const worktreeMocks = vi.hoisted(() => ({
createTaskWorktree: vi.fn(),
}));
const historyMocks = vi.hoisted(() => ({
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
runHistoryList: vi.fn<() => Promise<number | string>>(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: {
@@ -203,27 +198,18 @@ 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;
@@ -235,6 +221,8 @@ 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",
@@ -285,16 +273,6 @@ 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();
@@ -316,25 +294,11 @@ 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,
@@ -369,55 +333,6 @@ 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 () => {
@@ -476,7 +391,7 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(process.exitCode).toBe(0);
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
@@ -613,6 +528,10 @@ 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");
@@ -715,7 +634,7 @@ describe("runCli lightweight command dispatch", () => {
expect.anything(),
undefined,
expect.objectContaining({
startupTarget: undefined,
initialView: undefined,
}),
);
});
@@ -726,6 +645,10 @@ 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");
@@ -755,6 +678,10 @@ 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");
@@ -859,7 +786,7 @@ describe("runCli lightweight command dispatch", () => {
undefined,
expect.objectContaining({
initialPrompt: "sup",
startupTarget: undefined,
initialView: undefined,
}),
);
});
@@ -921,172 +848,6 @@ 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"];
@@ -1099,33 +860,65 @@ describe("runCli lightweight command dispatch", () => {
expect.anything(),
"sess_123",
expect.objectContaining({
startupTarget: "chat",
initialView: "chat",
}),
);
});
it("opens history inside the interactive TUI for the history picker", async () => {
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
});
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);
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(),
undefined,
"sess_from_history",
expect.objectContaining({
initialPrompt: undefined,
startupTarget: "history",
initialView: "chat",
}),
);
});
@@ -1509,6 +1302,7 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
thinking: true,
reasoningEffort: "medium",
@@ -1595,7 +1389,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("uses Core's agentic compaction default for prompt runs", async () => {
it("enables truncation compaction by default for prompt runs", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1610,6 +1404,7 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
}),
expect.anything(),
+165 -99
View File
@@ -5,7 +5,6 @@ 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,
@@ -16,7 +15,6 @@ 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,
@@ -45,11 +43,6 @@ 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,
@@ -138,19 +131,11 @@ 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) {
@@ -164,13 +149,11 @@ 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;
startupTarget?: TuiStartupTarget;
} = {};
const ctx: { exitCode?: number; resumeSessionId?: string } = {};
const io = { writeln, writeErr };
const program = createProgram();
// Re-enable built-in help/version output for the routing program
@@ -261,7 +244,7 @@ export async function runCli(): Promise<void> {
ctx.exitCode = code;
},
() => {
ctx.startupTarget = "config";
launchConfigView = true;
},
);
return configCmd;
@@ -379,11 +362,6 @@ 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(
@@ -394,32 +372,16 @@ export async function runCli(): Promise<void> {
const {
formatAdapterList,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
runStopConnector,
} = await import("./commands/connect");
const opts = connectCmd.opts();
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 (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).
@@ -428,7 +390,7 @@ export async function runCli(): Promise<void> {
connectCmd.args.slice(1),
io,
);
} else if (isFullTTY) {
} else if (process.stdin.isTTY && process.stdout.isTTY) {
ctx.exitCode = await runConnectWizard();
} else {
writeln(`\nAdapters:\n${formatAdapterList()}`);
@@ -440,7 +402,7 @@ export async function runCli(): Promise<void> {
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
if (isFullTTY) {
if (process.stdin.isTTY && process.stdout.isTTY) {
ctx.exitCode = await runMcpWizard();
} else {
writeln(
@@ -505,17 +467,107 @@ export async function runCli(): Promise<void> {
await doctorCmd.parseAsync(cmd.args, { from: "user" });
});
registerHistoryCommand({
program,
io,
setExitCode: (code) => {
ctx.exitCode = code;
},
setStartupTarget: (target) => {
ctx.startupTarget = target;
},
isInteractiveTTY: () => isFullTTY,
});
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,
);
});
program
.command("hook")
@@ -547,7 +599,11 @@ export async function runCli(): Promise<void> {
.allowExcessArguments()
.passThroughOptions()
.action(async (_opts: unknown, cmd: Command) => {
if (cmd.args.length === 0 && isFullTTY) {
if (
cmd.args.length === 0 &&
process.stdin.isTTY &&
process.stdout.isTTY
) {
ctx.exitCode = await runScheduleWizard();
return;
}
@@ -695,8 +751,30 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
let startupTarget = ctx.startupTarget;
let resumeSessionId: string | undefined;
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,
};
}
if (args.id !== undefined) {
const sessionId = args.id.trim();
if (!sessionId) {
@@ -705,12 +783,16 @@ 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 (startupTarget) {
if (launchConfigView) {
args = {
...args,
interactive: true,
@@ -762,6 +844,14 @@ 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(
@@ -784,7 +874,7 @@ export async function runCli(): Promise<void> {
!args.prompt &&
!resumeSessionId &&
!stdinHasPipedInput() &&
!isFullTTY
(!process.stdin.isTTY || !process.stdout.isTTY)
) {
writeErr("--worktree without a prompt requires an interactive terminal.");
process.exitCode = 1;
@@ -838,27 +928,6 @@ 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
@@ -913,15 +982,9 @@ export async function runCli(): Promise<void> {
// and cannot be retroactively updated; this is by design for
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
if (provider === "cline") {
const savedAuth = selectedProviderSettings?.auth;
if (savedAuth?.accountId) {
identifyTelemetryAccount({
id: savedAuth.accountId,
provider: "cline",
organizationId: savedAuth.organizationId,
organizationName: savedAuth.organizationName,
memberId: savedAuth.memberId,
});
const savedAccountId = selectedProviderSettings?.auth?.accountId;
if (savedAccountId) {
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
}
}
@@ -1017,13 +1080,13 @@ export async function runCli(): Promise<void> {
cwd,
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: effectiveMode,
mode: args.mode ?? "act",
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
},
checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG,
compaction: buildCliCompactionConfig(effectiveCompactionMode),
compaction: buildCliCompactionConfig(args.compactionMode),
timeoutSeconds: args.timeoutSeconds,
sandbox: sandboxEnabled,
sandboxDataDir,
@@ -1031,7 +1094,7 @@ export async function runCli(): Promise<void> {
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
outputMode: args.outputMode,
mode: effectiveMode,
mode: args.mode,
logger: loggerAdapter.core,
loggerConfig: loggerAdapter.runtimeConfig,
telemetry: getCliTelemetryService(loggerAdapter.core),
@@ -1131,6 +1194,12 @@ 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:
@@ -1141,10 +1210,7 @@ export async function runCli(): Promise<void> {
notice: import("./kanban-migration/notice").CliMigrationNotice,
) => void)
| undefined;
if (
!startupTargetTakesPrecedenceOverMigrationNotice(startupTarget) &&
isFullTTY
) {
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
await import("./kanban-migration/notice");
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
@@ -1160,7 +1226,7 @@ export async function runCli(): Promise<void> {
initialPrompt: args.prompt,
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
clineProviderSettings: initialClineProviderSettings,
startupTarget,
initialView,
initialNotice,
onInitialNoticeShown: markInitialNoticeShown,
});
+8 -5
View File
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
let savedRejectionListeners: Function[] | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
savedRejectionListeners = process.rawListeners(
"unhandledRejection",
) as Function[];
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
@@ -68,7 +68,10 @@ export function clearAbortInProgress(): void {
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
for (const listener of savedRejectionListeners) {
process.on("unhandledRejection", listener);
process.on(
"unhandledRejection",
listener as (...args: unknown[]) => void,
);
}
savedRejectionListeners = undefined;
}
@@ -12,25 +12,6 @@ import {
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
@@ -65,7 +46,6 @@ function createProviderSettingsManager(): ProviderSettingsManager {
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -126,7 +106,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(400_000);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -150,7 +130,7 @@ describe("compactInteractiveMessages", () => {
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
it("falls back to legacy contextWindow for manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -158,7 +138,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(360_000);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -183,15 +163,6 @@ describe("compactInteractiveMessages", () => {
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -218,17 +189,6 @@ describe("compactInteractiveMessages", () => {
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
+10 -10
View File
@@ -61,15 +61,11 @@ export async function compactInteractiveMessages(input: {
compactionState?: SessionCompactionState;
}> {
const modelInfo = input.config.knownModels?.[input.config.modelId];
const compactionModelInfo = modelInfo
? {
...modelInfo,
id: modelInfo.id ?? input.config.modelId,
}
: {
id: input.config.modelId,
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
};
const maxInputTokens =
input.config.compaction?.maxInputTokens ??
modelInfo?.maxInputTokens ??
modelInfo?.contextWindow ??
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
const compact = createContextCompactionPrepareTurn(
{
providerConfig: resolveCompactionProviderConfig(
@@ -110,7 +106,11 @@ export async function compactInteractiveMessages(input: {
model: {
id: input.config.modelId,
provider: input.config.providerId,
info: compactionModelInfo,
info: {
...(modelInfo ?? {}),
id: modelInfo?.id ?? input.config.modelId,
maxInputTokens: maxInputTokens,
},
},
});
if (!result?.messages) {
@@ -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, timeout 60s");
expect(linear?.description).toBe("streamableHttp, oauth error");
expect(linear?.loadError).toBe("OAuth authorization failed");
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
expect(docs?.description).toBe("sse, oauth authorized");
expect(docs?.loadError).toBeUndefined();
});
+32 -7
View File
@@ -107,13 +107,38 @@ export async function sendTurnWithActModeContinuation<
};
}
// The tracker moved to @cline/shared so the VSCode extension can share the
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
// import surface stable.
export {
createModeSwitchNoticeTracker,
type ModeSwitchNotice,
} from "@cline/shared";
export type ModeSwitchNotice = {
from: InteractiveUiMode;
to: InteractiveUiMode;
};
/**
* Tracks a user-initiated mode switch so the next user message can carry a
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
* switch_to_act_mode path already announces itself via the continuation
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
* out, since the mode the model last saw never effectively changed.
*/
export function createModeSwitchNoticeTracker() {
let pending: ModeSwitchNotice | null = null;
return {
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
if (from === to) {
return;
}
if (pending) {
pending = pending.from === to ? null : { from: pending.from, to };
return;
}
pending = { from, to };
},
consume(): ModeSwitchNotice | null {
const notice = pending;
pending = null;
return notice;
},
};
}
export async function applyInteractiveModeConfig(input: {
config: Config;
@@ -641,22 +641,7 @@ export function createInteractiveSessionRuntime(input: {
})
: undefined,
);
// Report carried context from what the new session actually accepted:
// the host can reject the inherited state (e.g. stale anchor), and the
// UI must not claim a carry-over that did not happen.
const acceptedState = projectedMessages
? await readCompactionState(activeSessionId)
: undefined;
return {
forkedFromSessionId,
newSessionId: activeSessionId,
carriedWorkingContext: acceptedState
? {
workingContextMessages: acceptedState.messages.length,
canonicalMessages: messages.length,
}
: undefined,
};
return { forkedFromSessionId, newSessionId: activeSessionId };
};
const resumeSession = async (sessionId: string): Promise<Message[]> => {
+26 -4
View File
@@ -9,6 +9,23 @@ import {
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
- Read files, search the codebase, and gather context to understand the problem
- Ask clarifying questions when requirements are ambiguous
- Present your plan as a structured outline with clear steps
- Explain tradeoffs between different approaches when they exist
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
export async function resolveSystemPrompt(input: {
cwd: string;
explicitSystemPrompt?: string;
@@ -17,10 +34,15 @@ export async function resolveSystemPrompt(input: {
mode?: AgentMode;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
// Mode-tag and plan-mode instructions are appended by the shared prompt
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
// @cline/shared), so only the caller-specific rules are merged here.
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
// Both modes get the mode-tag explanation: after a switch, the transcript
// still contains messages tagged with the other mode.
rules = rules
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
: MODE_TAG_INSTRUCTIONS;
if (input.mode === "plan") {
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
}
return buildClineSystemPrompt({
ide: "Terminal Shell",
workspaceRoot: input.cwd,
+48 -38
View File
@@ -55,44 +55,54 @@ 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",
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("@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("../utils/approval", () => ({
askQuestionInTerminal: vi.fn(),
+3 -7
View File
@@ -205,9 +205,7 @@ export async function runAgent(
event.error.message.trim()
) {
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message, {
modelId: config.modelId,
}).trim(),
formatCliErrorMessage(event.error.message).trim(),
);
}
handleEvent(event, config);
@@ -392,9 +390,7 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = formatCliErrorMessage(result.text, {
modelId: config.modelId,
}).trim();
const errorText = formatCliErrorMessage(result.text).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -415,7 +411,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = formatCliErrorMessage(err, { modelId: config.modelId });
const message = formatCliErrorMessage(err);
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
+1 -89
View File
@@ -1,27 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { 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(
@@ -125,74 +108,3 @@ 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();
});
});
+16 -73
View File
@@ -2,16 +2,11 @@ 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,
@@ -28,7 +23,7 @@ import {
resolveClineWelcomeLine,
} from "../tui/interactive-welcome";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem, TuiStartupTarget } from "../tui/types";
import type { QueuedPromptItem } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import {
@@ -75,17 +70,6 @@ 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">,
@@ -143,37 +127,6 @@ 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,
@@ -181,7 +134,7 @@ export async function runInteractive(
options?: {
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
startupTarget?: TuiStartupTarget;
initialView?: "chat" | "config";
initialPrompt?: string;
initialNotice?: CliMigrationNotice;
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
@@ -514,7 +467,7 @@ export async function runInteractive(
tuiApp = await renderOpenTui({
config,
startupTarget: options?.startupTarget,
initialView: options?.initialView,
initialPrompt: options?.initialPrompt,
initialNotice: options?.initialNotice,
onInitialNoticeShown: options?.onInitialNoticeShown,
@@ -759,20 +712,15 @@ 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";
@@ -808,23 +756,18 @@ export async function runInteractive(
});
await sessionRuntime.restartWithCurrentMessages();
},
// 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;
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),
};
},
onCompact: async () => {
await sessionRuntime.ensureReady();
@@ -853,7 +796,7 @@ export async function runInteractive(
},
});
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
if (!loadDeferredInitialMessages) {
setTimeout(() => {
void sessionRuntime.ensureReady().catch((error) => {
if (sessionRuntime.isShutdownRequested() || startupErrorReported) {
-34
View File
@@ -1,34 +0,0 @@
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;
}
+2 -117
View File
@@ -17,19 +17,14 @@
// - Auto-approve all (Shift+Tab)
// ---------------------------------------------------------------------------
import { expect, test } from "@microsoft/tui-test";
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { test } from "@microsoft/tui-test";
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 {
expectNotVisible,
expectVisible,
typeAndSubmit,
} from "../helpers/terminal.js";
import { expectVisible } from "../helpers/terminal.js";
test.describe("cline (authenticated) - shows chat view", () => {
test.use({
@@ -58,113 +53,3 @@ 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);
});
});
-59
View File
@@ -309,62 +309,3 @@ describe("loadIndividualSubscriptionPlans", () => {
expect(result).toEqual(plans);
});
});
describe("isClineAccountCreditsErrorMessage", () => {
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the plain human-readable Cline API message", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage("Not enough credits available"),
).toBe(true);
});
it("matches the legacy insufficient balance phrasing", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
"Insufficient balance. Your Cline credits balance is $0.00.",
),
).toBe(true);
});
it("does not match unrelated errors", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
expect(
isClineAccountCreditsErrorMessage(
"Your credit balance is too low to access the Anthropic API.",
),
).toBe(false);
expect(
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
).toBe(false);
});
});
+2 -42
View File
@@ -51,16 +51,9 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
// The Cline API's 402 response carries `code: "insufficient_credits"` and
// the message "Not enough credits available". Depending on how much of the
// payload survives error extraction, the CLI may see the raw JSON blob or
// just the human-readable message, so match both. The
// "insufficient balance" pair is an older backend phrasing kept for safety.
return (
normalized.includes("insufficient_credits") ||
normalized.includes("not enough credits") ||
(normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance"))
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
);
}
@@ -158,38 +151,6 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -222,7 +183,6 @@ export async function loadClineAccountSnapshot(input: {
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
return {
user,
+10 -121
View File
@@ -1,7 +1,4 @@
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
} from "@cline/core";
import type { ClineSubscriptionPlan } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
@@ -11,8 +8,6 @@ import {
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -30,11 +25,9 @@ import {
type TerminalTheme,
} from "../palette";
import type { ChatEntry } from "../types";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseAskQuestionInput,
parseEditorInput,
@@ -135,13 +128,12 @@ 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={keys[i]}>
<span key={f.path}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -429,38 +421,6 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
);
}
function CompactionDividerRow(props: {
entry: Extract<ChatEntry, { kind: "compaction" }>;
}) {
const { entry } = props;
const { width: terminalWidth } = useTerminalDimensions();
const inProgress = entry.status === "started";
const labelColor = inProgress
? "cyan"
: entry.status === "failed"
? "red"
: entry.status === "cancelled" || entry.status === "skipped"
? "gray"
: "cyan";
const label = `${formatCompactionDividerLabel(entry)}`;
// Fill the remaining line with a plain rule instead of a flexGrow bordered
// box: a single fixed-content text row keeps the renderer's diffing stable.
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
return (
<box flexDirection="row">
{inProgress ? (
<box width={2}>
<spinner name="dots" color={labelColor} />
</box>
) : (
<text fg="gray" content="── " />
)}
<text fg={labelColor} selectable content={label} />
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
</box>
);
}
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
@@ -485,6 +445,14 @@ 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 " />
@@ -501,71 +469,6 @@ 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;
@@ -690,17 +593,6 @@ 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="* " />
@@ -724,9 +616,6 @@ export function ChatEntryView(props: {
</box>
);
case "compaction":
return <CompactionDividerRow entry={entry} />;
case "done": {
const parts: string[] = [];
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
@@ -891,7 +891,6 @@ export function OAuthLoginContent(
const escapeHint = allowApiKeyFallback
? "K to enter an API key instead, Esc to cancel"
: "Esc to cancel";
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
if (mode === "device") {
return (
@@ -919,7 +918,7 @@ export function OAuthLoginContent(
{deviceError && <text fg="red">{deviceError}</text>}
<text fg={escapeHintColor}>
<text fg="gray">
<em>{escapeHint}</em>
</text>
</box>
@@ -942,7 +941,7 @@ export function OAuthLoginContent(
{error && <text fg="red">{error}</text>}
<text fg={escapeHintColor}>
<text fg="gray">
<em>{escapeHint}</em>
</text>
</box>
@@ -4,7 +4,6 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import type React from "react";
import { palette } from "../../palette";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseEditorInput,
parseReadFilesInput,
@@ -23,14 +22,13 @@ 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={keys[i]} fg="gray" selectable>
<text key={f.path} fg="gray" selectable>
{" "}
{shortenPath(f.path, 60)}
{range && <span fg="gray">{range}</span>}
+71
View File
@@ -0,0 +1,71 @@
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),
}),
);
});
}
@@ -3,7 +3,6 @@ import type { OpenConfigOptions } from "./use-config-panel";
export interface LocalSlashCommandActionInput {
name: string;
isRunning: boolean;
openAccount: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
@@ -47,12 +46,7 @@ export function runLocalSlashCommandAction(
return true;
}
if (normalized === "compact") {
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
// shared running state.
if (!input.isRunning) {
input.runCompact();
}
input.runCompact();
return true;
}
if (normalized === "fork") {
+4 -91
View File
@@ -6,14 +6,13 @@ import type {
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveNonCompactionStatusLabel } from "../../utils/events";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
formatToolOutput,
truncate,
} from "../../utils/helpers";
import type { ChatEntry, InlineStream, TuiProps } from "../types";
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
interface AgentEventDeps {
appendEntry: (entry: ChatEntry) => void;
@@ -30,11 +29,9 @@ interface AgentEventDeps {
}) => void;
onTurnErrorReported: TuiProps["onTurnErrorReported"];
verbose: boolean;
modelId?: string;
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
const openCompactionEntryRef = useRef(false);
const {
appendEntry,
updateLastEntry,
@@ -46,50 +43,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
} = deps;
// Compaction dividers that arrived while an assistant message was still
// streaming. Appending them immediately would split the message in two, so
// they are held until the active content block closes (or the turn ends).
const pendingCompactionEntriesRef = useRef<
Extract<ChatEntry, { kind: "compaction" }>[]
>([]);
const flushPendingCompactionEntries = useCallback(() => {
const pending = pendingCompactionEntriesRef.current;
if (pending.length === 0) return;
pendingCompactionEntriesRef.current = [];
for (const entry of pending) {
if (entry.status !== "started" && openCompactionEntryRef.current) {
updateEntry((current) =>
current.kind === "compaction" && current.status === "started"
? { ...current, ...entry }
: current,
);
openCompactionEntryRef.current = false;
} else {
appendEntry(entry);
if (entry.status === "started") {
openCompactionEntryRef.current = true;
}
}
}
}, [appendEntry, updateEntry]);
const finalizeDanglingCompactionEntry = useCallback(
(status: "failed" | "cancelled") => {
if (!openCompactionEntryRef.current) return;
openCompactionEntryRef.current = false;
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status }
: entry,
);
},
[updateEntry],
);
const closeToolEntry = useCallback(
(event: AgentEvent & { type: "content_end" }) => {
const error = event.error ?? undefined;
@@ -129,11 +84,9 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(true);
setIsStreaming(true);
closeInlineStream();
flushPendingCompactionEntries();
break;
case "iteration_end":
closeInlineStream();
flushPendingCompactionEntries();
break;
case "content_start": {
setIsStreaming(false);
@@ -212,60 +165,24 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("cancelled");
break;
case "error":
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("failed");
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error, { modelId }),
text: formatCliErrorMessage(event.error),
});
}
break;
case "notice":
if (event.displayRole === "status") {
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (!compaction) {
closeInlineStream();
}
if (compaction) {
if (activeInlineStreamRef.current) {
// An assistant message is still streaming; appending now
// would split it around the divider. Hold the divider (final
// state until the content block closes, then reconcile it
// with the same open divider atomically.
pendingCompactionEntriesRef.current.push({
kind: "compaction",
...compaction,
});
break;
}
if (compaction.status === "started") {
appendEntry({ kind: "compaction", ...compaction });
openCompactionEntryRef.current = true;
} else if (openCompactionEntryRef.current) {
// Finalize the in-progress divider in place, wherever it
// sits in the transcript.
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, ...compaction }
: entry,
);
openCompactionEntryRef.current = false;
} else {
appendEntry({ kind: "compaction", ...compaction });
}
break;
}
const label = resolveNonCompactionStatusLabel(event);
closeInlineStream();
const label = resolveStatusNoticeLabel(event);
if (label) {
appendEntry({ kind: "status", text: label });
}
@@ -283,7 +200,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
[
appendEntry,
updateLastEntry,
updateEntry,
closeInlineStream,
activeInlineStreamRef,
setIsRunning,
@@ -291,10 +207,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
],
);

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