mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1352fa709 | |||
| 394fb04518 | |||
| f1aebbfd5a | |||
| 543dd0d818 | |||
| 1f2cbbeb9f | |||
| b1a89156d6 | |||
| 1d7d9ce5e2 | |||
| 78b7c3d8ac | |||
| d626cfb0b5 | |||
| e14f354c59 | |||
| 41ba332f0a | |||
| 6997fae815 | |||
| 5594512eb0 | |||
| 6712d43c69 | |||
| bd27d9c41b | |||
| 21edad82a6 | |||
| 2f58bbe4ed | |||
| ddfb67515b | |||
| 472f9c88c5 | |||
| f77d0930ba | |||
| d1462cf919 | |||
| 49d33aa793 | |||
| 9b5dcc6405 | |||
| f2ac1ef10a | |||
| 10da3bf5d6 | |||
| 0034efe48c | |||
| 64993e78d5 | |||
| 06f31f2821 | |||
| 71e6b44ef7 | |||
| 0bcd602150 | |||
| d759f4c646 | |||
| b035bf9255 | |||
| ca9cb7f554 | |||
| 1ae2f71a0f | |||
| 62c57a0ccd | |||
| f3c8b6748b | |||
| e6104cfd2c | |||
| 12431cd97b | |||
| 2b34b48ec6 | |||
| dc7eb755cf | |||
| accd7e5809 | |||
| 400ba47387 | |||
| 3af23c1c4c | |||
| ce04e80909 | |||
| 058c8c90a2 | |||
| 0f7e4b66ef | |||
| f1c45b63c8 | |||
| d6d1c789b3 | |||
| 3e81006863 | |||
| 8ea52a2eea | |||
| 5ec2d47b21 | |||
| 46fcde0a96 | |||
| fbaa44be96 | |||
| aec350bb9d | |||
| 16129cf90a | |||
| 3520786f4a | |||
| 8f0ff70215 | |||
| 0619e5a016 | |||
| 2a0dd197bf | |||
| 82e3596f96 | |||
| 895ab53b78 | |||
| b8f51b9e55 | |||
| 25dc89eab4 | |||
| 5fdd840d5f | |||
| cdcaa74422 | |||
| 5acc98474a | |||
| 53a5266239 | |||
| 1654517614 | |||
| bbfcbcd31d | |||
| 102ef1ab84 | |||
| 55d1476169 | |||
| 6173bad65e | |||
| cf710d76bf | |||
| 9b31692fa0 | |||
| d3e32500ae | |||
| b0cb179a06 | |||
| f57c7944ec | |||
| e450cbf5dd | |||
| 830ef5d288 | |||
| 723ee3b610 | |||
| 75e1cc7a6f | |||
| 96f8fbf671 | |||
| 211cc035bd | |||
| 1e9e3c7a3e | |||
| e543c692c7 | |||
| e34e794624 | |||
| 8d078f59bd | |||
| 94f897f559 | |||
| 4b529f5f81 | |||
| 978155814e | |||
| 055210a2bf | |||
| 8e68b14673 | |||
| 4061dda034 | |||
| b93a8cd442 | |||
| 72561771a7 | |||
| 123477dcd9 | |||
| 2ac20c647c | |||
| 316efc4521 | |||
| b0ee2cc80a | |||
| a063317218 | |||
| 6d10f363b3 | |||
| ab0bc93182 | |||
| 22ded04bc4 | |||
| edaab58716 | |||
| 0ec999b31a | |||
| 3845be53b3 | |||
| 8014a05088 | |||
| c7850423f1 | |||
| ed5f3031b0 | |||
| 3f38bd516f | |||
| be8c16b0ae | |||
| 0074b7222f | |||
| ce81bc6855 | |||
| e0803124b2 | |||
| 644e841737 | |||
| 5c6753e6d7 | |||
| fdcc5367dc | |||
| 901fdbc5cb | |||
| 69e3149f5f | |||
| 3a3d0c1bc3 | |||
| 0746ea72bf | |||
| f0d5ede555 | |||
| ed821a6456 | |||
| f36b59c9cb | |||
| 311959e757 | |||
| f21e6faf1c | |||
| 1cf19304f4 | |||
| 09aec528d0 | |||
| 4ec2b68c7c | |||
| 56fd6bb1ce | |||
| 49c7a89882 | |||
| b47851791c | |||
| c0a966c46a | |||
| cf3f3e08eb | |||
| 7712e44468 | |||
| f77f584a32 | |||
| 2771760305 | |||
| 16d0d04573 | |||
| 3590b425eb | |||
| 12404f0a4b | |||
| 6cb43f0309 | |||
| 3058563f37 | |||
| 39de7479b8 | |||
| 86c2600a8a | |||
| f4230e475b | |||
| 078abcd055 | |||
| afc1229ab0 | |||
| 892837d352 | |||
| 078d9f63f0 | |||
| fba27b3c81 | |||
| 6549bdbacc | |||
| 55ccbb7d51 | |||
| f039b1419f | |||
| 97c558ffe8 | |||
| 8f6f1652e1 | |||
| 26ee3abf82 | |||
| d2b674bb9d | |||
| 2ce4facd9d | |||
| ac4724166d | |||
| 2131bbba0c | |||
| 792738eeed | |||
| 0ac7d0bdb5 | |||
| 4fa42771a7 | |||
| c91cd4be59 | |||
| 851ee033bc | |||
| 192e6ffab2 | |||
| 6b668fefdc | |||
| 8224ad1634 | |||
| 96e8f51436 | |||
| 5e5c4475bc | |||
| 66228fb30b | |||
| 7b8798c996 | |||
| dfd22bf79e | |||
| 307707fd00 | |||
| 2a47c6ca08 | |||
| 704e953b69 | |||
| c5661f8835 | |||
| 60c5a24eef | |||
| 3f9ed573db | |||
| ac432c87f0 | |||
| 95b841a2dd | |||
| 7d63376d98 | |||
| 912c467818 | |||
| c39c6d4479 | |||
| 071e5451b1 | |||
| 159961b3a3 | |||
| 4a3f1ce310 | |||
| 10a658a767 | |||
| 9d63bfcb31 | |||
| daa32ee138 | |||
| a51fa646bb | |||
| 5213070f67 | |||
| 7d7cddb9d9 | |||
| 55e169f1f8 | |||
| c227e1ae36 | |||
| d0a0c802af | |||
| f9227fead4 | |||
| bd83980359 | |||
| 4d3b161b9a | |||
| 255be9ad29 | |||
| 77b181a472 | |||
| 45476650b7 | |||
| f847b06cfd | |||
| 598b3af7eb | |||
| fc936b7305 | |||
| 9247dc30bb | |||
| 1b499161cc | |||
| 0e5cb433b0 | |||
| 53a46c309b | |||
| 36c78267c6 | |||
| 76c30b1c60 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-extension
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/publish-extension
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish Cline Code
|
||||
|
||||
> 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.
|
||||
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both 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
|
||||
|
||||
@@ -17,7 +17,7 @@ Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon
|
||||
- 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 workflow creates the `desktop-vX.Y.Z` GitHub release (universal DMG + updater artifact + `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.
|
||||
|
||||
@@ -90,9 +90,22 @@ gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
**The run pauses for approval.** `validate` runs immediately, then the `build`
|
||||
job waits on the `PublishDesktop` environment until a required reviewer approves
|
||||
it — the run sits in `waiting`, which is expected, not a hang. Approve it in the
|
||||
run's web UI ("Review deployments"), or:
|
||||
|
||||
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
|
||||
```sh
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
--method POST -f state=approved -f comment="desktop vX.Y.Z" \
|
||||
-F 'environment_ids[]=19152605990' # PublishDesktop
|
||||
```
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`), verifies every Mach-O in the bundle carries both slices, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
@@ -100,17 +113,30 @@ If the workflow fails on missing credentials, see "Repo secrets (one-time setup)
|
||||
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.
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new `desktop-vX.Y.Z` universal `.app.tar.gz` asset (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps — including older per-arch installs — 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)
|
||||
## Publish 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):
|
||||
These live on the **`PublishDesktop` environment**, not at repository level, so
|
||||
only the `build` job can read them and only after an approval. Set them under
|
||||
Settings → Environments → PublishDesktop → Environment secrets. The environment
|
||||
also restricts deployments to `main` and requires a reviewer.
|
||||
|
||||
Adding one of these as a *repository* secret is the common mistake. The build
|
||||
would still succeed — an environment-gated job resolves repository secrets too,
|
||||
with environment values simply taking precedence — so the credential would sit
|
||||
repo-wide while everything looked fine. `validate` therefore fails the run if any
|
||||
of them resolves in a job with no environment. If you hit that, delete the
|
||||
repository-level copy rather than duplicating it.
|
||||
|
||||
If a secret is missing everywhere, the preflight in `build` fails the run naming
|
||||
the missing entries. The Apple values 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 |
|
||||
| --- | --- |
|
||||
@@ -124,4 +150,8 @@ signing & notarization" section for how to obtain them):
|
||||
| `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.
|
||||
`ERROR_SERVICE_API_KEY`, OTEL settings) are shared with the CLI, SDK, and extension
|
||||
publish workflows and already configured. **Do not move these into
|
||||
`PublishDesktop`** — scoping them to this environment empties them in every other
|
||||
publish workflow, silently, with no error beyond missing telemetry and a failed
|
||||
Slack post.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
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'])"
|
||||
```
|
||||
|
||||
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
|
||||
|
||||
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`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
|
||||
## Stable release (combined A/B VSIX) — the current stable path
|
||||
|
||||
### Pre-flight
|
||||
|
||||
```bash
|
||||
# 1. What's live, and what version comes next (must exceed it — rule 1)
|
||||
# 2. Flag percentage (rule 2) — decide where it should be for this release
|
||||
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
|
||||
git fetch origin main legacy-extension
|
||||
git log --oneline -3 origin/legacy-extension
|
||||
|
||||
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
|
||||
# hard-fails if views/viewsContainers/configuration diverged between branches.
|
||||
git show origin/main:apps/vscode/package.json > /tmp/next.json
|
||||
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
|
||||
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
|
||||
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
|
||||
```
|
||||
|
||||
Release prep on `main` (PR, not direct push):
|
||||
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
|
||||
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
|
||||
|
||||
### Dispatch
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
|
||||
# publish=false builds an installable .vsix artifact without publishing and
|
||||
# needs NO environment approval — the ungated build job uploads the artifact
|
||||
# and the run completes.
|
||||
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
|
||||
```
|
||||
|
||||
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
```
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
|
||||
|
||||
```bash
|
||||
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
|
||||
```
|
||||
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
|
||||
```
|
||||
|
||||
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
### Known caveats of this path
|
||||
|
||||
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
|
||||
- A red run can still mean a successful publish on paths that tag (see Gotchas).
|
||||
|
||||
## Nightly release
|
||||
|
||||
Happens automatically (cron 12:00 UTC). Manual cut:
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
|
||||
gh run watch <run-id> --exit-status --interval 60
|
||||
```
|
||||
|
||||
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
|
||||
|
||||
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
|
||||
|
||||
## Legacy hotfix release (and emergency full rollback)
|
||||
|
||||
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
|
||||
|
||||
```bash
|
||||
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
|
||||
# highest version ever published to the listing (rule 1 — including combined
|
||||
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main \
|
||||
-f release-type=release -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.
|
||||
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 publish runs) block their version's concurrency group.
|
||||
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: tuistory
|
||||
description: |
|
||||
Drive and test terminal apps (especially the Cline CLI TUI in apps/cli) through tuistory — named background PTY sessions that agents can read, wait on, snapshot, screenshot, and type into. Like Playwright/tmux for terminals, with reactive waiting instead of blind `sleep`.
|
||||
|
||||
Use this skill when you need to:
|
||||
- Manually test or reproduce bugs in the interactive Cline TUI (`bun run cli -i`) from a headless environment
|
||||
- Run a dev server or any long-lived/interactive process in the background without hanging your tool call
|
||||
- Write or extend Playwright-style e2e tests for the TUI (`bun run test:e2e:tuistory` in apps/cli)
|
||||
- Capture text snapshots or styled PNG screenshots of a TUI screen as evidence
|
||||
---
|
||||
|
||||
# tuistory
|
||||
|
||||
[tuistory](https://github.com/remorses/tuistory) wraps any terminal command in a named background PTY session backed by a Ghostty terminal emulator. Agents interact with the session via short CLI calls that return instantly; humans can `tuistory attach` to the same session to watch or intervene. No real terminal or display (`DISPLAY`) is needed — it works fully headless, which makes it the preferred way for cloud agents to exercise the Cline TUI.
|
||||
|
||||
It is installed as a devDependency of `@cline/cli`, so the pinned binary resolves when you run from `apps/cli`:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
bunx tuistory --help # source of truth for commands, options, and syntax
|
||||
```
|
||||
|
||||
For full upstream docs: `curl -s https://raw.githubusercontent.com/remorses/tuistory/refs/heads/main/README.md`
|
||||
|
||||
## Driving the Cline TUI headlessly
|
||||
|
||||
Launch the TUI in an isolated environment so you don't touch real user config (`~/.cline`):
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
DATA_DIR=$(mktemp -d) && HOME_DIR=$(mktemp -d)
|
||||
bunx tuistory -s cline --cols 120 --rows 36 \
|
||||
--env HOME=$HOME_DIR --env CLINE_DATA_DIR=$DATA_DIR \
|
||||
--env CLINE_DISABLE_CLINE_PASS_NOTICE=1 --env CLINE_TELEMETRY_DISABLED=1 \
|
||||
-- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
|
||||
```
|
||||
|
||||
The dummy `-k test-key` renders the full chat UI; only an actual agent turn would fail. For recorded LLM turns, use the VCR cassettes described in `apps/cli/src/tests/helpers/env.ts` (`CLINE_VCR=playback` + `CLINE_VCR_CASSETTE`). Real turns need a provider credential (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`).
|
||||
|
||||
Then use an **observe → act → observe** loop:
|
||||
|
||||
```bash
|
||||
# Wait reactively for the chat view — never use sleep
|
||||
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
|
||||
|
||||
# Act, then always observe the resulting screen state
|
||||
bunx tuistory -s cline type "/settings"
|
||||
bunx tuistory -s cline snapshot --trim
|
||||
bunx tuistory -s cline press enter
|
||||
bunx tuistory -s cline snapshot --trim
|
||||
|
||||
# Styled PNG of the current screen (prints the file path) — good for artifacts
|
||||
bunx tuistory -s cline screenshot
|
||||
|
||||
# Full raw output stream (snapshot shows only the visible screen)
|
||||
bunx tuistory read -s cline --all
|
||||
|
||||
# Tear down a session YOU started (double Ctrl+C exits the TUI cleanly)
|
||||
bunx tuistory -s cline press ctrl c
|
||||
bunx tuistory -s cline press ctrl c
|
||||
bunx tuistory -s cline close
|
||||
```
|
||||
|
||||
## Background processes (instead of tmux)
|
||||
|
||||
```bash
|
||||
bunx tuistory -s my-server -- bun run dev:sidecar # returns immediately
|
||||
bunx tuistory -s my-server wait "/listening|ready/i" --timeout 30000
|
||||
bunx tuistory read -s my-server # new output since last read
|
||||
bunx tuistory -s my-server restart # after code changes
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Options before `--`, command after.** Everything after the first `--` is passed verbatim to the child: `tuistory -s name --cols 150 -- bun src/index.ts` is correct.
|
||||
- **Snapshot after every action.** TUIs are stateful; dialogs and errors can render over the view you expect. `snapshot` reflects what the user actually sees (occluded text does not count), unlike grepping the raw stream.
|
||||
- **Wait, never sleep.** `wait "text"` / `wait "/regex/i"` (case-sensitive by default) reacts as fast as the terminal updates; `wait-idle` when you don't know what to expect. Always pass `--timeout`.
|
||||
- **Keys land instantly.** Unlike sleep-based scripts, a queued second keypress can leak into the next view (e.g. one Enter both accepts a slash completion and submits it).
|
||||
- **Never close a session you didn't start.** Sessions are shared with humans (`tuistory attach -s name`) and other agents. Default to leaving sessions running; use `read`/`wait`/`snapshot` to inspect without disrupting.
|
||||
- `--cols`/`--rows` affect TUI layout (assertions are width-sensitive); `--pixel-ratio 2` gives sharper screenshots.
|
||||
|
||||
## Writing e2e tests with the library API
|
||||
|
||||
`apps/cli/src/cli.tuistory.e2e.test.ts` (run: `bun run test:e2e:tuistory`) is the reference. The programmatic API runs in-process — no daemon:
|
||||
|
||||
```ts
|
||||
import { launchTerminal } from "tuistory";
|
||||
|
||||
const session = await launchTerminal({
|
||||
command: "bun",
|
||||
args: ["src/index.ts", "--provider", "anthropic", "-k", "test-key"],
|
||||
cwd: cliRoot,
|
||||
env: isolatedEnv, // see createCliEnv() in the reference test
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
waitForDataTimeout: 30_000, // CLI cold start compiles a large TS graph
|
||||
});
|
||||
|
||||
await session.waitForText("What can I do for you?", { timeout: 30_000 });
|
||||
const screen = await session.text({ trimEnd: true }); // emulated screen state
|
||||
await session.type("/settings");
|
||||
await session.press("enter");
|
||||
session.close(); // always close in test teardown
|
||||
```
|
||||
|
||||
Screen-state assertions can check that stale UI is *gone* (`expect(screen).not.toContain(...)`), which stream-grepping harnesses cannot. `session.text({ only: { bold: true } })` filters by style; `session.read()` returns the raw stream since the last read.
|
||||
@@ -31,6 +31,45 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
# Companion to the presence check in `build`, and the half that actually
|
||||
# establishes scope. This job declares no environment, so a signing secret
|
||||
# that resolves here can only be a repository or organization secret —
|
||||
# meaning it is still readable by every workflow in the repo, which is the
|
||||
# thing the PublishDesktop environment exists to prevent. Neither check
|
||||
# proves provenance alone (an environment-gated job resolves repository
|
||||
# secrets too, with environment values merely taking precedence), but
|
||||
# together they do: empty here plus present in `build` means the value came
|
||||
# from the environment.
|
||||
- name: Verify signing secrets are not repository-scoped
|
||||
env:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
unscoped=()
|
||||
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
|
||||
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -z "${!name}" ] || unscoped+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#unscoped[@]} -gt 0 ]; then
|
||||
echo "These signing secrets resolve in a job with no environment:"
|
||||
printf ' - %s\n' "${unscoped[@]}"
|
||||
echo
|
||||
echo "That means they are still repository or organization secrets and"
|
||||
echo "are readable by any workflow in this repo. Delete them at that"
|
||||
echo "level and add them to the PublishDesktop environment instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "No signing secret resolves outside the PublishDesktop environment."
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -79,19 +118,59 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (${{ matrix.arch }})
|
||||
name: Build macOS (universal)
|
||||
needs: validate
|
||||
# The Apple signing/notarization and Tauri updater secrets live in the
|
||||
# PublishDesktop environment rather than at repository level, so they are
|
||||
# readable only by this job and only once a required reviewer approves the
|
||||
# run. Defense in depth: this `if` is advisory because a dispatched branch
|
||||
# runs its own copy of this file; the enforced gate is the PublishDesktop
|
||||
# environment's deployment-branch policy, which must also allow only main.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
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:
|
||||
# A secret missing here is dangerous rather than merely broken: Tauri skips
|
||||
# code signing when APPLE_CERTIFICATE is empty and skips notarization when
|
||||
# APPLE_API_KEY is empty, both silently, so the build would still succeed
|
||||
# and publish an unsigned, un-notarized bundle. Only the missing updater
|
||||
# key is caught later (by the .sig check in "Collect artifacts"). Fail up
|
||||
# front instead, before any build work, if the environment is misconfigured.
|
||||
- name: Verify PublishDesktop secrets are present
|
||||
env:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
|
||||
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing from the PublishDesktop environment:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "Check that every secret above is set on the PublishDesktop"
|
||||
echo "environment and that this job still declares"
|
||||
echo "'environment: PublishDesktop'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deliberately not phrased as "resolved from PublishDesktop": a
|
||||
# non-empty value here could also be a repository or organization
|
||||
# secret. The repository-scope check in `validate` is what rules that
|
||||
# out.
|
||||
echo "All 8 signing secrets are present."
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -102,16 +181,18 @@ jobs:
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# A universal (fat) macOS bundle needs both architecture slices, so
|
||||
# install both Rust targets; `tauri build --target universal-apple-darwin`
|
||||
# compiles each and lipos the results into one binary.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/examples/desktop-app/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
# No Rust build cache here, deliberately. This is the only job that can
|
||||
# read the Apple signing certificate and the Tauri updater key, and a
|
||||
# restored cache archive is attacker-controlled the moment the Actions
|
||||
# cache is poisoned.
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -142,8 +223,22 @@ jobs:
|
||||
|
||||
- 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
|
||||
run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
|
||||
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
|
||||
# this step and inlines these values into the binary via `--define`
|
||||
# (scripts/telemetry-define-args.ts); a packaged app launched from
|
||||
# Finder/the Dock has no runtime env, so build-time inlining is the
|
||||
# only way the shipped sidecar can ever report telemetry.
|
||||
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 }}
|
||||
# Developer ID signing (Tauri imports the cert into a temp keychain)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -157,14 +252,64 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
# carries both slices before anything is published. A single-arch
|
||||
# sidecar would otherwise ship fine and only crash on the other arch.
|
||||
- name: Verify bundle is a universal binary
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Cline Code.app"
|
||||
if [ ! -d "$APP" ]; then
|
||||
echo "app bundle not found at $APP"
|
||||
exit 1
|
||||
fi
|
||||
for bin in "$APP/Contents/MacOS/"*; do
|
||||
archs=$(lipo -archs "$bin")
|
||||
echo "$bin: $archs"
|
||||
case "$archs" in
|
||||
*arm64*x86_64*|*x86_64*arm64*) ;;
|
||||
*)
|
||||
echo "$bin is not a universal binary (archs: $archs)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Guardrail: assert the telemetry config actually made it into the
|
||||
# compiled sidecar. Missing env on the build step (or a regression in
|
||||
# the --define inlining) would otherwise ship a release with telemetry
|
||||
# silently disabled — exactly what happened for every release before
|
||||
# this check existed. Being enabled is not enough on its own: an empty,
|
||||
# malformed, or non-http(s) OTLP endpoint would still drop every event
|
||||
# at runtime (the SDK exporters speak OTLP http/json only), so the
|
||||
# selfcheck must also report a usable endpoint host.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build, sign, and notarize desktop bundle' step and the"
|
||||
echo "--define inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
|
||||
echo "every event would be dropped at runtime. Check the"
|
||||
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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"
|
||||
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
@@ -173,22 +318,22 @@ jobs:
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_universal.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"
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.arch }}
|
||||
name: desktop-universal
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ name: ext-vscode-ab-package
|
||||
# `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.
|
||||
#
|
||||
# Job layout: cheap input gates (preflight) and the two bundle test suites run
|
||||
# ungated; the build job packages the VSIX with no environment attached, so
|
||||
# publish=false rehearsals complete without any approval; only the publish job
|
||||
# — Marketplace + Open VSX + bookkeeping — waits on the `publish` environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -24,7 +29,7 @@ on:
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -37,22 +42,187 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
# Input gates that need no checkout: fail in seconds — before the test
|
||||
# suites, the ~20-minute build, and the environment approval — instead of
|
||||
# at publish time.
|
||||
preflight:
|
||||
name: Validate inputs
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
# The input reaches the shell ONLY via env here (never inline
|
||||
# expression interpolation, which is evaluated before bash runs and
|
||||
# would allow script injection from the dispatch form). Because
|
||||
# every later job `needs` preflight, passing this regex is what
|
||||
# makes the plain-string `${{ inputs.version }}` interpolations
|
||||
# downstream safe.
|
||||
- name: Validate version format
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be plain X.Y.Z with no leading 'v' and no suffix (got '$VERSION')."
|
||||
echo "It is stamped verbatim into the union manifest and both bundle manifests."
|
||||
exit 1
|
||||
fi
|
||||
echo "Version format ok: $VERSION"
|
||||
|
||||
# The reusable bun 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
|
||||
|
||||
# Marketplace versions are monotonic and cannot be unpublished:
|
||||
# every publish must exceed the highest version ever published to
|
||||
# the claude-dev listing FROM ANY BRANCH (combined stable or legacy
|
||||
# hotfix). The publish job re-checks right before publishing — the
|
||||
# environment-approval wait can last days and a legacy hotfix can
|
||||
# land in between. Keep both copies of this check in sync.
|
||||
- name: Verify version exceeds the live Marketplace version
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
LIVE=$(curl -sf --retry 3 -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" \
|
||||
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
|
||||
if [[ -z "$LIVE" ]]; then
|
||||
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
|
||||
exit 1
|
||||
fi
|
||||
node -e '
|
||||
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (next[i] > live[i]) process.exit(0);
|
||||
if (next[i] < live[i]) break;
|
||||
}
|
||||
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
|
||||
process.exit(1);
|
||||
' "$VERSION" "$LIVE"
|
||||
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
|
||||
|
||||
# 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).
|
||||
#
|
||||
# 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 build job therefore pins the
|
||||
# default next-ref checkout to that same revision (tested == built) and
|
||||
# preflight 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 build job builds EXACTLY what
|
||||
# this suite ran against. legacy-ref is a mutable branch name and the
|
||||
# build job starts later — 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
|
||||
|
||||
build:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
needs: [preflight, test-next, test-legacy]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# For the default next-ref (main), pin the checkout to the exact
|
||||
# revision the test-next gate ran against: a moving branch name could
|
||||
# otherwise drift past the tested commit during the test phase.
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
# 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: ${{ github.event.inputs.legacy-ref }}
|
||||
ref: ${{ needs.test-legacy.outputs.tested-sha }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
@@ -65,9 +235,12 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# --frozen-lockfile so the built bundle resolves the exact
|
||||
# dependency set the test-next gate ran against (the reusable suite
|
||||
# installs frozen too) — a bare install could silently re-resolve.
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
@@ -76,6 +249,17 @@ jobs:
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# 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
|
||||
@@ -192,14 +376,176 @@ jobs:
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish to Marketplace and Open VSX
|
||||
needs: build
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
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:
|
||||
# The built next revision: preflight refused publish=true for any
|
||||
# next-ref other than main, and the build job pinned main to the
|
||||
# dispatch SHA — so github.sha IS the published commit. Used for the
|
||||
# changelog, the release tag, and the previous-tag lookup.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Download VSIX artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
# Re-check monotonicity at the last moment: the environment-approval
|
||||
# wait can last days, and a legacy hotfix published in the meantime
|
||||
# would otherwise be silently superseded by this older code line.
|
||||
# Keep in sync with the preflight copy of this check.
|
||||
- name: Re-verify version exceeds the live Marketplace version
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
LIVE=$(curl -sf --retry 3 -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" \
|
||||
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
|
||||
if [[ -z "$LIVE" ]]; then
|
||||
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
|
||||
exit 1
|
||||
fi
|
||||
node -e '
|
||||
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (next[i] > live[i]) process.exit(0);
|
||||
if (next[i] < live[i]) break;
|
||||
}
|
||||
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
|
||||
process.exit(1);
|
||||
' "$VERSION" "$LIVE"
|
||||
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
|
||||
|
||||
# Both PATs are verified BEFORE the first irreversible publish so a
|
||||
# missing Open VSX token can't strand us half-published. The two
|
||||
# registries are separate steps: if Open VSX fails after the
|
||||
# Marketplace accepted the VSIX, the run goes red (so the operator
|
||||
# notices Open VSX lagged) but the bookkeeping below still runs —
|
||||
# it is keyed off the Marketplace outcome, which is what "shipped"
|
||||
# means for this listing.
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
id: publish_marketplace
|
||||
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
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish to Open VSX."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Publish to Open VSX
|
||||
working-directory: staging
|
||||
env:
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: npx ovsx publish --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
|
||||
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
|
||||
# Mirrors the standalone publish workflows. Every step here is
|
||||
# continue-on-error, and gated on the MARKETPLACE outcome rather
|
||||
# than plain step ordering: the Marketplace publish already
|
||||
# happened, so bookkeeping must still run when only the Open VSX
|
||||
# step failed, 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: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
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: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
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: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
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: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
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: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
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 }}"
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
@@ -58,6 +58,11 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -97,7 +102,6 @@ jobs:
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
+188
@@ -1,5 +1,193 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
|
||||
|
||||
### Changed
|
||||
|
||||
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
|
||||
|
||||
## [4.1.4]
|
||||
|
||||
### Added
|
||||
|
||||
- Recognize Chutes as a provider.
|
||||
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
|
||||
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
|
||||
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
|
||||
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
|
||||
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
|
||||
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
|
||||
- Show installed plugins under their real package names instead of all appearing as "index".
|
||||
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
|
||||
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
|
||||
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
|
||||
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
|
||||
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
|
||||
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
|
||||
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
|
||||
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
|
||||
|
||||
## [4.1.3]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop the two bundles of the combined rollout package from invalidating each other's Cline account session. A still-open legacy window that refreshed its token after the machine was promoted to the new extension would consume the shared refresh token, producing spurious "Unauthorized" / re-authenticate prompts and unexpected sign-outs. Promoted legacy windows now keep working on their current session and offer a one-time Reload Window prompt instead.
|
||||
- Fall back to the default Cline model when migrating a setup that references a model id the new extension doesn't recognize, instead of leaving the provider unconfigured.
|
||||
- Restore reliable checkpoints: checkpoints are created consistently, and restoring one now rewinds the whole workspace rather than a subset of files.
|
||||
- Keep settings edits that are made before the provider config finishes loading — base URLs, API keys, and the Qwen/Moonshot API line are no longer silently discarded.
|
||||
- Stop losing keystrokes in custom base URL fields, and keep the custom URL checkbox state after a failed clear.
|
||||
- Use the AskSage custom API URL at inference time instead of ignoring it.
|
||||
- Settle a pending tool approval when an edited message replaces the session, so the task no longer hangs waiting on a prompt that is gone.
|
||||
- Drop attachments from messages that have been edited.
|
||||
- Complete terminal commands when the shell execution ends, so tasks no longer stall on commands that already finished.
|
||||
- Include untracked files when generating commit messages.
|
||||
- Run Windows Store PowerShell profiles correctly.
|
||||
- Surface the upstream provider error when a gateway-forwarded stream fails, instead of a generic failure.
|
||||
- Retry empty Ollama responses at the model boundary, and raise the response-start timeout to 5 minutes so cold model loads no longer error out.
|
||||
- Show proper display names for Cline free models and recommended models in the model picker.
|
||||
- Preserve video input capability for models that support it.
|
||||
- Keep the plan/act input border in sync with the actual textarea focus.
|
||||
|
||||
## [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
@@ -7,7 +7,7 @@ We're thrilled you're interested in contributing to Cline. Whether you're fixing
|
||||
Bug reports help make Cline better for everyone! Before creating a new issue, please [search existing ones](https://github.com/cline/cline/issues) to avoid duplicates. When you're ready to report a bug, head over to our [issues page](https://github.com/cline/cline/issues/new/choose) where you'll find a template to help you with filling out the relevant information.
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
|
||||
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">GitHub security tool to report it privately</a>.
|
||||
</blockquote>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.51
|
||||
|
||||
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
|
||||
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
|
||||
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
|
||||
|
||||
## 3.0.50
|
||||
|
||||
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
|
||||
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
|
||||
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
|
||||
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
|
||||
- Auto-approval settings are now honored over ACP
|
||||
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
|
||||
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
|
||||
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
|
||||
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
|
||||
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
|
||||
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
|
||||
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
|
||||
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
|
||||
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
|
||||
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
|
||||
|
||||
## 3.0.49
|
||||
|
||||
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
|
||||
- Checkpoints are actually created again; a run-boundary regression meant none were ever recorded in the CLI (from SDK v0.0.69)
|
||||
- Checkpoint restore is now a full workspace rewind: files Cline created during the task come back at their checkpoint-time content and files created after the checkpoint are removed, while `.gitignore`d paths (build output, `node_modules`, `.env`) are left alone (from SDK v0.0.69)
|
||||
- After a restore, the rewound message is prefilled as plain text instead of the raw `<user_input mode="act">` envelope
|
||||
- Ollama's response-start timeout is now 5 minutes instead of 30 seconds, so cold-loading a large local model no longer errors out mid-load (from SDK v0.0.69)
|
||||
- Empty Ollama responses are now retried instead of failing the task with "Model returned empty response" (from SDK v0.0.69)
|
||||
- Migrated users whose stored Cline model id isn't in the catalog now fall back to the default model instead of sending an unknown model id on every request (from SDK v0.0.69)
|
||||
- The ClinePass promo dialog can be dismissed with any key (Enter still opens the subscription page), and it is marked as shown when it appears, so force-quitting no longer replays it on every launch
|
||||
- Opening a URL no longer crashes the CLI on hosts without an opener binary (headless Linux without `xdg-open`); WSL2 containers now use `xdg-open`, Windows tries the absolute PowerShell path first, and `cline doctor log` converts Linux paths to `\\wsl$` UNC paths
|
||||
- The hub now restarts through the installed wrapper after a Unix self-update, so npm cannot reuse a deleted cached executable
|
||||
- ACP: ClinePass is selectable as a provider, organizations can be selected, session resolution and text rendering on session restart are fixed, and agent errors now describe the actual failure
|
||||
- Provider errors forwarded through the Vercel AI Gateway now surface the real upstream message instead of a raw Zod dump or `[object Object]` (from SDK v0.0.68)
|
||||
- Cline free models and recommended models now show their real display names in the model picker (from SDK v0.0.68)
|
||||
- Sessions rooted at the filesystem root (`/`) no longer fail every command (from SDK v0.0.68)
|
||||
- On Windows, PowerShell commands now travel over UTF-8 stdin, so non-ASCII commands survive the active code page and long commands are not capped by the command-line limit (from SDK v0.0.68)
|
||||
- The live model catalog no longer drops the video input capability (from SDK v0.0.68)
|
||||
- Removed the CLI promo code flow
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -339,6 +339,9 @@ bun run test:e2e:interactive
|
||||
# TUI-specific E2E tests (uses @microsoft/tui-test)
|
||||
bun run test:e2e:cli:tui
|
||||
|
||||
# TUI E2E tests driven through tuistory (PTY + Ghostty terminal emulator)
|
||||
bun run test:e2e:tuistory
|
||||
|
||||
# Type checking
|
||||
bun run typecheck
|
||||
|
||||
@@ -364,6 +367,34 @@ bun run dev -- --interactive --config /tmp/cline-test
|
||||
|
||||
Or set `CLINE_FORCE_ONBOARDING=1` to force the onboarding view regardless of existing config.
|
||||
|
||||
### Manually testing the TUI (agents / headless environments)
|
||||
|
||||
[tuistory](https://github.com/remorses/tuistory) is installed as a devDependency. It wraps the TUI in a named background PTY session that can be scripted from a plain shell — no real terminal or display needed. This is the preferred way for AI agents (or anyone in a headless environment) to poke at the interactive TUI:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
|
||||
# Launch the TUI in a background session
|
||||
bunx tuistory -s cline --cols 120 --rows 36 -- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
|
||||
|
||||
# Wait reactively for the chat view (no sleep guessing)
|
||||
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
|
||||
|
||||
# Interact and inspect
|
||||
bunx tuistory -s cline type "/settings"
|
||||
bunx tuistory -s cline press enter
|
||||
bunx tuistory -s cline snapshot --trim # current screen as text
|
||||
bunx tuistory -s cline screenshot # current screen as a styled PNG
|
||||
|
||||
# A human can watch/drive the same session from another terminal
|
||||
tuistory attach -s cline
|
||||
|
||||
# Tear down
|
||||
bunx tuistory -s cline close
|
||||
```
|
||||
|
||||
The same engine powers the `test:e2e:tuistory` vitest suite (`src/cli.tuistory.e2e.test.ts`), which uses the programmatic `launchTerminal()` API for assertions against the emulated screen.
|
||||
|
||||
### Adding a new TUI component
|
||||
|
||||
1. Create a `.tsx` file in `src/tui/components/`
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
|
||||
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
|
||||
| `--kanban` | Run the external `kanban` app |
|
||||
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.47",
|
||||
"version": "3.0.51",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -62,6 +62,7 @@
|
||||
"test:unit": "vitest run --config vitest.config.ts",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:e2e:interactive": "vitest run --config vitest.interactive.e2e.config.ts",
|
||||
"test:e2e:tuistory": "vitest run --config vitest.tuistory.e2e.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"test:e2e:cli:tui": "cd src/tests && tui-test",
|
||||
"link": "bun unlink && bun link"
|
||||
@@ -99,8 +100,9 @@
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@microsoft/tui-test": "^0.0.2",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/react": "19.2.14",
|
||||
"vitest": "^4.0.18",
|
||||
"@types/bun": "^1.3.10"
|
||||
"tuistory": "^0.10.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Launch the Cline CLI in ACP mode from source, for use as a Zed custom agent.
|
||||
#
|
||||
# Zed spawns agents without your interactive shell's PATH, so `bun` (installed
|
||||
# via mise/asdf/nvm/homebrew) is usually not resolvable. This wrapper finds bun
|
||||
# explicitly and execs it from the repo root.
|
||||
#
|
||||
# IMPORTANT: stdout is the JSON-RPC channel. Never echo to stdout here — any
|
||||
# stray byte corrupts the ACP stream. Diagnostics go to stderr.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
|
||||
# Prefer an explicit override, then PATH, then common version-manager locations.
|
||||
if [ -n "${BUN_BIN:-}" ]; then
|
||||
bun_bin="$BUN_BIN"
|
||||
elif command -v bun > /dev/null 2>&1; then
|
||||
bun_bin="$(command -v bun)"
|
||||
else
|
||||
bun_bin=""
|
||||
for candidate in \
|
||||
"$HOME"/.local/share/mise/installs/bun/*/bin/bun \
|
||||
"$HOME"/.bun/bin/bun \
|
||||
/opt/homebrew/bin/bun \
|
||||
/usr/local/bin/bun; do
|
||||
if [ -x "$candidate" ]; then
|
||||
bun_bin="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$bun_bin" ]; then
|
||||
echo "acp-dev.sh: could not find the 'bun' executable; set BUN_BIN to its path" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec "$bun_bin" --conditions=development --cwd apps/cli dev --acp "$@"
|
||||
+314
-36
@@ -7,6 +7,8 @@ import type {
|
||||
ContentBlock,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
@@ -28,11 +30,12 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { isLikelyAuthError, 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 } from "../utils/cline-pass-errors";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import type { Config } from "../utils/types";
|
||||
@@ -43,8 +46,24 @@ import {
|
||||
authenticateAcpProvider,
|
||||
isAcpAuthMethodId,
|
||||
} from "./auth";
|
||||
import { requestAcpToolApproval } from "./permissions";
|
||||
import {
|
||||
AUTO_APPROVE_CONFIG_ID,
|
||||
buildAutoApproveConfigOption,
|
||||
parseAutoApproveValue,
|
||||
} from "./auto-approve";
|
||||
import {
|
||||
buildOrganizationConfigOption,
|
||||
fetchClineOrganizations,
|
||||
getAcpOrgSubscriptionMessage,
|
||||
ORGANIZATION_CONFIG_ID,
|
||||
PERSONAL_ACCOUNT_VALUE,
|
||||
switchClineOrganization,
|
||||
usesClineAccount,
|
||||
} from "./organizations";
|
||||
import { requestAcpToolApproval } from "./permissions";
|
||||
import { replaySessionHistory } from "./session-load";
|
||||
import {
|
||||
describeAgentError,
|
||||
forwardAgentEvent,
|
||||
sendConfigOptionUpdate,
|
||||
sendCurrentModeUpdate,
|
||||
@@ -61,6 +80,8 @@ interface SessionState {
|
||||
currentProviderId: string;
|
||||
/** Current model id for the session. */
|
||||
currentModelId: string;
|
||||
/** When true, all tool calls are approved without asking the client. */
|
||||
autoApproveTools: boolean;
|
||||
/** Active session manager for the running agent, if any. */
|
||||
sessionManager?: ClineCore;
|
||||
/** Internal session id within the session manager. */
|
||||
@@ -69,6 +90,15 @@ 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[];
|
||||
}
|
||||
@@ -77,12 +107,17 @@ export class AcpAgent implements Agent {
|
||||
private sessions = new Map<string, SessionState>();
|
||||
private readonly conn: AgentSideConnection;
|
||||
private readonly providerSettingsManager = new ProviderSettingsManager();
|
||||
private readonly defaultAutoApproveTools: boolean;
|
||||
|
||||
/** Set after a successful `authenticate` call. */
|
||||
private authResult?: AcpAuthResult;
|
||||
|
||||
constructor(conn: AgentSideConnection) {
|
||||
constructor(
|
||||
conn: AgentSideConnection,
|
||||
options?: { autoApproveTools?: boolean },
|
||||
) {
|
||||
this.conn = conn;
|
||||
this.defaultAutoApproveTools = options?.autoApproveTools ?? false;
|
||||
}
|
||||
|
||||
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
@@ -109,7 +144,7 @@ export class AcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
isSessionReady() {
|
||||
// Require authentication unless an API key is provided via env var.
|
||||
if (!this.authResult && !process.env.CLINE_API_KEY) {
|
||||
// Check for valid persisted credentials from a previous session
|
||||
@@ -119,18 +154,46 @@ export class AcpAgent implements Agent {
|
||||
if (!this.authResult) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
"Call authenticate before creating a session",
|
||||
"Call authenticate before starting a session",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
availableModes() {
|
||||
return [
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description:
|
||||
"Explore the codebase and plan changes without modifying files",
|
||||
},
|
||||
{
|
||||
id: "act",
|
||||
name: "Act",
|
||||
description: "Make changes to the codebase",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
this.isSessionReady();
|
||||
|
||||
const sessionId = randomSessionId();
|
||||
|
||||
const defaultMode = "act";
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
const defaultModelId =
|
||||
process.env.CLINE_MODEL ?? "anthropic/claude-sonnet-4.6";
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
// Model ids are provider-scoped, so the default must come from the
|
||||
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
|
||||
// nothing to `cline`, and vice versa.
|
||||
const defaultModelId = await resolveDefaultModelId(
|
||||
providerId,
|
||||
process.env.CLINE_MODEL,
|
||||
providerModels,
|
||||
);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
@@ -139,9 +202,9 @@ export class AcpAgent implements Agent {
|
||||
currentMode: defaultMode,
|
||||
currentProviderId: providerId,
|
||||
currentModelId: defaultModelId,
|
||||
autoApproveTools: this.defaultAutoApproveTools,
|
||||
});
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([modelId, info]) => ({
|
||||
modelId,
|
||||
@@ -150,22 +213,13 @@ export class AcpAgent implements Agent {
|
||||
}),
|
||||
);
|
||||
|
||||
const organizationOption =
|
||||
await this.getOrganizationConfigOption(providerId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: [
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description:
|
||||
"Explore the codebase and plan changes without modifying files",
|
||||
},
|
||||
{
|
||||
id: "act",
|
||||
name: "Act",
|
||||
description: "Make changes to the codebase",
|
||||
},
|
||||
],
|
||||
availableModes: this.availableModes(),
|
||||
currentModeId: defaultMode,
|
||||
},
|
||||
models: {
|
||||
@@ -176,10 +230,87 @@ export class AcpAgent implements Agent {
|
||||
await buildProviderConfigOption(providerId),
|
||||
buildModelConfigOption(defaultModelId, providerModels),
|
||||
buildModeConfigOption(defaultMode),
|
||||
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
|
||||
...(organizationOption ? [organizationOption] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
this.isSessionReady();
|
||||
|
||||
let session = this.sessions.get(params.sessionId);
|
||||
let messages: Message[];
|
||||
|
||||
if (session?.sessionManager && session.activeSessionId) {
|
||||
// The session is still live in this connection — replay its current
|
||||
// conversation without restarting anything.
|
||||
messages =
|
||||
(await session.sessionManager.readMessages(session.activeSessionId)) ??
|
||||
[];
|
||||
} else {
|
||||
if (!session) {
|
||||
// Provider/model are not persisted per session — a session
|
||||
// loaded on a fresh connection starts from the same defaults
|
||||
// as a new session, with the model resolved against the
|
||||
// provider's own catalog just like newSession.
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
session = {
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers,
|
||||
currentMode: "act",
|
||||
currentProviderId: providerId,
|
||||
currentModelId: await resolveDefaultModelId(
|
||||
providerId,
|
||||
process.env.CLINE_MODEL,
|
||||
providerModels,
|
||||
),
|
||||
autoApproveTools: this.defaultAutoApproveTools,
|
||||
};
|
||||
this.sessions.set(params.sessionId, session);
|
||||
}
|
||||
try {
|
||||
messages =
|
||||
(await this.ensureSessionManager(session, params.sessionId, {
|
||||
resume: true,
|
||||
})) ?? [];
|
||||
} catch (error) {
|
||||
this.sessions.delete(params.sessionId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The ACP spec requires the full conversation to be replayed via
|
||||
// session/update notifications before this request resolves.
|
||||
await replaySessionHistory(this.conn, params.sessionId, messages);
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([availableModelId, info]) => ({
|
||||
modelId: availableModelId,
|
||||
name: info.name ?? availableModelId,
|
||||
description: info.description,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
modes: {
|
||||
availableModes: this.availableModes(),
|
||||
currentModeId: session.currentMode,
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId: session.currentModelId,
|
||||
},
|
||||
configOptions: await buildAllConfigOptions(session),
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
@@ -193,6 +324,7 @@ 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) {
|
||||
@@ -242,6 +374,17 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -326,16 +469,37 @@ export class AcpAgent implements Agent {
|
||||
// creates a fresh one with the new provider on the next prompt().
|
||||
await this.teardownSessionManager(session);
|
||||
|
||||
// If current model doesn't exist in new provider, reset to first available
|
||||
// Re-resolve the model against the new provider's catalog: keep the
|
||||
// current one when it's offered there too, otherwise fall back to the
|
||||
// provider's declared default rather than whichever model happens to
|
||||
// be listed first (for cline-pass that is an unrelated free model).
|
||||
const providerModels = await Llms.getModelsForProvider(value);
|
||||
const modelIds = Object.keys(providerModels);
|
||||
const fallbackModelId = modelIds[0];
|
||||
if (
|
||||
!modelIds.includes(session.currentModelId) &&
|
||||
fallbackModelId !== undefined
|
||||
) {
|
||||
session.currentModelId = fallbackModelId;
|
||||
session.currentModelId = await resolveDefaultModelId(
|
||||
value,
|
||||
session.currentModelId,
|
||||
providerModels,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case ORGANIZATION_CONFIG_ID: {
|
||||
try {
|
||||
await switchClineOrganization({
|
||||
apiKey: this.accountApiKey,
|
||||
providerSettingsManager: this.providerSettingsManager,
|
||||
organizationId: value === PERSONAL_ACCOUNT_VALUE ? null : value,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = describeAgentError(error);
|
||||
throw RequestError.internalError(
|
||||
{ message },
|
||||
`Failed to switch account: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Restart the backend session so subsequent turns run under the
|
||||
// newly selected account.
|
||||
await this.teardownSessionManager(session);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -362,6 +526,18 @@ export class AcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
case AUTO_APPROVE_CONFIG_ID: {
|
||||
const autoApprove = parseAutoApproveValue(params.value);
|
||||
if (autoApprove === undefined) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid auto-approve value: ${String(params.value)} (must be a boolean)`,
|
||||
);
|
||||
}
|
||||
session.autoApproveTools = autoApprove;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
@@ -370,6 +546,12 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
|
||||
const configOptions = await buildAllConfigOptions(session);
|
||||
const organizationOption = await this.getOrganizationConfigOption(
|
||||
session.currentProviderId,
|
||||
);
|
||||
if (organizationOption) {
|
||||
configOptions.push(organizationOption);
|
||||
}
|
||||
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
|
||||
return { configOptions };
|
||||
}
|
||||
@@ -410,6 +592,25 @@ export class AcpAgent implements Agent {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private get accountApiKey(): string {
|
||||
return process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
}
|
||||
|
||||
private async getOrganizationConfigOption(
|
||||
providerId: string,
|
||||
): Promise<SessionConfigOption | undefined> {
|
||||
if (!usesClineAccount(providerId)) {
|
||||
return undefined;
|
||||
}
|
||||
const organizations = await fetchClineOrganizations({
|
||||
apiKey: this.accountApiKey,
|
||||
providerSettingsManager: this.providerSettingsManager,
|
||||
});
|
||||
return organizations
|
||||
? buildOrganizationConfigOption(organizations)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to restore authentication from persisted provider settings.
|
||||
*
|
||||
@@ -467,13 +668,17 @@ export class AcpAgent implements Agent {
|
||||
* Lazily create and start the session manager for this ACP session.
|
||||
* After the first call the manager persists across prompt() calls so that
|
||||
* conversation history is maintained.
|
||||
*
|
||||
* With `resume: true` the persisted conversation for `acpSessionId` is read
|
||||
* back through the session manager.
|
||||
*/
|
||||
private async ensureSessionManager(
|
||||
session: SessionState,
|
||||
acpSessionId: string,
|
||||
): Promise<void> {
|
||||
options?: { resume?: boolean },
|
||||
): Promise<Message[] | undefined> {
|
||||
if (session.sessionManager) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = await this.buildConfig(session);
|
||||
@@ -482,31 +687,61 @@ export class AcpAgent implements Agent {
|
||||
toolPolicies: config.toolPolicies,
|
||||
capabilities: {
|
||||
requestToolApproval: (request) =>
|
||||
requestAcpToolApproval(this.conn, acpSessionId, request),
|
||||
session.autoApproveTools
|
||||
? Promise.resolve({ approved: true })
|
||||
: requestAcpToolApproval(this.conn, acpSessionId, request),
|
||||
},
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
});
|
||||
|
||||
let initialMessages: Message[] | undefined;
|
||||
if (options?.resume) {
|
||||
initialMessages = await sessionManager
|
||||
.readMessages(acpSessionId)
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!initialMessages || initialMessages.length === 0) {
|
||||
await sessionManager
|
||||
.dispose("acp_load_session_not_found")
|
||||
.catch(() => {});
|
||||
throw RequestError.resourceNotFound(acpSessionId);
|
||||
}
|
||||
} else {
|
||||
initialMessages = session.pendingInitialMessages;
|
||||
session.pendingInitialMessages = undefined;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
const initialMessages = session.pendingInitialMessages;
|
||||
session.pendingInitialMessages = undefined;
|
||||
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
config,
|
||||
// Persist the core session under the ACP session id so that
|
||||
// session/load can find the conversation by the id the client holds.
|
||||
config: {
|
||||
...config,
|
||||
modelId: session.currentModelId,
|
||||
sessionId: acpSessionId,
|
||||
},
|
||||
interactive: true,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
session.sessionManager = sessionManager;
|
||||
session.activeSessionId = started.sessionId;
|
||||
return initialMessages;
|
||||
}
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
@@ -560,6 +795,48 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDefaultModelId(
|
||||
providerId: string,
|
||||
preferredModelId: string | undefined,
|
||||
providerModels: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const modelIds = Object.keys(providerModels);
|
||||
const preferred = preferredModelId?.trim();
|
||||
if (preferred && modelIds.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
const providerDefault = (await Llms.getProvider(providerId))?.defaultModelId;
|
||||
if (providerDefault && modelIds.includes(providerDefault)) {
|
||||
return providerDefault;
|
||||
}
|
||||
return modelIds[0] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
const message = getAcpOrgSubscriptionMessage();
|
||||
return RequestError.internalError({ message }, message);
|
||||
}
|
||||
|
||||
const message = describeAgentError(error);
|
||||
const isAuthProblem = isLikelyAuthError(error);
|
||||
return isAuthProblem
|
||||
? RequestError.authRequired({ message }, message)
|
||||
: RequestError.internalError({ message }, message);
|
||||
}
|
||||
|
||||
async function buildProviderConfigOption(
|
||||
currentProviderId: string,
|
||||
): Promise<SessionConfigOption> {
|
||||
@@ -636,6 +913,7 @@ async function buildAllConfigOptions(
|
||||
providerOption,
|
||||
buildModelConfigOption(session.currentModelId, providerModels),
|
||||
buildModeConfigOption(session.currentMode),
|
||||
buildAutoApproveConfigOption(session.autoApproveTools),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@ import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
* Supported ACP OAuth provider IDs.
|
||||
*
|
||||
* This list doubles as the set of selectable providers (see
|
||||
* `setSessionConfigOption`)
|
||||
*/
|
||||
export const ACP_AUTH_METHODS = [
|
||||
{ id: "cline", name: "Sign in with Cline" },
|
||||
{ id: "cline-pass", name: "Sign in with ClinePass" },
|
||||
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
|
||||
] as const;
|
||||
|
||||
@@ -30,7 +34,7 @@ async function performOAuthLogin(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
[import("@cline/core"), import("../utils/open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTO_APPROVE_CONFIG_ID,
|
||||
buildAutoApproveConfigOption,
|
||||
parseAutoApproveValue,
|
||||
} from "./auto-approve";
|
||||
|
||||
describe("buildAutoApproveConfigOption", () => {
|
||||
it("builds a boolean config option reflecting the current value", () => {
|
||||
const option = buildAutoApproveConfigOption(true);
|
||||
|
||||
expect(option).toMatchObject({
|
||||
type: "boolean",
|
||||
id: AUTO_APPROVE_CONFIG_ID,
|
||||
currentValue: true,
|
||||
});
|
||||
expect(option.name).toBeTruthy();
|
||||
});
|
||||
|
||||
it("defaults to disabled when the session has it off", () => {
|
||||
const option = buildAutoApproveConfigOption(false);
|
||||
|
||||
expect(option).toMatchObject({ type: "boolean", currentValue: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAutoApproveValue", () => {
|
||||
it("accepts booleans", () => {
|
||||
expect(parseAutoApproveValue(true)).toBe(true);
|
||||
expect(parseAutoApproveValue(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the string forms sent by older clients", () => {
|
||||
expect(parseAutoApproveValue("true")).toBe(true);
|
||||
expect(parseAutoApproveValue("false")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed for unrecognized values", () => {
|
||||
expect(parseAutoApproveValue("yes")).toBeUndefined();
|
||||
expect(parseAutoApproveValue(1)).toBeUndefined();
|
||||
expect(parseAutoApproveValue(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no value was provided", () => {
|
||||
expect(parseAutoApproveValue(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
|
||||
export const AUTO_APPROVE_CONFIG_ID = "auto_approve";
|
||||
|
||||
export function buildAutoApproveConfigOption(
|
||||
currentValue: boolean,
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "boolean",
|
||||
id: AUTO_APPROVE_CONFIG_ID,
|
||||
name: "Auto-approve tools",
|
||||
description:
|
||||
"Automatically approve all tool calls without asking for permission",
|
||||
currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret the value of a `session/set_config_option` request for the
|
||||
* auto-approve option.
|
||||
*
|
||||
* The ACP schema sends booleans for boolean options, but clients that predate
|
||||
* boolean options may send the string form, so both are accepted. Returns
|
||||
* `undefined` for anything else so the caller can reject the request.
|
||||
*/
|
||||
export function parseAutoApproveValue(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean" || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value === "true" ? true : value === "false" ? false : undefined;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
export async function runAcpMode(): Promise<void> {
|
||||
export interface AcpModeOptions {
|
||||
autoApproveTools?: boolean;
|
||||
}
|
||||
|
||||
export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
|
||||
const { AgentSideConnection, ndJsonStream } = await import(
|
||||
"@agentclientprotocol/sdk"
|
||||
);
|
||||
@@ -15,7 +19,9 @@ export async function runAcpMode(): Promise<void> {
|
||||
);
|
||||
|
||||
const connection = new AgentSideConnection((conn) => {
|
||||
return new AcpAgent(conn);
|
||||
return new AcpAgent(conn, {
|
||||
autoApproveTools: options?.autoApproveTools,
|
||||
});
|
||||
}, stream);
|
||||
|
||||
// Keep the process alive until the connection closes
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOrganizationConfigOption,
|
||||
PERSONAL_ACCOUNT_VALUE,
|
||||
} from "./organizations";
|
||||
|
||||
describe("buildOrganizationConfigOption", () => {
|
||||
const organizations = [
|
||||
{
|
||||
active: false,
|
||||
memberId: "m-1",
|
||||
name: "Acme Corp",
|
||||
organizationId: "org-1",
|
||||
roles: ["member" as const],
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
memberId: "m-2",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-2",
|
||||
roles: ["admin" as const],
|
||||
},
|
||||
];
|
||||
|
||||
it("lists Personal first plus every organization", () => {
|
||||
const option = buildOrganizationConfigOption({
|
||||
organizations,
|
||||
activeOrganizationId: "org-2",
|
||||
});
|
||||
expect(option.id).toBe("organization");
|
||||
if (option.type !== "select") {
|
||||
throw new Error(`expected a select option, got ${option.type}`);
|
||||
}
|
||||
expect(option.currentValue).toBe("org-2");
|
||||
expect(option.options).toEqual([
|
||||
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
|
||||
{ value: "org-1", name: "Acme Corp" },
|
||||
{ value: "org-2", name: "Cline Bot Inc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects Personal when no organization is active", () => {
|
||||
const option = buildOrganizationConfigOption({
|
||||
organizations,
|
||||
activeOrganizationId: null,
|
||||
});
|
||||
expect(option.currentValue).toBe(PERSONAL_ACCOUNT_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
type ClineAccountOrganization,
|
||||
ClineAccountService,
|
||||
getPersistedProviderApiKey,
|
||||
type ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export const PERSONAL_ACCOUNT_VALUE = "personal";
|
||||
|
||||
export const ORGANIZATION_CONFIG_ID = "organization";
|
||||
|
||||
export function usesClineAccount(providerId: string): boolean {
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
export interface AcpOrganizationState {
|
||||
organizations: ClineAccountOrganization[];
|
||||
/** Active organization id, or null when the personal account is active. */
|
||||
activeOrganizationId: string | null;
|
||||
}
|
||||
|
||||
interface ClineAccountInput {
|
||||
apiKey: string;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}
|
||||
|
||||
// Cline access tokens expire between runs, so account requests resolve
|
||||
// through the refresh-aware OAuth manager. A single shared instance keeps
|
||||
// refreshes single-flight; the refresh token is single-use, so parallel
|
||||
// refreshes would invalidate each other.
|
||||
let oauthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function createAccountService(input: ClineAccountInput): ClineAccountService {
|
||||
const { providerSettingsManager } = input;
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
return new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => {
|
||||
try {
|
||||
oauthTokenManager ??= new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager,
|
||||
});
|
||||
const resolution = await oauthTokenManager.resolveProviderApiKey({
|
||||
providerId: "cline",
|
||||
});
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the persisted token; the account request surfaces
|
||||
// the auth failure to the caller.
|
||||
}
|
||||
return (
|
||||
getPersistedProviderApiKey(
|
||||
"cline",
|
||||
providerSettingsManager.getProviderSettings("cline"),
|
||||
) ||
|
||||
input.apiKey ||
|
||||
undefined
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClineOrganizations(
|
||||
input: ClineAccountInput,
|
||||
): Promise<AcpOrganizationState | undefined> {
|
||||
try {
|
||||
const service = createAccountService(input);
|
||||
const organizations = await service.fetchUserOrganizations();
|
||||
if (organizations.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
organizations,
|
||||
activeOrganizationId:
|
||||
organizations.find((org) => org.active)?.organizationId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrganizationConfigOption(
|
||||
state: AcpOrganizationState,
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: ORGANIZATION_CONFIG_ID,
|
||||
name: "Account",
|
||||
description:
|
||||
"The Cline account usage is billed to — your personal account or an organization",
|
||||
category: "account",
|
||||
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
|
||||
options: [
|
||||
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
|
||||
...state.organizations.map((org) => ({
|
||||
value: org.organizationId,
|
||||
name: org.name,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function switchClineOrganization(
|
||||
input: ClineAccountInput & { organizationId: string | null },
|
||||
): Promise<void> {
|
||||
const service = createAccountService(input);
|
||||
await service.switchAccount(input.organizationId);
|
||||
await persistActiveOrganization(input.providerSettingsManager, service);
|
||||
}
|
||||
|
||||
// Re-persist the active organization so headless runs and the hub daemon
|
||||
// attribute telemetry to the right account. Best-effort: the switch itself
|
||||
// already succeeded server-side.
|
||||
async function persistActiveOrganization(
|
||||
manager: ProviderSettingsManager,
|
||||
service: ClineAccountService,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const organizations = await service.fetchUserOrganizations();
|
||||
const active = organizations.find((org) => org.active) ?? null;
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
organizationId: active?.organizationId,
|
||||
organizationName: active?.name,
|
||||
memberId: active?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Ignore; see above.
|
||||
}
|
||||
}
|
||||
|
||||
export function getAcpOrgSubscriptionMessage(): string {
|
||||
return [
|
||||
"Organization accounts cannot use ClinePass subscriptions.",
|
||||
'Switch the "Account" session option to Personal to keep using ClinePass,',
|
||||
'or switch the "Provider" option to Cline to bill your organization.',
|
||||
].join(" ");
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
import {
|
||||
replaySessionHistory,
|
||||
translateHistoricalMessage,
|
||||
} from "./session-load";
|
||||
|
||||
describe("translateHistoricalMessage", () => {
|
||||
it("maps string content to a message chunk for the right role", () => {
|
||||
expect(translateHistoricalMessage({ role: "user", content: "hi" })).toEqual(
|
||||
[
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "hi" },
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
translateHistoricalMessage({ role: "assistant", content: "hello" }),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips the <user_input> wrapper from replayed user text", () => {
|
||||
// Persisted user messages keep their runtime-generated wrapper. Replaying
|
||||
// it verbatim leaked markup to the client, which rendered the unknown
|
||||
// element as bare text (a one-word prompt showed up as just its content
|
||||
// with the wrapper swallowed).
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: '<user_input mode="act">s</user_input>',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "s" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "lets do it" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips mode notices and formats slash commands for display", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content:
|
||||
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "are you okay?" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content:
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect rpc startup</user_command>',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "/team inspect rpc startup" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not replay the synthetic act-mode continuation prompt", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves assistant text untouched", () => {
|
||||
// Only user text carries the wrapper; agent output must replay verbatim.
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: 'Use <user_input mode="act"> to wrap prompts.',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: 'Use <user_input mode="act"> to wrap prompts.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips empty text and unknown blocks", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "redacted_thinking", data: "xxx" },
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps thinking blocks to agent_thought_chunk", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "pondering" }],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "pondering" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps tool_use to a pending tool_call", () => {
|
||||
const updates = translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call-1",
|
||||
name: "read_files",
|
||||
input: { file_paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-1",
|
||||
kind: "read",
|
||||
status: "pending",
|
||||
rawInput: { file_paths: ["a.ts"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps tool_result to a tool_call_update with flattened output", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call-1",
|
||||
name: "read_files",
|
||||
content: [
|
||||
{ type: "text", text: "line one" },
|
||||
{ type: "image", data: "abc", mediaType: "image/png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-1",
|
||||
status: "completed",
|
||||
rawOutput: "line one\n[image]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks errored tool results as failed", () => {
|
||||
const [update] = translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call-2",
|
||||
name: "run_commands",
|
||||
content: "boom",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-2",
|
||||
status: "failed",
|
||||
rawOutput: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps image blocks to image content chunks", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "user",
|
||||
content: [{ type: "image", data: "abc", mediaType: "image/png" }],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "image", data: "abc", mimeType: "image/png" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaySessionHistory", () => {
|
||||
it("sends one awaited notification per update, in order", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const conn = {
|
||||
sessionUpdate: vi.fn(async (notification: unknown) => {
|
||||
sent.push(notification);
|
||||
}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
await replaySessionHistory(conn, "sess-1", [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionId: "sess-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "question" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "sess-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "answer" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
import { buildToolTitle, mapToolKind } from "./tool-utils";
|
||||
|
||||
/**
|
||||
* The act-mode continuation prompt is runtime-generated, not typed by the
|
||||
* user, so it must not replay as a user turn. Mirrors the TUI transcript
|
||||
* hydration filter in tui/utils/hydrate-messages.ts.
|
||||
*/
|
||||
function isSyntheticUserText(text: string): boolean {
|
||||
return text === ACT_MODE_CONTINUATION_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a persisted conversation to the client as session/update
|
||||
* notifications. Used by `session/load` — the ACP spec requires the entire
|
||||
* conversation to be replayed before the load request resolves, so each
|
||||
* notification is awaited.
|
||||
*/
|
||||
export async function replaySessionHistory(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
for (const update of translateHistoricalMessage(message)) {
|
||||
await conn.sessionUpdate({ sessionId, update });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
|
||||
const blocks: ContentBlock[] =
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text", text: message.content }]
|
||||
: message.content;
|
||||
|
||||
const updates: SessionUpdate[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
if (!block.text) break;
|
||||
if (message.role !== "user") {
|
||||
updates.push({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: block.text },
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Display boundary: persisted user text keeps its runtime-generated
|
||||
// <user_input mode="..."> wrapper and <mode_notice> elements (they are
|
||||
// the durable record of the mode each turn was sent in). Replaying them
|
||||
// verbatim leaks markup to the client, which renders the unknown
|
||||
// element as bare text — so `s` shows up as `s` with the wrapper
|
||||
// swallowed. Strip them the same way every other surface does.
|
||||
const text = formatDisplayUserInput(block.text);
|
||||
if (!text || isSyntheticUserText(text)) break;
|
||||
updates.push({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "thinking": {
|
||||
if (!block.thinking) break;
|
||||
updates.push({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: block.thinking },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
const content = {
|
||||
type: "image" as const,
|
||||
data: block.data,
|
||||
mimeType: block.mediaType,
|
||||
};
|
||||
updates.push(
|
||||
message.role === "user"
|
||||
? { sessionUpdate: "user_message_chunk", content }
|
||||
: { sessionUpdate: "agent_message_chunk", content },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_use": {
|
||||
updates.push({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: block.id,
|
||||
title: buildToolTitle(block.name, block.input),
|
||||
kind: mapToolKind(block.name),
|
||||
status: "pending",
|
||||
rawInput: block.input,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool_result": {
|
||||
updates.push({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: block.tool_use_id,
|
||||
status: block.is_error ? "failed" : "completed",
|
||||
rawOutput: flattenToolResultContent(block.content),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
function flattenToolResultContent(
|
||||
content: ToolResultContent["content"],
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.map((part) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return part.text;
|
||||
case "file":
|
||||
return part.content;
|
||||
default:
|
||||
return "[image]";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getErrorMessage } from "@cline/shared";
|
||||
import { buildToolTitle, mapToolKind } from "./tool-utils";
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,11 @@ 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[] {
|
||||
|
||||
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
"seed history session",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof-of-concept: driving the interactive TUI with tuistory
|
||||
// (https://github.com/remorses/tuistory) instead of `script` + timed printf.
|
||||
//
|
||||
// Compare with `cli.interactive.e2e.test.ts`, which pipes keystrokes through
|
||||
// the Unix `script` utility on a fixed sleep schedule and greps the raw
|
||||
// output dump. Here each test launches the CLI in a real PTY backed by a
|
||||
// Ghostty terminal emulator, waits reactively for screen content
|
||||
// (`waitForText` resolves as soon as the text renders), and asserts against
|
||||
// the emulated screen state rather than the raw byte stream.
|
||||
//
|
||||
// Run with: bun run test:e2e:tuistory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { launchTerminal, type Session } from "tuistory";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const cliRoot = path.resolve(__dirname, "..");
|
||||
const cliEntry = path.join(cliRoot, "src", "index.ts");
|
||||
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
|
||||
|
||||
const LAUNCH_TIMEOUT_MS = 30_000;
|
||||
const UI_TIMEOUT_MS = 15_000;
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const sessions: Session[] = [];
|
||||
|
||||
function createCliEnv(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): Record<string, string | undefined> {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-data-"));
|
||||
const sessionDir = mkdtempSync(
|
||||
path.join(os.tmpdir(), "cli-tuistory-sessions-"),
|
||||
);
|
||||
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-teams-"));
|
||||
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
|
||||
|
||||
return {
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
CLINE_TELEMETRY_DISABLED: "1",
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
// Without this, the ClinePass promo dialog renders over the chat view.
|
||||
// The stream-grepping interactive suite doesn't notice the overlay, but
|
||||
// tuistory's screen snapshot reflects what the user actually sees.
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
// The parent vitest process sets CI/VITEST; clear them so the spawned
|
||||
// CLI renders as a real interactive terminal.
|
||||
CI: undefined,
|
||||
VITEST: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function launchCli(
|
||||
extraArgs: string[] = [],
|
||||
env: Record<string, string | undefined> = createCliEnv(),
|
||||
): Promise<Session> {
|
||||
const session = await launchTerminal({
|
||||
command: bunExec,
|
||||
args: [
|
||||
cliEntry,
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
...extraArgs,
|
||||
],
|
||||
cwd: cliRoot,
|
||||
env,
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
// The CLI compiles a large TS graph on cold start; don't gate launch
|
||||
// on the default 5s first-data timeout.
|
||||
waitForDataTimeout: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Wait for the chat view to be fully rendered. */
|
||||
async function waitForChatView(session: Session): Promise<void> {
|
||||
await session.waitForText("What can I do for you?", {
|
||||
timeout: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
describe("cli tuistory e2e", () => {
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) {
|
||||
try {
|
||||
// Double Ctrl+C exits the TUI cleanly (first press shows the
|
||||
// "press again to exit" hint) before the PTY is torn down.
|
||||
await session.press(["ctrl", "c"]);
|
||||
await session.press(["ctrl", "c"]);
|
||||
await session.waitIdle({ timeout: 3_000 });
|
||||
} catch {
|
||||
// Session may already be dead; close() below still cleans up.
|
||||
}
|
||||
session.close();
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the interactive chat view on launch", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
|
||||
const screen = await session.text({ trimEnd: true });
|
||||
expect(screen).toContain("What can I do for you?");
|
||||
expect(screen).toContain("○ Plan ● Act (Tab)");
|
||||
expect(screen).toContain("Auto-approve all enabled (Shift+Tab)");
|
||||
});
|
||||
|
||||
it("toggles plan/act mode with Tab", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
expect(await session.text()).toContain("○ Plan ● Act (Tab)");
|
||||
|
||||
await session.press("tab");
|
||||
// Reactive wait: resolves as soon as the toggled indicator renders.
|
||||
await session.waitForText("● Plan ○ Act (Tab)", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Unlike stream-grepping, the emulated screen reflects current state:
|
||||
// the old indicator is gone, not just buried in scrollback.
|
||||
const screen = await session.text();
|
||||
expect(screen).toContain("● Plan ○ Act (Tab)");
|
||||
expect(screen).not.toContain("○ Plan ● Act (Tab)");
|
||||
});
|
||||
|
||||
it("toggles auto-approve-all with Shift+Tab", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
expect(await session.text()).toContain(
|
||||
"Auto-approve all enabled (Shift+Tab)",
|
||||
);
|
||||
|
||||
await session.press(["shift", "tab"]);
|
||||
await session.waitForText("Auto-approve all disabled (Shift+Tab)", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const screen = await session.text();
|
||||
expect(screen).not.toContain("Auto-approve all enabled (Shift+Tab)");
|
||||
});
|
||||
|
||||
it("opens /settings, navigates tabs, and closes with Escape", async () => {
|
||||
const session = await launchCli();
|
||||
await waitForChatView(session);
|
||||
|
||||
await session.type("/settings");
|
||||
// Slash menu completion for the settings command.
|
||||
await session.waitForText("Modify agent configuration", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
// A single Enter accepts the highlighted completion and submits it.
|
||||
// (The `script`-based suite pressed Enter twice with 250ms sleeps; with
|
||||
// reactive key delivery the second Enter would leak into the settings
|
||||
// view and activate the focused row.)
|
||||
await session.press("enter");
|
||||
await session.waitForText("←/→ switch tabs", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const settingsScreen = await session.text();
|
||||
expect(settingsScreen).toContain("Settings");
|
||||
expect(settingsScreen).toContain("▸ Provider");
|
||||
|
||||
// Switch from the General tab to the MCP tab; the body swaps from the
|
||||
// provider/model rows to MCP content.
|
||||
await session.press("right");
|
||||
await session.text({
|
||||
waitFor: (text) => !text.includes("Compaction"),
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await session.press("escape");
|
||||
await session.waitForText("Use / for slash commands", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
expect(await session.text()).not.toContain("←/→ switch tabs");
|
||||
});
|
||||
|
||||
it("launches config view directly with `cline config`", async () => {
|
||||
const session = await launchCli(["config"]);
|
||||
await session.waitForText("←/→ switch tabs", {
|
||||
timeout: LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
const screen = await session.text();
|
||||
expect(screen).toContain("Settings");
|
||||
expect(screen).toContain("▸ Provider");
|
||||
});
|
||||
|
||||
it("dismisses the ClinePass promo with any key and marks it as shown", async () => {
|
||||
// Re-enable the promo dialog that the shared env suppresses.
|
||||
const env = createCliEnv({ CLINE_DISABLE_CLINE_PASS_NOTICE: undefined });
|
||||
const dataDir = env.CLINE_DATA_DIR as string;
|
||||
const session = await launchCli([], env);
|
||||
|
||||
await session.waitForText("Try ClinePass", { timeout: LAUNCH_TIMEOUT_MS });
|
||||
await session.waitForText("Press Enter to open, any other key to close", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Any key other than Enter dismisses the dialog (Esc is unreliable in
|
||||
// some terminals, notably on Windows).
|
||||
await session.type("x");
|
||||
await session.text({
|
||||
waitFor: (text) => !text.includes("Try ClinePass"),
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const screen = await session.text();
|
||||
expect(screen).toContain("What can I do for you?");
|
||||
expect(screen).not.toContain("Open ClinePass");
|
||||
|
||||
// The "shown" marker is persisted once the dialog is dismissed so the
|
||||
// promo doesn't reappear on the next launch.
|
||||
const markerPath = path.join(dataDir, "settings", "cli-notices.json");
|
||||
await session.waitIdle({ timeout: UI_TIMEOUT_MS });
|
||||
expect(existsSync(markerPath)).toBe(true);
|
||||
expect(readFileSync(markerPath, "utf8")).toContain(
|
||||
'"cline-cli-cline-pass-intro": true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import open from "../utils/open";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
@@ -270,7 +271,7 @@ async function runPluginsConfigCommand(
|
||||
continue;
|
||||
}
|
||||
pluginsByPath.set(filePath, {
|
||||
name: basename(filePath, extname(filePath)),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectIo } from "../connectors/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureDetachedHubServer: vi.fn(),
|
||||
readHubDiscovery: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
command: vi.fn(),
|
||||
close: vi.fn(),
|
||||
clientOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mocks.ensureDetachedHubServer,
|
||||
readHubDiscovery: mocks.readHubDiscovery,
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/production.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/owner.json",
|
||||
}),
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
mocks.clientOptions(options);
|
||||
}
|
||||
connect = mocks.connect;
|
||||
command = mocks.command;
|
||||
close = mocks.close;
|
||||
},
|
||||
}));
|
||||
|
||||
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
|
||||
|
||||
describe("startConnectorViaHub", () => {
|
||||
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ensureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: ["session.create", "connector.start"],
|
||||
});
|
||||
mocks.connect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function startRequest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
args: ["--bot-token", "xoxb"],
|
||||
io,
|
||||
cwd: "/workspace",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("hands the start to the hub and reports supervision", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: {
|
||||
started: true,
|
||||
record: { pid: 4242, state: "running" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.start", {
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
args: ["--bot-token", "xoxb"],
|
||||
restart: false,
|
||||
});
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("started under hub supervision pid=4242"),
|
||||
);
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a restart through", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: { started: true, record: { state: "running" } },
|
||||
});
|
||||
|
||||
await startConnectorViaHub(startRequest({ restart: true }));
|
||||
|
||||
expect(mocks.command).toHaveBeenCalledWith(
|
||||
"connector.start",
|
||||
expect.objectContaining({ restart: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an already-running instance as success", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: {
|
||||
started: false,
|
||||
reason: "already_running",
|
||||
record: { pid: 99, state: "running" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("already running under the hub"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when the hub cannot be reached", async () => {
|
||||
mocks.ensureDetachedHubServer.mockRejectedValue(new Error("EADDRINUSE"));
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome.delegated).toBe(false);
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when a running hub predates connector supervision", async () => {
|
||||
// The normal state of a long-lived host mid-upgrade: a new CLI, an old hub.
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: ["session.create"],
|
||||
});
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome).toEqual({
|
||||
delegated: false,
|
||||
reason: "hub does not support connector supervision",
|
||||
});
|
||||
expect(mocks.command).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when the hub reports supervision unavailable", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "connector_command_failed",
|
||||
message: "connector supervision is unavailable in this hub",
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome.delegated).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back when the hub command throws", async () => {
|
||||
mocks.command.mockRejectedValue(new Error("socket closed"));
|
||||
|
||||
const outcome = await startConnectorViaHub(startRequest());
|
||||
|
||||
expect(outcome.delegated).toBe(false);
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a genuine start refusal instead of starting locally", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "connector_command_failed",
|
||||
message: "instanceId is required",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 1,
|
||||
});
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hub refused to start slack"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a hub that accepted the command but did not start anything", async () => {
|
||||
mocks.command.mockResolvedValue({
|
||||
version: "v1",
|
||||
ok: true,
|
||||
payload: {
|
||||
started: false,
|
||||
record: { state: "failed", lastError: "bad token" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
|
||||
delegated: true,
|
||||
exitCode: 1,
|
||||
});
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bad token"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopConnectorsViaHub", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: [
|
||||
"connector.start",
|
||||
"connector.stop",
|
||||
"connector.supervised",
|
||||
],
|
||||
});
|
||||
mocks.connect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("retires every supervised instance of a channel", async () => {
|
||||
mocks.command.mockImplementation(async (command: string) => {
|
||||
if (command === "connector.supervised") {
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
supervised: [
|
||||
{ channel: "slack", instanceId: "a" },
|
||||
{ channel: "slack", instanceId: "b" },
|
||||
{ channel: "telegram", instanceId: "c" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, payload: { stopped: true } };
|
||||
});
|
||||
|
||||
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(2);
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "slack",
|
||||
instanceId: "a",
|
||||
});
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "slack",
|
||||
instanceId: "b",
|
||||
});
|
||||
// A different channel is left alone.
|
||||
expect(mocks.command).not.toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "telegram",
|
||||
instanceId: "c",
|
||||
});
|
||||
});
|
||||
|
||||
it("retires only the requested instance", async () => {
|
||||
mocks.command.mockImplementation(async (command: string) => {
|
||||
if (command === "connector.supervised") {
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
supervised: [
|
||||
{ channel: "slack", instanceId: "a" },
|
||||
{ channel: "slack", instanceId: "b" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, payload: { stopped: true } };
|
||||
});
|
||||
|
||||
await expect(
|
||||
stopConnectorsViaHub({ channel: "slack", instanceId: "b" }),
|
||||
).resolves.toBe(1);
|
||||
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
|
||||
channel: "slack",
|
||||
instanceId: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nothing to stop when the hub supervises none of them", async () => {
|
||||
mocks.command.mockResolvedValue({ ok: true, payload: { supervised: [] } });
|
||||
|
||||
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("returns undefined when the hub cannot supervise", async () => {
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
capabilities: ["session.create"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
stopConnectorsViaHub({ channel: "slack" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
ensureDetachedHubServer,
|
||||
NodeHubClient,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type ConnectorStartResult,
|
||||
resolveClineBuildEnv,
|
||||
type SupervisedConnectorRecord,
|
||||
} from "@cline/shared";
|
||||
import type { ConnectIo } from "../connectors/types";
|
||||
|
||||
/**
|
||||
* Error codes that mean "this hub cannot supervise connectors", as opposed to
|
||||
* "the start failed". Both are answers from the hub, but only the former should
|
||||
* send the caller back to starting the connector itself.
|
||||
*/
|
||||
const UNSUPPORTED_ERROR_CODES = new Set([
|
||||
"unsupported_command",
|
||||
"unsupported_connector_command",
|
||||
]);
|
||||
const UNSUPPORTED_MESSAGE_FRAGMENT = "connector supervision is unavailable";
|
||||
|
||||
export type HubDelegationOutcome =
|
||||
/** The hub owns the connector now; `exitCode` is the command's result. */
|
||||
| { delegated: true; exitCode: number }
|
||||
/** Nothing was started; the caller should start the connector locally. */
|
||||
| { delegated: false; reason: string };
|
||||
|
||||
function resolveHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the hub at `url` advertises connector supervision.
|
||||
*
|
||||
* A newer CLI regularly talks to an older running hub — that is the normal state
|
||||
* of a long-lived host mid-upgrade — and such a hub would reject
|
||||
* `connector.start` outright. Checking the advertised capability first keeps that
|
||||
* case on the local path instead of turning it into a failed start.
|
||||
*/
|
||||
async function hubSupportsSupervision(): Promise<boolean> {
|
||||
try {
|
||||
const owner = resolveHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
return record?.capabilities?.includes("connector.start") === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeRecord(record: SupervisedConnectorRecord | undefined): string {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
const details = [
|
||||
record.pid === undefined ? undefined : `pid=${record.pid}`,
|
||||
`state=${record.state}`,
|
||||
].filter(Boolean);
|
||||
return details.length > 0 ? ` ${details.join(" ")}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running hub is supervising, or undefined when it cannot say.
|
||||
*
|
||||
* Deliberately does not start a hub: this exists for diagnostics, and `cline
|
||||
* doctor` reporting on the system must never change it.
|
||||
*/
|
||||
export async function listSupervisedConnectorsViaHub(): Promise<
|
||||
SupervisedConnectorRecord[] | undefined
|
||||
> {
|
||||
let url: string;
|
||||
let authToken: string | undefined;
|
||||
try {
|
||||
const owner = resolveHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (
|
||||
!record?.url ||
|
||||
!record.capabilities?.includes("connector.supervised")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
url = record.url;
|
||||
authToken = record.authToken;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
...(authToken ? { authToken } : {}),
|
||||
clientType: "cli-doctor",
|
||||
displayName: "doctor",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const reply = await client.command("connector.supervised");
|
||||
if (!reply.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const supervised = (reply.payload as { supervised?: unknown })?.supervised;
|
||||
return Array.isArray(supervised)
|
||||
? (supervised as SupervisedConnectorRecord[])
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// One-shot connection; a failed close changes nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the hub to stop supervising a channel's connectors, or one instance of it.
|
||||
*
|
||||
* Returns how many the hub stopped, or undefined when it cannot supervise. The
|
||||
* local stop path alone is not enough: it finds processes through their state
|
||||
* files, so a connector that has not written one yet — still starting, or failing
|
||||
* to start — would keep running under the hub and be restarted.
|
||||
*/
|
||||
export async function stopConnectorsViaHub(input: {
|
||||
channel: string;
|
||||
instanceId?: string;
|
||||
}): Promise<number | undefined> {
|
||||
const supervised = await listSupervisedConnectorsViaHub();
|
||||
if (!supervised) {
|
||||
return undefined;
|
||||
}
|
||||
const targets = supervised.filter(
|
||||
(record) =>
|
||||
record.channel === input.channel &&
|
||||
(input.instanceId === undefined ||
|
||||
record.instanceId === input.instanceId),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let url: string;
|
||||
let authToken: string | undefined;
|
||||
try {
|
||||
const owner = resolveHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!record?.url) {
|
||||
return undefined;
|
||||
}
|
||||
url = record.url;
|
||||
authToken = record.authToken;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
...(authToken ? { authToken } : {}),
|
||||
clientType: "cli-connect",
|
||||
displayName: `stop ${input.channel}`,
|
||||
});
|
||||
let stopped = 0;
|
||||
try {
|
||||
await client.connect();
|
||||
for (const target of targets) {
|
||||
const reply = await client.command("connector.stop", {
|
||||
channel: target.channel,
|
||||
instanceId: target.instanceId,
|
||||
});
|
||||
if (reply.ok) {
|
||||
stopped += 1;
|
||||
}
|
||||
}
|
||||
return stopped;
|
||||
} catch {
|
||||
return stopped > 0 ? stopped : undefined;
|
||||
} finally {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// One-shot connection; a failed close changes nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the hub to start and own a connector.
|
||||
*
|
||||
* The hub spawning the connector — rather than the connector spawning itself and
|
||||
* then bringing up a hub — is what makes the hub the single authority on how many
|
||||
* processes hold one connector's credentials, and what lets it reap and restart
|
||||
* them when they die. Every failure mode here falls back to the local path so a
|
||||
* missing or older hub cannot stop a connector from starting.
|
||||
*/
|
||||
export async function startConnectorViaHub(input: {
|
||||
channel: string;
|
||||
instanceId: string;
|
||||
args: string[];
|
||||
restart?: boolean;
|
||||
io: ConnectIo;
|
||||
cwd?: string;
|
||||
}): Promise<HubDelegationOutcome> {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
let hub: { url: string; authToken: string };
|
||||
try {
|
||||
hub = await ensureDetachedHubServer(cwd);
|
||||
} catch (error) {
|
||||
return {
|
||||
delegated: false,
|
||||
reason: `hub unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
if (!(await hubSupportsSupervision())) {
|
||||
return {
|
||||
delegated: false,
|
||||
reason: "hub does not support connector supervision",
|
||||
};
|
||||
}
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url: hub.url,
|
||||
authToken: hub.authToken,
|
||||
clientType: "cli-connect",
|
||||
displayName: `connect ${input.channel}`,
|
||||
cwd,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const reply = await client.command("connector.start", {
|
||||
channel: input.channel,
|
||||
instanceId: input.instanceId,
|
||||
args: input.args,
|
||||
restart: input.restart === true,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
const code = reply.error?.code ?? "";
|
||||
const message = reply.error?.message ?? "connector start failed";
|
||||
if (
|
||||
UNSUPPORTED_ERROR_CODES.has(code) ||
|
||||
message.includes(UNSUPPORTED_MESSAGE_FRAGMENT)
|
||||
) {
|
||||
return { delegated: false, reason: message };
|
||||
}
|
||||
input.io.writeErr(
|
||||
`[connect] hub refused to start ${input.channel}: ${message}`,
|
||||
);
|
||||
return { delegated: true, exitCode: 1 };
|
||||
}
|
||||
const payload = reply.payload as ConnectorStartResult | undefined;
|
||||
const record = payload?.record;
|
||||
if (payload?.started === false && payload.reason === "already_running") {
|
||||
input.io.writeln(
|
||||
`[connect] ${input.channel} connector ${input.instanceId} is already running under the hub${describeRecord(record)}`,
|
||||
);
|
||||
return { delegated: true, exitCode: 0 };
|
||||
}
|
||||
if (payload?.started !== true) {
|
||||
input.io.writeErr(
|
||||
`[connect] hub could not start ${input.channel} connector ${input.instanceId}${
|
||||
record?.lastError ? `: ${record.lastError}` : ""
|
||||
}`,
|
||||
);
|
||||
return { delegated: true, exitCode: 1 };
|
||||
}
|
||||
input.io.writeln(
|
||||
`[connect] ${input.channel} connector ${input.instanceId} started under hub supervision${describeRecord(record)}`,
|
||||
);
|
||||
input.io.writeln(
|
||||
"[connect] the hub will restart it if it exits; use `cline connect --stop` to retire it",
|
||||
);
|
||||
return { delegated: true, exitCode: 0 };
|
||||
} catch (error) {
|
||||
return {
|
||||
delegated: false,
|
||||
reason: `hub command failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
client.close();
|
||||
} catch {
|
||||
// The connection is one-shot; a failed close changes nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "../connectors/common";
|
||||
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
|
||||
import {
|
||||
runCleanupConnectorInstance,
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
} from "./connect";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
startConnectorViaHub: vi.fn(),
|
||||
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
|
||||
disableConnectorAutostart: vi.fn(),
|
||||
getPersistedConnectorConnection: vi.fn(),
|
||||
getConnector: vi.fn(),
|
||||
@@ -36,6 +39,11 @@ vi.mock("../connectors/registry", () => ({
|
||||
listConnectors: mocks.listConnectors,
|
||||
}));
|
||||
|
||||
vi.mock("./connect-via-hub", () => ({
|
||||
startConnectorViaHub: mocks.startConnectorViaHub,
|
||||
stopConnectorsViaHub: mocks.stopConnectorsViaHub,
|
||||
}));
|
||||
|
||||
describe("runConnectAdapter", () => {
|
||||
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
|
||||
const io: ConnectIo = {
|
||||
@@ -492,3 +500,195 @@ describe("runConnectAdapter", () => {
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCleanupConnectorInstance", () => {
|
||||
const io: ConnectIo = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reaps one instance without disabling its autostart", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
});
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("slack", "cline-slack", io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline-slack", io);
|
||||
// The instance crashed; it was not retired. Disabling autostart here would
|
||||
// make every crash silently opt the connector out of supervision.
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed reap", async () => {
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance: vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("slack", "cline-slack", io),
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an adapter without per-instance stop", async () => {
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("slack", "cline-slack", io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
'connect adapter "slack" does not support per-instance stop',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown adapter", async () => {
|
||||
mocks.getConnector.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
runCleanupConnectorInstance("nope", "instance", io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith('unknown connect adapter "nope"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub-delegated connector starts", () => {
|
||||
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listConnectors.mockReturnValue([]);
|
||||
mocks.listActiveConnectors.mockReturnValue([]);
|
||||
mocks.validate.mockResolvedValue(0);
|
||||
mocks.run.mockResolvedValue(0);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "slack",
|
||||
description: "Slack",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
resolveInstanceId: () => "cline-slack",
|
||||
});
|
||||
mocks.startConnectorViaHub.mockResolvedValue({
|
||||
delegated: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_CONNECTOR_SUPERVISED;
|
||||
});
|
||||
|
||||
it("asks the hub to own a background connector and records the intent", async () => {
|
||||
await expect(
|
||||
runConnectAdapter("slack", ["--bot-token", "xoxb"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.startConnectorViaHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
args: ["--bot-token", "xoxb"],
|
||||
}),
|
||||
);
|
||||
// The adapter must not also run here: the hub owns the process now.
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"slack",
|
||||
"cline-slack",
|
||||
["--bot-token", "xoxb"],
|
||||
);
|
||||
});
|
||||
|
||||
it("runs locally for a foreground connector", async () => {
|
||||
await runConnectAdapter("slack", ["--bot-token", "xoxb", "-i"], io);
|
||||
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs locally inside a supervised process instead of asking the hub again", async () => {
|
||||
process.env.CLINE_CONNECTOR_SUPERVISED = "1";
|
||||
|
||||
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
|
||||
|
||||
// Delegating here would send the hub straight back to spawning this same
|
||||
// process.
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs locally when the instance id cannot be known up front", async () => {
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
resolveInstanceId: () => undefined,
|
||||
});
|
||||
|
||||
await runConnectAdapter("telegram", ["-k", "token"], io);
|
||||
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a local start when the hub declines", async () => {
|
||||
mocks.startConnectorViaHub.mockResolvedValue({
|
||||
delegated: false,
|
||||
reason: "hub does not support connector supervision",
|
||||
});
|
||||
|
||||
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
|
||||
|
||||
expect(mocks.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not validate or delegate a help invocation", async () => {
|
||||
await runConnectAdapter("slack", ["--help"], io);
|
||||
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a validation failure without contacting the hub", async () => {
|
||||
mocks.validate.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("slack", ["--bot-token", "bad"], io),
|
||||
).resolves.toBe(2);
|
||||
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
persistConnectorConnection,
|
||||
removePersistedConnectorConnection,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isSupervisedConnectorProcess,
|
||||
setStartingConnectorInstance,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
@@ -15,6 +19,7 @@ import type {
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../connectors/types";
|
||||
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
|
||||
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
|
||||
@@ -83,6 +88,20 @@ export async function runStopConnector(
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
return 1;
|
||||
}
|
||||
// Retire it with the hub first. The local stop below finds processes through
|
||||
// their state files, so a supervised connector that has not written one yet
|
||||
// would survive and be restarted.
|
||||
const stoppedByHub = await stopConnectorsViaHub({
|
||||
channel: connector.name,
|
||||
...(options.instanceId === undefined
|
||||
? {}
|
||||
: { instanceId: options.instanceId }),
|
||||
});
|
||||
if (stoppedByHub) {
|
||||
io.writeln(
|
||||
`[connect] hub stopped supervising ${stoppedByHub} ${connector.name} connector${stoppedByHub === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
const result = await stop();
|
||||
if (!result) {
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
@@ -97,6 +116,39 @@ export async function runStopConnector(
|
||||
return result.failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap one connector instance that is no longer running.
|
||||
*
|
||||
* Invoked by the hub supervisor when it observes a connector die. It clears the
|
||||
* same things a normal stop does — process state file, thread→session bindings,
|
||||
* the instance's hub sessions — but deliberately leaves the autostart record
|
||||
* intact: the instance crashed, it was not retired, so the supervisor still
|
||||
* intends to restart it. `runStopConnector` with `autostart: "disable"` would
|
||||
* make every crash silently opt the connector out of recovery.
|
||||
*/
|
||||
export async function runCleanupConnectorInstance(
|
||||
adapterName: string,
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
if (!connector.stopInstance) {
|
||||
io.writeErr(
|
||||
`connect adapter "${adapterName}" does not support per-instance stop`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const result = await connector.stopInstance(instanceId, io);
|
||||
io.writeln(
|
||||
`[connect] ${connector.name} instance=${instanceId} cleaned processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
|
||||
);
|
||||
return result.failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runRestartConnector(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
@@ -132,6 +184,21 @@ export async function runRestartConnector(
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
// The supervisor replaces an instance in one step, so let it do the whole
|
||||
// restart rather than stopping here and racing it to start the replacement.
|
||||
// Only when the target is the instance these arguments describe: a
|
||||
// `--restart-instance` pointing elsewhere is not ours to reinterpret.
|
||||
if (
|
||||
requestedInstanceId === undefined ||
|
||||
connector.resolveInstanceId?.(passthroughArgs) === requestedInstanceId
|
||||
) {
|
||||
const delegated = await tryDelegateToHub(connector, passthroughArgs, io, {
|
||||
restart: true,
|
||||
});
|
||||
if (delegated !== undefined) {
|
||||
return delegated;
|
||||
}
|
||||
}
|
||||
const previousConnection = getPersistedConnectorConnection(
|
||||
adapterName,
|
||||
instanceId,
|
||||
@@ -208,6 +275,14 @@ async function runConnectAdapterWithResult(
|
||||
},
|
||||
setPersistenceInstanceId: (instanceId) => {
|
||||
persistenceInstanceId = instanceId;
|
||||
// Adapters report their instance id before they build a Cline core, so
|
||||
// this lands in the environment before the hub daemon is spawned and
|
||||
// inherited by it. Without it the daemon's autostart pass cannot tell
|
||||
// that this instance is mid-startup and launches a second copy of it.
|
||||
setStartingConnectorInstance({
|
||||
channel: connector.name,
|
||||
instanceId,
|
||||
});
|
||||
},
|
||||
};
|
||||
const exitCode = await connector.run(passthroughArgs, io, context);
|
||||
@@ -218,8 +293,12 @@ async function runConnectAdapterWithResult(
|
||||
const isInteractiveInvocation = passthroughArgs.some((arg) =>
|
||||
INTERACTIVE_FLAGS.has(arg),
|
||||
);
|
||||
// A supervised process is the hub's own connector, not a user invocation, so
|
||||
// it makes the same autostart bookkeeping choices as a detached child: the
|
||||
// process that asked for the start already recorded the intent.
|
||||
const isDetachedChild =
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
|
||||
isSupervisedConnectorProcess();
|
||||
if (
|
||||
exitCode === 0 &&
|
||||
!isHelpInvocation &&
|
||||
@@ -242,11 +321,74 @@ async function runConnectAdapterWithResult(
|
||||
return { exitCode, instanceId: persistenceInstanceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a background connector start to the hub, when that is possible.
|
||||
*
|
||||
* Returns the exit code once the hub owns the connector, or undefined to mean
|
||||
* "start it locally instead". Delegation is skipped for foreground (`-i`) runs,
|
||||
* which are attached to the user's terminal, and for connectors the hub itself
|
||||
* launched, which would otherwise ask the hub to start them again.
|
||||
*/
|
||||
async function tryDelegateToHub(
|
||||
connector: {
|
||||
name: string;
|
||||
validate: (args: string[], io: ConnectIo) => Promise<number>;
|
||||
resolveInstanceId?: (args: string[]) => string | undefined;
|
||||
},
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
options: { restart?: boolean } = {},
|
||||
): Promise<number | undefined> {
|
||||
if (
|
||||
passthroughArgs.some((arg) => HELP_FLAGS.has(arg)) ||
|
||||
passthroughArgs.some((arg) => INTERACTIVE_FLAGS.has(arg)) ||
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
|
||||
isSupervisedConnectorProcess()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// Without an instance id the hub cannot enforce one process per connector,
|
||||
// which is the entire point of routing through it.
|
||||
const instanceId = connector.resolveInstanceId?.(passthroughArgs);
|
||||
if (!instanceId) {
|
||||
return undefined;
|
||||
}
|
||||
// Check the arguments here rather than after handing off: a bad token should
|
||||
// fail in front of the user instead of becoming a supervised crash loop.
|
||||
const validationExitCode = await connector.validate(passthroughArgs, io);
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
const outcome = await startConnectorViaHub({
|
||||
channel: connector.name,
|
||||
instanceId,
|
||||
args: passthroughArgs,
|
||||
...(options.restart === undefined ? {} : { restart: options.restart }),
|
||||
io,
|
||||
});
|
||||
if (!outcome.delegated) {
|
||||
return undefined;
|
||||
}
|
||||
if (outcome.exitCode === 0) {
|
||||
// Recorded here rather than in the hub-spawned process: this is the
|
||||
// invocation that expressed the intent to keep the connector running.
|
||||
persistConnectorConnection(connector.name, instanceId, passthroughArgs);
|
||||
}
|
||||
return outcome.exitCode;
|
||||
}
|
||||
|
||||
export async function runConnectAdapter(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (connector) {
|
||||
const delegated = await tryDelegateToHub(connector, passthroughArgs, io);
|
||||
if (delegated !== undefined) {
|
||||
return delegated;
|
||||
}
|
||||
}
|
||||
const result = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
passthroughArgs,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import open from "../utils/open";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
mockEnsureFileExists,
|
||||
mockListActiveConnectors,
|
||||
mockStopAllConnectors,
|
||||
mockListSupervisedConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
@@ -58,6 +59,7 @@ const {
|
||||
stoppedSessions: 0,
|
||||
executed: 0,
|
||||
})),
|
||||
mockListSupervisedConnectors: vi.fn(async () => undefined as unknown),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
@@ -84,7 +86,11 @@ vi.mock("./connect", () => ({
|
||||
stopAllConnectors: mockStopAllConnectors,
|
||||
}));
|
||||
|
||||
import { createDoctorCommand, runDoctorCommand } from "./doctor";
|
||||
vi.mock("./connect-via-hub", () => ({
|
||||
listSupervisedConnectorsViaHub: mockListSupervisedConnectors,
|
||||
}));
|
||||
|
||||
import { __test__, createDoctorCommand, runDoctorCommand } from "./doctor";
|
||||
|
||||
describe("runDoctorCommand", () => {
|
||||
const tempDirs: string[] = [];
|
||||
@@ -450,3 +456,171 @@ describe("createDoctorCommand log subcommand", () => {
|
||||
expect(errors[0]).toContain("open failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("container-aware process filtering", () => {
|
||||
const { decideForeignContainer, CONTAINER_CGROUP_PATTERN } = __test__;
|
||||
|
||||
it("treats a process in a different pid namespace as foreign", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [
|
||||
["pid:[4026531836]", "pid:[4026532500]"],
|
||||
[undefined, undefined],
|
||||
],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a sibling process in our own namespaces", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [
|
||||
["pid:[4026531836]", "pid:[4026531836]"],
|
||||
["mnt:[4026531840]", "mnt:[4026531840]"],
|
||||
],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to cgroup container ids when namespaces are unreadable", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [[undefined, undefined]],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
|
||||
}),
|
||||
).toBe(true);
|
||||
// Same container: our own sibling process, not something to retire.
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "linux",
|
||||
namespacePairs: [[undefined, undefined]],
|
||||
ownContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
|
||||
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never filters off Linux, where containers cannot share our pid space", () => {
|
||||
expect(
|
||||
decideForeignContainer({
|
||||
platform: "darwin",
|
||||
namespacePairs: [["pid:[1]", "pid:[2]"]],
|
||||
ownContainerId: undefined,
|
||||
otherContainerId: "abcdef123456",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts container ids from real cgroup paths", () => {
|
||||
const docker =
|
||||
"0::/system.slice/docker-7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad.scope";
|
||||
expect(docker.match(CONTAINER_CGROUP_PATTERN)?.[1]).toBe(
|
||||
"7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad",
|
||||
);
|
||||
// A plain host session must not look like a container.
|
||||
expect(
|
||||
"0::/user.slice/user-1001.slice/session-121.scope".match(
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor supervision reporting", () => {
|
||||
const { formatSupervisedConnector } = __test__;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListSupervisedConnectors.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
async function runDoctorJson(): Promise<Record<string, unknown>> {
|
||||
const output: string[] = [];
|
||||
await runDoctorCommand(
|
||||
{ cwd: "/workspace", json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
return JSON.parse(output[0] || "{}") as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("reports what the hub is supervising", async () => {
|
||||
mockListSupervisedConnectors.mockResolvedValue([
|
||||
{
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
state: "backoff",
|
||||
origin: "spawned",
|
||||
restarts: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(runDoctorJson()).resolves.toMatchObject({
|
||||
supervisedConnectors: [
|
||||
{ channel: "slack", instanceId: "cline-slack", state: "backoff" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits supervision when the hub cannot report it", async () => {
|
||||
mockListSupervisedConnectors.mockResolvedValue(undefined);
|
||||
|
||||
const status = await runDoctorJson();
|
||||
|
||||
expect(status.supervisedConnectors).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays usable when the supervision query fails", async () => {
|
||||
mockListSupervisedConnectors.mockRejectedValue(new Error("hub gone"));
|
||||
|
||||
// Diagnostics must degrade quietly rather than fail.
|
||||
const status = await runDoctorJson();
|
||||
|
||||
expect(status.supervisedConnectors).toBeUndefined();
|
||||
expect(status).toHaveProperty("hubHealthy");
|
||||
});
|
||||
|
||||
it("formats restart and failure state so a crash loop is visible", () => {
|
||||
expect(
|
||||
formatSupervisedConnector({
|
||||
channel: "slack",
|
||||
instanceId: "cline-slack",
|
||||
state: "failed",
|
||||
origin: "adopted",
|
||||
pid: 42,
|
||||
restarts: 5,
|
||||
lastExitCode: 1,
|
||||
lastError: "invalid token",
|
||||
}),
|
||||
).toBe(
|
||||
"slack | instance=cline-slack | state=failed | origin=adopted | pid=42 | restarts=5 | lastExit=1 | error=invalid token",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out fields that do not apply to a healthy connector", () => {
|
||||
expect(
|
||||
formatSupervisedConnector({
|
||||
channel: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
state: "running",
|
||||
origin: "spawned",
|
||||
pid: 7,
|
||||
restarts: 0,
|
||||
}),
|
||||
).toBe(
|
||||
"telegram | instance=cline_bot | state=running | origin=spawned | pid=7",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
@@ -16,14 +16,16 @@ import {
|
||||
type ActiveConnectorRecord,
|
||||
formatUptime,
|
||||
resolveClineBuildEnv,
|
||||
type SupervisedConnectorRecord,
|
||||
} from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import open from "../utils/open";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
import { listSupervisedConnectorsViaHub } from "./connect-via-hub";
|
||||
|
||||
type DoctorIo = {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -64,6 +66,8 @@ type DoctorStatus = {
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
activeConnectors: ActiveConnectorRecord[];
|
||||
/** Undefined when the running hub cannot report supervision. */
|
||||
supervisedConnectors?: SupervisedConnectorRecord[];
|
||||
recentSpawnedProcesses: SpawnedProcessRecord[];
|
||||
};
|
||||
|
||||
@@ -72,6 +76,13 @@ type ProcessRecord = {
|
||||
command: string;
|
||||
};
|
||||
|
||||
// Container id inside a cgroup path, e.g.
|
||||
// "0::/system.slice/docker-<64-hex>.scope" (docker/containerd/podman) or
|
||||
// "/kubepods/.../<64-hex>" (kubernetes). Captures the id so two different
|
||||
// containers can be told apart, not merely "is containerised".
|
||||
const CONTAINER_CGROUP_PATTERN =
|
||||
/(?:docker[-/]|containerd[-/]|libpod[-/]|crio[-/]|lxc[-/.])([0-9a-f]{12,64})/;
|
||||
|
||||
function parsePids(raw: string): number[] {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
@@ -79,6 +90,77 @@ function parsePids(raw: string): number[] {
|
||||
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
||||
}
|
||||
|
||||
function tryReadLink(target: string): string | undefined {
|
||||
try {
|
||||
return readlinkSync(target);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readContainerCgroupId(pid: number | "self"): string | undefined {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(`/proc/${pid}/cgroup`, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return raw.match(CONTAINER_CGROUP_PATTERN)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a process belongs to a container other than our own.
|
||||
*
|
||||
* Namespace identity is the reliable signal: a containerised process has
|
||||
* different PID/mount namespaces than the host process running the scan.
|
||||
* Container ids parsed from cgroup paths are the fallback for kernels where the
|
||||
* namespace links are unreadable. Unknown on both sides means "assume ours",
|
||||
* preserving the previous behaviour rather than silently dropping processes the
|
||||
* user does want cleaned up.
|
||||
*/
|
||||
function decideForeignContainer(input: {
|
||||
platform: string;
|
||||
namespacePairs: Array<[string | undefined, string | undefined]>;
|
||||
ownContainerId: string | undefined;
|
||||
otherContainerId: string | undefined;
|
||||
}): boolean {
|
||||
// /proc/<pid>/ns exists only on Linux. Elsewhere containers run inside a VM
|
||||
// and never share a pid space with us, so there is nothing to disambiguate.
|
||||
if (input.platform !== "linux") {
|
||||
return false;
|
||||
}
|
||||
for (const [own, other] of input.namespacePairs) {
|
||||
if (own && other && own !== other) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return (
|
||||
Boolean(input.otherContainerId) &&
|
||||
input.otherContainerId !== input.ownContainerId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pid` belongs to a container other than this process's own.
|
||||
*
|
||||
* `pgrep` sees every process on the host, containers included: a Docker agent's
|
||||
* hub daemon shows up beside ours, and when the container shares our uid `kill`
|
||||
* on it succeeds. Those daemons are emphatically not stale — they belong to a
|
||||
* live agent with its own data dir — so reporting them, and killing them in
|
||||
* `doctor fix`, takes down an unrelated agent.
|
||||
*/
|
||||
function isForeignContainerPid(pid: number): boolean {
|
||||
return decideForeignContainer({
|
||||
platform: process.platform,
|
||||
namespacePairs: (["pid", "mnt"] as const).map((namespace) => [
|
||||
tryReadLink(`/proc/self/ns/${namespace}`),
|
||||
tryReadLink(`/proc/${pid}/ns/${namespace}`),
|
||||
]),
|
||||
ownContainerId: readContainerCgroupId("self"),
|
||||
otherContainerId: readContainerCgroupId(pid),
|
||||
});
|
||||
}
|
||||
|
||||
function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
@@ -108,7 +190,8 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
pid <= 0 ||
|
||||
!command ||
|
||||
pid === process.pid ||
|
||||
pid === process.ppid
|
||||
pid === process.ppid ||
|
||||
isForeignContainerPid(pid)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -354,6 +437,7 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
activeConnectors: listActiveConnectors(),
|
||||
...((await listSupervisedConnectorsSafely()) ?? {}),
|
||||
recentSpawnedProcesses: readRecentSpawnedProcesses(),
|
||||
};
|
||||
}
|
||||
@@ -378,6 +462,39 @@ function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
|
||||
return pieces.join(" | ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervision is reported by the running hub, so it is unavailable whenever
|
||||
* there is no hub or it predates supervision. Diagnostics must degrade quietly
|
||||
* rather than fail.
|
||||
*/
|
||||
async function listSupervisedConnectorsSafely(): Promise<
|
||||
{ supervisedConnectors: SupervisedConnectorRecord[] } | undefined
|
||||
> {
|
||||
try {
|
||||
const supervised = await listSupervisedConnectorsViaHub();
|
||||
return supervised ? { supervisedConnectors: supervised } : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSupervisedConnector(record: SupervisedConnectorRecord): string {
|
||||
const pieces = [
|
||||
record.channel,
|
||||
`instance=${record.instanceId}`,
|
||||
`state=${record.state}`,
|
||||
`origin=${record.origin}`,
|
||||
record.pid === undefined ? undefined : `pid=${record.pid}`,
|
||||
record.restarts > 0 ? `restarts=${record.restarts}` : undefined,
|
||||
record.nextRestartAt ? `nextRestart=${record.nextRestartAt}` : undefined,
|
||||
record.lastExitCode === undefined
|
||||
? undefined
|
||||
: `lastExit=${record.lastExitCode}`,
|
||||
record.lastError ? `error=${record.lastError}` : undefined,
|
||||
];
|
||||
return pieces.filter(Boolean).join(" | ");
|
||||
}
|
||||
|
||||
function formatActiveConnector(record: ActiveConnectorRecord): string {
|
||||
const identity =
|
||||
record.type === "telegram"
|
||||
@@ -411,6 +528,12 @@ function killPids(pids: number[]): number {
|
||||
return killed;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
decideForeignContainer,
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
formatSupervisedConnector,
|
||||
};
|
||||
|
||||
export async function runDoctorCommand(
|
||||
opts: { cwd: string; json?: boolean; fix?: boolean; verbose?: boolean },
|
||||
io: DoctorIo,
|
||||
@@ -450,6 +573,12 @@ export async function runDoctorCommand(
|
||||
writeln(`- ${c.dim}${formatActiveConnector(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (before.supervisedConnectors?.length) {
|
||||
writeln("hub-supervised connectors:");
|
||||
for (const record of before.supervisedConnectors) {
|
||||
writeln(`- ${c.dim}${formatSupervisedConnector(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (verbose && before.recentSpawnedProcesses.length > 0) {
|
||||
writeln("recent spawned processes:");
|
||||
for (const record of before.recentSpawnedProcesses) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryDelete: vi.fn(async () => 0),
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryList: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
vi.mock("./history", () => historyMocks);
|
||||
|
||||
import { registerHistoryCommand } from "./history-command";
|
||||
|
||||
function createHarness(isInteractiveTTY: boolean) {
|
||||
const program = new Command()
|
||||
.exitOverride()
|
||||
.option("--json", "Output as JSON");
|
||||
program.configureOutput({
|
||||
writeOut: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
});
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
const setExitCode = vi.fn();
|
||||
const setStartupTarget = vi.fn();
|
||||
registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode,
|
||||
setStartupTarget,
|
||||
isInteractiveTTY: () => isInteractiveTTY,
|
||||
});
|
||||
return { program, io, setExitCode, setStartupTarget };
|
||||
}
|
||||
|
||||
describe("registerHistoryCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens the in-app history picker for an interactive text terminal", async () => {
|
||||
const { program, setExitCode, setStartupTarget } = createHarness(true);
|
||||
|
||||
await program.parseAsync(["history"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).toHaveBeenCalledOnce();
|
||||
expect(setStartupTarget).toHaveBeenCalledWith("history");
|
||||
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
||||
expect(setExitCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps explicit JSON output non-interactive even when a TTY is attached", async () => {
|
||||
const { program, io, setExitCode, setStartupTarget } = createHarness(true);
|
||||
|
||||
await program.parseAsync(["history", "--json"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).not.toHaveBeenCalled();
|
||||
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
outputMode: "json",
|
||||
io,
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("prints text history when no interactive terminal is attached", async () => {
|
||||
const { program, io, setExitCode, setStartupTarget } = createHarness(false);
|
||||
|
||||
await program.parseAsync(["history", "--limit", "12"], { from: "user" });
|
||||
|
||||
expect(setStartupTarget).not.toHaveBeenCalled();
|
||||
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
|
||||
limit: 12,
|
||||
outputMode: "text",
|
||||
io,
|
||||
});
|
||||
expect(setExitCode).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("returns an error when delete is missing --session-id", async () => {
|
||||
const { program, io, setExitCode } = createHarness(false);
|
||||
|
||||
await program.parseAsync(["history", "delete"], { from: "user" });
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"history delete requires --session-id <id>",
|
||||
);
|
||||
expect(historyMocks.runHistoryDelete).not.toHaveBeenCalled();
|
||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Command } from "commander";
|
||||
import type { TuiStartupTarget } from "../tui/types";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
import {
|
||||
runHistoryDelete,
|
||||
runHistoryExport,
|
||||
runHistoryList,
|
||||
runHistoryUpdate,
|
||||
} from "./history";
|
||||
|
||||
type HistoryCommandIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
type RegisterHistoryCommandOptions = {
|
||||
program: Command;
|
||||
io: HistoryCommandIo;
|
||||
setExitCode: (code: number) => void;
|
||||
setStartupTarget: (target: TuiStartupTarget) => void;
|
||||
isInteractiveTTY?: () => boolean;
|
||||
};
|
||||
|
||||
function resolveHistoryOutputMode(
|
||||
program: Command,
|
||||
historyCmd: Command,
|
||||
): CliOutputMode {
|
||||
return program.opts().json || historyCmd.opts().json ? "json" : "text";
|
||||
}
|
||||
|
||||
export function registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode,
|
||||
setStartupTarget,
|
||||
isInteractiveTTY = () =>
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true,
|
||||
}: RegisterHistoryCommandOptions): void {
|
||||
const historyCmd = program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List session history or manage saved sessions")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--limit <count>", "Maximum number of sessions to show", "50")
|
||||
.option("--page <number>", "Page number for paginated results")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const opts = historyCmd.opts();
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
if (outputMode === "text" && isInteractiveTTY()) {
|
||||
setStartupTarget("history");
|
||||
return;
|
||||
}
|
||||
setExitCode(
|
||||
await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
io,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const historyDeleteCmd = historyCmd
|
||||
.command("delete")
|
||||
.description("Delete a session from history")
|
||||
.option("--session-id <id>", "Session ID to delete")
|
||||
.action(async () => {
|
||||
const opts = historyDeleteCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
io.writeErr("history delete requires --session-id <id>");
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(await runHistoryDelete(opts.sessionId, outputMode, io));
|
||||
});
|
||||
|
||||
const historyUpdateCmd = historyCmd
|
||||
.command("update")
|
||||
.description("Update a session in history")
|
||||
.option("--metadata <json>", "Metadata as JSON string")
|
||||
.option("--prompt <text>", "New prompt text")
|
||||
.option("--session-id <id>", "Session ID to update")
|
||||
.option("--title <text>", "New title")
|
||||
.action(async () => {
|
||||
const opts = historyUpdateCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
io.writeErr("history update requires --session-id <id>");
|
||||
setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(
|
||||
await runHistoryUpdate(
|
||||
opts.sessionId,
|
||||
opts.prompt,
|
||||
opts.title,
|
||||
opts.metadata,
|
||||
outputMode,
|
||||
io,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const historyExportCmd = historyCmd
|
||||
.command("export <sessionId>")
|
||||
.description("Export a session as a standalone HTML file")
|
||||
.option("-o, --output <path>", "Output HTML file path")
|
||||
.action(async (sessionId: string) => {
|
||||
const opts = historyExportCmd.opts();
|
||||
const outputMode = resolveHistoryOutputMode(program, historyCmd);
|
||||
setExitCode(
|
||||
await runHistoryExport(sessionId, opts.output, outputMode, io),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SessionHistoryRecord } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import {
|
||||
formatCheckpointDetail,
|
||||
formatHistoryListLine,
|
||||
@@ -16,18 +17,12 @@ vi.mock("../session/session", () => ({
|
||||
readSessionMessagesArtifact: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../tui/history-standalone", () => ({
|
||||
renderHistoryStandalone: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
import { listSessions, readSessionMessagesArtifact } from "../session/session";
|
||||
import { renderHistoryStandalone } from "../tui/history-standalone";
|
||||
|
||||
const mockedReadSessionMessagesArtifact = vi.mocked(
|
||||
readSessionMessagesArtifact,
|
||||
);
|
||||
const mockedListSessions = vi.mocked(listSessions);
|
||||
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
|
||||
|
||||
function createHistoryRow(
|
||||
overrides: Partial<SessionHistoryRecord> = {},
|
||||
@@ -200,8 +195,11 @@ describe("runHistoryList", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
|
||||
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
|
||||
it("requests hydrated text history rows so titles can come from messages", async () => {
|
||||
const row = createHistoryRow({
|
||||
prompt: undefined,
|
||||
metadata: { title: "hydrated title", totalCost: 0.25 },
|
||||
});
|
||||
mockedListSessions.mockResolvedValue([row]);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
@@ -218,8 +216,8 @@ describe("runHistoryList", () => {
|
||||
expect(mockedListSessions).toHaveBeenCalledWith(25, {
|
||||
hydrate: true,
|
||||
});
|
||||
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rows: [row] }),
|
||||
expect(io.writeln).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hydrated title"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -313,6 +311,42 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("writes structured JSON from a persisted messages artifact", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
systemPrompt: "Be helpful",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "world" }],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
|
||||
const targetPath = await exportHistorySession({
|
||||
sessionId: "sess_1",
|
||||
format: "json",
|
||||
outputDirectory: tempDir,
|
||||
});
|
||||
|
||||
expect(targetPath).toBe(join(tempDir, "sess_1.json"));
|
||||
await expect(
|
||||
readFile(targetPath, "utf8").then((contents) => JSON.parse(contents)),
|
||||
).resolves.toEqual(artifact);
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { generateConversationHTML } from "../session/export";
|
||||
import {
|
||||
deleteSession,
|
||||
listSessions,
|
||||
readSessionMessagesArtifact,
|
||||
updateSession,
|
||||
} from "../session/session";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import { exportHistorySession } from "../session/history-export";
|
||||
import { deleteSession, listSessions, updateSession } from "../session/session";
|
||||
import { formatHistoryListLine } from "../utils/history-format";
|
||||
import { writeln } from "../utils/output";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
|
||||
@@ -22,22 +15,6 @@ type HistoryIo = {
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
async function exportHistorySession(
|
||||
sessionId: string,
|
||||
outputPath?: string,
|
||||
): Promise<string> {
|
||||
const data = await readSessionMessagesArtifact(sessionId);
|
||||
if (!data) {
|
||||
throw new Error(`Session ${sessionId} not found or has no messages.json`);
|
||||
}
|
||||
|
||||
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
|
||||
const html = generateConversationHTML(data, sessionId);
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, html, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function runHistoryDelete(
|
||||
sessionId: string | undefined,
|
||||
outputMode: CliOutputMode,
|
||||
@@ -136,7 +113,11 @@ async function runHistoryExport(
|
||||
}
|
||||
|
||||
try {
|
||||
const targetPath = await exportHistorySession(sessionId, outputPath);
|
||||
const targetPath = await exportHistorySession({
|
||||
sessionId,
|
||||
format: "html",
|
||||
outputPath,
|
||||
});
|
||||
|
||||
if (outputMode === "json") {
|
||||
process.stdout.write(
|
||||
@@ -161,7 +142,7 @@ export async function runHistoryList(input: {
|
||||
outputMode: CliOutputMode;
|
||||
workspaceRoot?: string;
|
||||
io?: HistoryIo;
|
||||
}): Promise<number | string> {
|
||||
}): Promise<number> {
|
||||
const io = input.io ?? {
|
||||
writeln,
|
||||
writeErr: (text: string) => process.stderr.write(`${text}\n`),
|
||||
@@ -186,23 +167,10 @@ export async function runHistoryList(input: {
|
||||
return 0;
|
||||
}
|
||||
|
||||
disableOpenTuiGraphicsProbe();
|
||||
const { renderHistoryStandalone } = await import("../tui/history-standalone");
|
||||
return await renderHistoryStandalone({
|
||||
rows,
|
||||
refreshRows: async () =>
|
||||
await listSessions(limit, {
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
hydrate: false,
|
||||
}),
|
||||
onExport: async (sessionId: string) =>
|
||||
await exportHistorySession(sessionId, undefined),
|
||||
});
|
||||
for (const row of rows) {
|
||||
io.writeln(formatHistoryListLine(row));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export {
|
||||
exportHistorySession,
|
||||
runHistoryDelete,
|
||||
runHistoryExport,
|
||||
runHistoryUpdate,
|
||||
};
|
||||
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { relative, sep } from "node:path";
|
||||
import {
|
||||
resolveClineDataDir,
|
||||
resolveClineDir,
|
||||
setHomeDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createProgram } from "./program";
|
||||
|
||||
/** Render an absolute path under `home` the way help text does: `~/...`. */
|
||||
function tildePath(absolutePath: string, home: string): string {
|
||||
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
|
||||
}
|
||||
|
||||
describe("root option help text", () => {
|
||||
const FAKE_HOME = "/home/cline-help-test";
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(() => {
|
||||
// Pin the resolver inputs so the defaults below are the true defaults
|
||||
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
|
||||
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
setHomeDir(FAKE_HOME);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the actual resolver defaults for --config and --data-dir", () => {
|
||||
// A wide help width keeps each option description on one line so the
|
||||
// full default text can be matched.
|
||||
const help = createProgram()
|
||||
.configureHelp({ helpWidth: 500 })
|
||||
.helpInformation();
|
||||
|
||||
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
|
||||
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
|
||||
|
||||
// Sanity-check the resolvers themselves so the assertions below can't
|
||||
// silently drift along with a resolver regression.
|
||||
expect(configDefault).toBe("~/.cline");
|
||||
expect(dataDirDefault).toBe("~/.cline/data");
|
||||
|
||||
expect(help).toContain(
|
||||
`Configuration directory (default: ${configDefault})`,
|
||||
);
|
||||
expect(help).toContain(
|
||||
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"--acp",
|
||||
"Run in Agent Client Protocol (ACP) mode for editor integration",
|
||||
)
|
||||
.option(
|
||||
"--config <path>",
|
||||
"Configuration directory (default: ~/.cline/data/settings)",
|
||||
)
|
||||
.option("--config <path>", "Configuration directory (default: ~/.cline)")
|
||||
.option(
|
||||
"--data-dir <path>",
|
||||
"Use isolated local state at this directory path (default: ~/.cline)",
|
||||
"Use isolated local state at this directory path (default: ~/.cline/data)",
|
||||
)
|
||||
.option(
|
||||
"--hooks-dir <path>",
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
|
||||
mockEnsureCliHubServer: vi.fn(),
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: mockSpawn,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
ensureCliHubServerAfterUpdate,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
@@ -21,6 +42,14 @@ const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createChildProcessThatCloses(exitCode: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
@@ -236,6 +265,70 @@ describe("hub restart owner selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-update hub launch", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureCliHubServer.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
});
|
||||
|
||||
it("uses the freshly installed wrapper instead of the current executable", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
|
||||
const env = {
|
||||
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
CLINE_NO_AUTO_UPDATE: "0",
|
||||
};
|
||||
|
||||
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
["hub", "ensure"],
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
env: {
|
||||
...env,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
await ensureCliHubServerAfterUpdate(
|
||||
"C:\\workspace\\project",
|
||||
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
|
||||
"C:\\workspace\\project",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a failure from the freshly installed CLI", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
|
||||
|
||||
await expect(
|
||||
ensureCliHubServerAfterUpdate(
|
||||
"/workspace/project",
|
||||
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
|
||||
"linux",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"freshly installed Cline failed to start the hub (exit code 1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
|
||||
@@ -237,6 +237,50 @@ async function runKanbanUpdate(
|
||||
return waitForProcessExit(updateProcess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the hub through the freshly installed CLI after a self-update.
|
||||
*
|
||||
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
|
||||
* remove that cached executable while replacing the package and then block the
|
||||
* postinstall script that recreates it. The current process keeps running from
|
||||
* the unlinked executable, but process.execPath is no longer spawnable. Going
|
||||
* back through the wrapper makes it resolve the newly installed platform
|
||||
* binary instead.
|
||||
*
|
||||
* Windows does not create the bin/.cline cache, and development builds do not
|
||||
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
|
||||
* ensure path.
|
||||
*/
|
||||
export async function ensureCliHubServerAfterUpdate(
|
||||
workspaceRoot: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (!wrapperPath || platform === "win32") {
|
||||
await ensureCliHubServer(workspaceRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(wrapperPath, ["hub", "ensure"], {
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...env,
|
||||
// The fresh CLI only exists to start the hub. Do not let it launch
|
||||
// another background update check while this update is finishing.
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return "";
|
||||
@@ -342,7 +386,7 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
|
||||
await ensureCliHubServerAfterUpdate(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
|
||||
import type { ChatStartSessionRequest } from "@cline/core";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -729,60 +731,69 @@ class DiscordConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Discord bot username label")
|
||||
.option("--application-id <id>", "Discord application id")
|
||||
.option("--app-id <id>", "Alias for --application-id")
|
||||
.option("--bot-token <token>", "Discord bot token")
|
||||
.option("--token <token>", "Alias for --bot-token")
|
||||
.option("--public-key <key>", "Discord application public key")
|
||||
.option(
|
||||
"--owner-user-id <id>",
|
||||
"Discord user id that should be marked as connector owner",
|
||||
)
|
||||
.option("--ignore-bot-authors", "Ignore messages from other Discord bots")
|
||||
.option(
|
||||
"--mention-role-ids <ids>",
|
||||
"Comma-separated role IDs that should trigger mention handlers",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Discord sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for Discord interactions webhook",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" DISCORD_APPLICATION_ID Discord application id",
|
||||
" DISCORD_BOT_TOKEN Discord bot token",
|
||||
" DISCORD_PUBLIC_KEY Discord application public key",
|
||||
" DISCORD_OWNER_USER_ID Optional connector owner user id",
|
||||
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
|
||||
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Discord bot username label")
|
||||
.option("--application-id <id>", "Discord application id")
|
||||
.option("--app-id <id>", "Alias for --application-id")
|
||||
.option("--bot-token <token>", "Discord bot token")
|
||||
.option("--token <token>", "Alias for --bot-token")
|
||||
.option("--public-key <key>", "Discord application public key")
|
||||
.option(
|
||||
"--owner-user-id <id>",
|
||||
"Discord user id that should be marked as connector owner",
|
||||
)
|
||||
.option(
|
||||
"--ignore-bot-authors",
|
||||
"Ignore messages from other Discord bots",
|
||||
)
|
||||
.option(
|
||||
"--mention-role-ids <ids>",
|
||||
"Comma-separated role IDs that should trigger mention handlers",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Discord sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for Discord interactions webhook",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" DISCORD_APPLICATION_ID Discord application id",
|
||||
" DISCORD_BOT_TOKEN Discord bot token",
|
||||
" DISCORD_PUBLIC_KEY Discord application public key",
|
||||
" DISCORD_OWNER_USER_ID Optional connector owner user id",
|
||||
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
|
||||
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectDiscordOptions {
|
||||
@@ -804,6 +815,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -856,7 +868,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -973,6 +985,12 @@ class DiscordConnector extends ConnectorBase<
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
): string | undefined {
|
||||
return options.applicationId;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
rawArgs: string[],
|
||||
@@ -1119,7 +1137,9 @@ class DiscordConnector extends ConnectorBase<
|
||||
isSubscribedThreadMessage?: boolean;
|
||||
},
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -1152,6 +1172,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
resolveMuteTarget: ({ target }) => resolveDiscordMuteTarget(target),
|
||||
createEmptyRuntimeReplyResolver:
|
||||
@@ -1291,9 +1312,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -235,48 +236,54 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Google Chat bot username label")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Google Chat sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.option(
|
||||
"--pubsub-topic <topic>",
|
||||
"Optional Pub/Sub topic for all-message events",
|
||||
)
|
||||
.option("--impersonate-user <email>", "Optional delegation user email")
|
||||
.option("--use-adc", "Use Google Application Default Credentials")
|
||||
.option("--credentials-json <json>", "Service account credentials JSON")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" GOOGLE_CHAT_CREDENTIALS Service account JSON",
|
||||
" GOOGLE_CHAT_USE_ADC=true Use Application Default Credentials",
|
||||
" GOOGLE_CHAT_PUBSUB_TOPIC Optional Pub/Sub topic",
|
||||
" GOOGLE_CHAT_IMPERSONATE_USER Optional delegation user",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Google Chat bot username label")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Google Chat sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.option(
|
||||
"--pubsub-topic <topic>",
|
||||
"Optional Pub/Sub topic for all-message events",
|
||||
)
|
||||
.option("--impersonate-user <email>", "Optional delegation user email")
|
||||
.option("--use-adc", "Use Google Application Default Credentials")
|
||||
.option("--credentials-json <json>", "Service account credentials JSON")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" GOOGLE_CHAT_CREDENTIALS Service account JSON",
|
||||
" GOOGLE_CHAT_USE_ADC=true Use Application Default Credentials",
|
||||
" GOOGLE_CHAT_PUBSUB_TOPIC Optional Pub/Sub topic",
|
||||
" GOOGLE_CHAT_IMPERSONATE_USER Optional delegation user",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectGoogleChatOptions {
|
||||
@@ -290,6 +297,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -316,7 +324,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -463,6 +471,12 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
}
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
): string | undefined {
|
||||
return options.userName;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
rawArgs: string[],
|
||||
@@ -614,7 +628,9 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
thread: Thread<GoogleChatThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -637,6 +653,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -698,9 +715,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -290,48 +291,54 @@ class LinearConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Linear bot display name")
|
||||
.option("--api-key <key>", "Linear personal API key")
|
||||
.option("--client-id <id>", "Linear OAuth client id")
|
||||
.option("--client-secret <secret>", "Linear OAuth client secret")
|
||||
.option("--access-token <token>", "Pre-obtained Linear access token")
|
||||
.option("--webhook-secret <secret>", "Linear webhook signing secret")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--provider-api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Linear sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" LINEAR_API_KEY Personal API key",
|
||||
" LINEAR_CLIENT_ID OAuth client id",
|
||||
" LINEAR_CLIENT_SECRET OAuth client secret",
|
||||
" LINEAR_ACCESS_TOKEN Pre-obtained access token",
|
||||
" LINEAR_WEBHOOK_SECRET Webhook signing secret",
|
||||
" LINEAR_BOT_USERNAME Bot display name (default: linear-bot)",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Linear bot display name")
|
||||
.option("--api-key <key>", "Linear personal API key")
|
||||
.option("--client-id <id>", "Linear OAuth client id")
|
||||
.option("--client-secret <secret>", "Linear OAuth client secret")
|
||||
.option("--access-token <token>", "Pre-obtained Linear access token")
|
||||
.option("--webhook-secret <secret>", "Linear webhook signing secret")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--provider-api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Linear sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" LINEAR_API_KEY Personal API key",
|
||||
" LINEAR_CLIENT_ID OAuth client id",
|
||||
" LINEAR_CLIENT_SECRET OAuth client secret",
|
||||
" LINEAR_ACCESS_TOKEN Pre-obtained access token",
|
||||
" LINEAR_WEBHOOK_SECRET Webhook signing secret",
|
||||
" LINEAR_BOT_USERNAME Bot display name (default: linear-bot)",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectLinearOptions {
|
||||
@@ -350,6 +357,7 @@ class LinearConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -396,7 +404,7 @@ class LinearConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -488,6 +496,12 @@ class LinearConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectLinearOptions,
|
||||
): string | undefined {
|
||||
return options.userName;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectLinearOptions,
|
||||
rawArgs: string[],
|
||||
@@ -637,7 +651,9 @@ class LinearConnector extends ConnectorBase<
|
||||
thread: Thread<LinearThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -662,6 +678,7 @@ class LinearConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -723,9 +740,7 @@ class LinearConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -338,6 +338,88 @@ describe("slack binding lookup", () => {
|
||||
expect(calls).toEqual(["get:T123", "token:xoxb-team-token", "work"]);
|
||||
});
|
||||
|
||||
it("strips the leading bot mention from Slack message text", () => {
|
||||
expect(
|
||||
__test__.stripSlackBotMention("@U0B8E8H3U1F hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention("<@U0B8E8H3U1F> hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention("<@U0B8E8H3U1F|cline> hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention(" @U0B8E8H3U1F: hi", "U0B8E8H3U1F"),
|
||||
).toBe("hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention(
|
||||
"@U0B8E8H3U1F @U0B8E8H3U1F hi",
|
||||
"U0B8E8H3U1F",
|
||||
),
|
||||
).toBe("hi");
|
||||
});
|
||||
|
||||
it("keeps Slack text that does not start with the bot mention", () => {
|
||||
expect(
|
||||
__test__.stripSlackBotMention("hi @U0B8E8H3U1F", "U0B8E8H3U1F"),
|
||||
).toBe("hi @U0B8E8H3U1F");
|
||||
expect(__test__.stripSlackBotMention("@U999999 hi", "U0B8E8H3U1F")).toBe(
|
||||
"@U999999 hi",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("@cline hi", "U0B8E8H3U1F")).toBe(
|
||||
"@cline hi",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F hi", undefined)).toBe(
|
||||
"@U0B8E8H3U1F hi",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps mentions of other Slack users whose id starts with the bot id", () => {
|
||||
expect(__test__.stripSlackBotMention("@U1234 help", "U123")).toBe(
|
||||
"@U1234 help",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("@U123 hi", "U123")).toBe("hi");
|
||||
expect(__test__.stripSlackBotMention("<@U1234> help", "U123")).toBe(
|
||||
"<@U1234> help",
|
||||
);
|
||||
expect(__test__.stripSlackBotMention("<@U1234|other> help", "U123")).toBe(
|
||||
"<@U1234|other> help",
|
||||
);
|
||||
expect(
|
||||
__test__.stripSlackBotMention("@U0B8E8H3U1FX hi", "U0B8E8H3U1F"),
|
||||
).toBe("@U0B8E8H3U1FX hi");
|
||||
expect(
|
||||
__test__.stripSlackBotMention(
|
||||
"@U0B8E8H3U1F @U0B8E8H3U1FX hi",
|
||||
"U0B8E8H3U1F",
|
||||
),
|
||||
).toBe("@U0B8E8H3U1FX hi");
|
||||
});
|
||||
|
||||
it("keeps a bare Slack bot mention so the turn is not dropped", () => {
|
||||
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F", "U0B8E8H3U1F")).toBe(
|
||||
"@U0B8E8H3U1F",
|
||||
);
|
||||
expect(
|
||||
__test__.stripSlackBotMention("<@U0B8E8H3U1F> ", "U0B8E8H3U1F"),
|
||||
).toBe("<@U0B8E8H3U1F> ");
|
||||
});
|
||||
|
||||
it("resolves the Slack bot user id from the adapter or event authorizations", () => {
|
||||
expect(__test__.resolveSlackBotUserId({ botUserId: "U0B8E8H3U1F" })).toBe(
|
||||
"U0B8E8H3U1F",
|
||||
);
|
||||
expect(
|
||||
__test__.resolveSlackBotUserId(
|
||||
{ botUserId: undefined },
|
||||
{ authorizations: [{ user_id: "U0B8E8H3U1F" }] },
|
||||
),
|
||||
).toBe("U0B8E8H3U1F");
|
||||
expect(
|
||||
__test__.resolveSlackBotUserId({ botUserId: undefined }, { text: "hi" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects Slack invalid_thread_ts errors", () => {
|
||||
expect(
|
||||
__test__.isSlackInvalidThreadTsError(
|
||||
@@ -351,3 +433,53 @@ describe("slack binding lookup", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slack legacy connector state", () => {
|
||||
it("stops a live connector recorded by a pre-claim state file (no claimId)", async () => {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
const previousDataDir = process.env.CLINE_DATA_DIR;
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "slack-legacy-state-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
try {
|
||||
const stateDir = join(dataDir, "connectors", "slack");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateDir, "mybot.json"),
|
||||
JSON.stringify({
|
||||
userName: "mybot",
|
||||
connectionMode: "socket",
|
||||
pid: child.pid,
|
||||
// Unreachable on purpose: session cleanup falls back to
|
||||
// local storage inside the isolated data dir.
|
||||
rpcAddress: "ws://127.0.0.1:1/hub",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const io = { writeln: () => {}, writeErr: () => {} };
|
||||
const result = await slackConnector.stopInstance?.("mybot", io);
|
||||
|
||||
expect(result?.stoppedProcesses).toBe(1);
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = previousDataDir;
|
||||
}
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
enqueueThreadTurn,
|
||||
startConnectorWebhookServer,
|
||||
} from "../chat-runtime";
|
||||
import { isProcessRunning } from "../common";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
|
||||
import {
|
||||
type ActiveConnectorTurn,
|
||||
handleConnectorUserTurn,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
writeBindings,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
@@ -64,19 +65,13 @@ import type {
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
getConnectorFirstContactMessage,
|
||||
getConnectorSystemPrompt,
|
||||
getConnectorSystemRules,
|
||||
} from "./prompts";
|
||||
import { getConnectorSystemPrompt, getConnectorSystemRules } from "./prompts";
|
||||
|
||||
const SLACK_SYSTEM_RULES = getConnectorSystemRules(
|
||||
"Slack",
|
||||
"You can respond to user messages in threads and DMs, and you can use tools according to user's requests and your capabilities.",
|
||||
);
|
||||
|
||||
const SLACK_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
|
||||
|
||||
type SlackThreadState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
@@ -206,6 +201,60 @@ function extractSlackChannelFromId(id: string): string | undefined {
|
||||
return parts[0] === "slack" ? readString(parts[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack delivers `@cline hi` as `<@U0B8E8H3U1F> hi`, and the chat SDK
|
||||
* deliberately leaves the bot's own mention unresolved (so mention detection
|
||||
* keeps working), flattening it to `@U0B8E8H3U1F hi`. Strip that leading
|
||||
* self-mention so the agent receives `hi`.
|
||||
*
|
||||
* Only leading mentions of the bot itself are removed; mentions of other users
|
||||
* (already resolved to `@display-name`) and inline mentions are preserved so
|
||||
* the agent still sees who was addressed. A bare mention with no other content
|
||||
* is left untouched so the turn still reaches the agent instead of being
|
||||
* dropped as empty input.
|
||||
*/
|
||||
function stripSlackBotMention(
|
||||
text: string,
|
||||
botUserId: string | undefined,
|
||||
): string {
|
||||
const botId = botUserId?.trim();
|
||||
if (!botId || !text) {
|
||||
return text;
|
||||
}
|
||||
const escapedBotId = botId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Matches `<@U123>`, `<@U123|name>` and the SDK-flattened `@U123` form,
|
||||
// repeated when a user mentions the bot more than once up front.
|
||||
//
|
||||
// The angle-bracket forms are delimited by `>`, but the flattened form has no
|
||||
// closing delimiter, so it needs an explicit boundary. Without one, `@U123`
|
||||
// also matches the start of a longer id belonging to someone else, turning
|
||||
// `@U1234 help` into `4 help`. Slack ids are uppercase alphanumeric, so a
|
||||
// complete mention is one that is not followed by another id character.
|
||||
// `\b` cannot express this: ids end in word characters, so `@U123\b` still
|
||||
// matches inside `@U1234`.
|
||||
const leadingMention = new RegExp(
|
||||
`^(?:\\s*(?:<@${escapedBotId}(?:\\|[^<>]*)?>|@${escapedBotId}(?![A-Za-z0-9]))[\\s,:]*)+`,
|
||||
);
|
||||
const stripped = text.replace(leadingMention, "");
|
||||
return stripped.trim() ? stripped.trimStart() : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter exposes the authenticated bot user id (request-scoped in
|
||||
* multi-workspace mode). When it is not yet known, fall back to the id Slack
|
||||
* reports as the authorized app user on the event envelope.
|
||||
*/
|
||||
function resolveSlackBotUserId(
|
||||
slack: Pick<SlackAdapter, "botUserId">,
|
||||
rawMessage?: unknown,
|
||||
): string | undefined {
|
||||
const raw = asRecord(rawMessage);
|
||||
return (
|
||||
readString(slack.botUserId) ??
|
||||
readString(firstRecord(raw?.authorizations)?.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSlackChannelMentionThread(
|
||||
thread: Thread<SlackThreadState>,
|
||||
message: Message,
|
||||
@@ -447,62 +496,68 @@ class SlackConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Slack bot username label")
|
||||
.option(
|
||||
"--bot-token <token>",
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
"--encryption-key <key>",
|
||||
"Base64 32-byte key for encrypted installations",
|
||||
)
|
||||
.option(
|
||||
"--installation-key-prefix <prefix>",
|
||||
"Override stored installation key prefix",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for Slack sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for webhooks and OAuth callback",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "Slack bot username label")
|
||||
.option(
|
||||
"--bot-token <token>",
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
"--encryption-key <key>",
|
||||
"Base64 32-byte key for encrypted installations",
|
||||
)
|
||||
.option(
|
||||
"--installation-key-prefix <prefix>",
|
||||
"Override stored installation key prefix",
|
||||
)
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Slack sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"Public base URL for webhooks and OAuth callback",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectSlackOptions {
|
||||
@@ -523,6 +578,7 @@ class SlackConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -589,7 +645,7 @@ class SlackConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -628,6 +684,9 @@ class SlackConnector extends ConnectorBase<
|
||||
Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
// claimId is optional: state files written by older CLI
|
||||
// versions predate claiming and must stay manageable
|
||||
// (already-running detection, status, stop).
|
||||
typeof (value as SlackConnectorState).pid === "number" &&
|
||||
typeof (value as SlackConnectorState).userName === "string",
|
||||
),
|
||||
@@ -679,6 +738,12 @@ class SlackConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectSlackOptions,
|
||||
): string | undefined {
|
||||
return options.userName;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectSlackOptions,
|
||||
rawArgs: string[],
|
||||
@@ -689,14 +754,18 @@ class SlackConnector extends ConnectorBase<
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const stateStorePath = this.resolveStateStorePath(options.userName);
|
||||
const staleState = this.removeStaleState(
|
||||
statePath,
|
||||
(path) => this.readConnectorState(path),
|
||||
(state) => state.pid,
|
||||
);
|
||||
const existingState = this.readConnectorState(statePath);
|
||||
const staleState =
|
||||
existingState && !isProcessRunning(existingState.pid)
|
||||
? existingState
|
||||
: undefined;
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
}
|
||||
const formatAlreadyRunning = (state: SlackConnectorState) =>
|
||||
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}`;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
@@ -705,10 +774,7 @@ class SlackConnector extends ConnectorBase<
|
||||
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}`,
|
||||
formatAlreadyRunningMessage: formatAlreadyRunning,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
@@ -719,6 +785,34 @@ class SlackConnector extends ConnectorBase<
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
// Foreground / detached-child path: exclusively claim the instance before
|
||||
// opening Slack socket-mode so a second process cannot share the token.
|
||||
const startedAt = new Date().toISOString();
|
||||
const claim = this.claimConnectorInstance({
|
||||
statePath,
|
||||
createState: (claimId) => ({
|
||||
claimId,
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
rpcAddress: "pending",
|
||||
startedAt,
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
}),
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
getPid: (state) => state.pid,
|
||||
});
|
||||
if (!claim.claimed) {
|
||||
io.writeln(
|
||||
claim.running
|
||||
? formatAlreadyRunning(claim.running)
|
||||
: `[slack] connector already running for user=${options.userName}`,
|
||||
);
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "slack-connect",
|
||||
@@ -806,6 +900,7 @@ class SlackConnector extends ConnectorBase<
|
||||
});
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
claimId: claim.claimId,
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
@@ -813,7 +908,7 @@ class SlackConnector extends ConnectorBase<
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
startedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
@@ -838,7 +933,9 @@ class SlackConnector extends ConnectorBase<
|
||||
bindingsPath,
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await withSlackTeamBotToken({
|
||||
@@ -863,10 +960,10 @@ class SlackConnector extends ConnectorBase<
|
||||
hookCommand: options.hookCommand,
|
||||
systemRules: SLACK_SYSTEM_RULES,
|
||||
errorLabel: "Slack",
|
||||
firstContactMessage: SLACK_FIRST_CONTACT_MESSAGE,
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (
|
||||
currentThread,
|
||||
@@ -951,9 +1048,7 @@ class SlackConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
@@ -966,10 +1061,14 @@ class SlackConnector extends ConnectorBase<
|
||||
rawMessage: message.raw,
|
||||
errorLabel: "Slack",
|
||||
});
|
||||
const text = stripSlackBotMention(
|
||||
message.text,
|
||||
resolveSlackBotUserId(slack, message.raw),
|
||||
);
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread: mentionThread,
|
||||
text: message.text,
|
||||
text,
|
||||
client,
|
||||
clientId,
|
||||
pendingApprovals,
|
||||
@@ -978,7 +1077,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(mentionThread, message.text);
|
||||
await handleTurn(mentionThread, text);
|
||||
});
|
||||
|
||||
bot.onSubscribedMessage(async (thread, message) => {
|
||||
@@ -989,10 +1088,14 @@ class SlackConnector extends ConnectorBase<
|
||||
rawMessage: message.raw,
|
||||
errorLabel: "Slack",
|
||||
});
|
||||
const text = stripSlackBotMention(
|
||||
message.text,
|
||||
resolveSlackBotUserId(slack, message.raw),
|
||||
);
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread,
|
||||
text: message.text,
|
||||
text,
|
||||
client,
|
||||
clientId,
|
||||
pendingApprovals,
|
||||
@@ -1001,7 +1104,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(thread, message.text);
|
||||
await handleTurn(thread, text);
|
||||
});
|
||||
|
||||
bot.onSlashCommand(async (event) => {
|
||||
@@ -1214,7 +1317,9 @@ export const __test__ = {
|
||||
buildSlackParticipantKey,
|
||||
resolveSlackParticipant,
|
||||
normalizeSlackMessageEventChannelType,
|
||||
resolveSlackBotUserId,
|
||||
resolveSlackChannelMentionThread,
|
||||
stripSlackBotMention,
|
||||
withSlackTeamBotToken,
|
||||
isSlackInvalidThreadTsError,
|
||||
findBindingForThread: (
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { handleConnectorUserTurn } from "../connector-host";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -468,3 +469,322 @@ describe("telegram binding lookup", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
type SlashTestState = Record<string, unknown>;
|
||||
|
||||
function createSlashTestThread(input: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
isDM: boolean;
|
||||
initialState?: SlashTestState;
|
||||
}) {
|
||||
let state: SlashTestState = { ...(input.initialState ?? {}) };
|
||||
const posts: unknown[] = [];
|
||||
let subscribed = false;
|
||||
const thread = {
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
get state() {
|
||||
return Promise.resolve(state);
|
||||
},
|
||||
async setState(nextState: SlashTestState) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
async subscribe() {
|
||||
subscribed = true;
|
||||
},
|
||||
async post(message: unknown) {
|
||||
posts.push(message);
|
||||
const sentMessage = {
|
||||
edit: async (nextMessage: unknown) => {
|
||||
posts.push(nextMessage);
|
||||
return sentMessage;
|
||||
},
|
||||
delete: async () => undefined,
|
||||
};
|
||||
return sentMessage;
|
||||
},
|
||||
async startTyping() {},
|
||||
toJSON() {
|
||||
return {
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
state,
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
thread,
|
||||
posts,
|
||||
getState: () => state,
|
||||
isSubscribed: () => subscribed,
|
||||
};
|
||||
}
|
||||
|
||||
function slashTestStartRequest() {
|
||||
return {
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
systemPrompt: "system",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
mode: "act",
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegram slash command delivery", () => {
|
||||
it("receives bot_command updates intercepted by the telegram chat library", async () => {
|
||||
// The Telegram Bot API tags any leading-slash message with a
|
||||
// `bot_command` entity, and @chat-adapter/telegram diverts those
|
||||
// updates away from the mention/subscribed-message handlers. This
|
||||
// pins the delivery contract: an intercepted update must still reach
|
||||
// the connector turn handler through the slash-command path.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, result: {} }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { createTelegramAdapter } = await import("@chat-adapter/telegram");
|
||||
const { Chat, ConsoleLogger } = await import("chat");
|
||||
const { InMemoryStateAdapter } = await import("../stores/memory-state");
|
||||
const telegram = createTelegramAdapter({
|
||||
mode: "polling",
|
||||
botToken: "123456:TEST-TOKEN",
|
||||
userName: "test_bot",
|
||||
logger: new ConsoleLogger("error", "telegram-slash-test"),
|
||||
});
|
||||
const bot = new Chat({
|
||||
userName: "test_bot",
|
||||
adapters: { telegram },
|
||||
state: new InMemoryStateAdapter(),
|
||||
logger: new ConsoleLogger("error", "telegram-slash-test"),
|
||||
});
|
||||
// bot.initialize() assigns this reference before polling starts; set
|
||||
// it directly to avoid the real getMe/long-polling network calls.
|
||||
(telegram as unknown as { chat: unknown }).chat = bot;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const turns: Array<{ threadId: string; isDM: boolean; text: string }> = [];
|
||||
bot.onSlashCommand(
|
||||
__test__.createTelegramSlashCommandHandler({
|
||||
bot,
|
||||
bindingsPath: join(dir, "threads.json"),
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: async (thread, text) => {
|
||||
turns.push({ threadId: thread.id, isDM: thread.isDM, text });
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const completions: Promise<unknown>[] = [];
|
||||
(
|
||||
telegram as unknown as {
|
||||
processUpdate: (update: unknown, options?: unknown) => void;
|
||||
}
|
||||
).processUpdate(
|
||||
{
|
||||
update_id: 1,
|
||||
message: {
|
||||
message_id: 42,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: { id: 555, type: "private" },
|
||||
from: {
|
||||
id: 999,
|
||||
is_bot: false,
|
||||
first_name: "Alice",
|
||||
username: "alice",
|
||||
},
|
||||
text: "/clear",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 6 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
waitUntil: (task: Promise<unknown>) =>
|
||||
completions.push(task.catch(() => undefined)),
|
||||
},
|
||||
);
|
||||
await Promise.all(completions);
|
||||
await vi.waitFor(() => {
|
||||
expect(turns).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(turns[0]).toEqual({
|
||||
threadId: "telegram:555",
|
||||
isDM: true,
|
||||
text: "/clear",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes intercepted slash commands into the connector turn handler", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, isSubscribed, getState } = createSlashTestThread({
|
||||
id: "telegram:12345",
|
||||
channelId: "telegram:12345",
|
||||
isDM: true,
|
||||
});
|
||||
const botThread = vi.fn(() => thread);
|
||||
const handleTurn = vi.fn(async () => undefined);
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: botThread } as never,
|
||||
bindingsPath,
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: handleTurn as never,
|
||||
});
|
||||
|
||||
await handler({
|
||||
channel: { id: "telegram:12345" },
|
||||
command: "/clear",
|
||||
text: "",
|
||||
raw: {
|
||||
message_id: 7,
|
||||
chat: { id: 12345, type: "private" },
|
||||
from: { id: 999, username: "alice", first_name: "Alice" },
|
||||
text: "/clear",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 6 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(botThread).toHaveBeenCalledWith("telegram:12345");
|
||||
expect(isSubscribed()).toBe(true);
|
||||
expect(handleTurn).toHaveBeenCalledWith(thread, "/clear");
|
||||
expect(getState().participantKey).toBe("telegram:id:999");
|
||||
});
|
||||
|
||||
it("preserves group-chat bot addressing in the forwarded command text", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const { thread } = createSlashTestThread({
|
||||
id: "telegram:-100200",
|
||||
channelId: "telegram:-100200",
|
||||
isDM: false,
|
||||
});
|
||||
const handleTurn = vi.fn(async () => undefined);
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: () => thread } as never,
|
||||
bindingsPath: join(dir, "threads.json"),
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: handleTurn as never,
|
||||
});
|
||||
|
||||
// The chat adapter strips "@test_bot" into command targeting before
|
||||
// invoking slash handlers; the raw message text keeps it so the
|
||||
// connector host can enforce group addressing rules.
|
||||
await handler({
|
||||
channel: { id: "telegram:-100200" },
|
||||
command: "/tools",
|
||||
text: "on",
|
||||
raw: {
|
||||
message_id: 8,
|
||||
chat: { id: -100200, type: "supergroup" },
|
||||
from: { id: 999, username: "alice" },
|
||||
text: "/tools@test_bot on",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 15 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleTurn).toHaveBeenCalledWith(thread, "/tools@test_bot on");
|
||||
});
|
||||
|
||||
it("falls back to the parsed command when the raw payload has no text", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const { thread } = createSlashTestThread({
|
||||
id: "telegram:12345",
|
||||
channelId: "telegram:12345",
|
||||
isDM: true,
|
||||
});
|
||||
const handleTurn = vi.fn(async () => undefined);
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: () => thread } as never,
|
||||
bindingsPath: join(dir, "threads.json"),
|
||||
baseStartRequest: slashTestStartRequest() as never,
|
||||
handleTurn: handleTurn as never,
|
||||
});
|
||||
|
||||
await handler({
|
||||
channel: { id: "telegram:12345" },
|
||||
command: "/cwd",
|
||||
text: "/tmp",
|
||||
raw: undefined,
|
||||
});
|
||||
|
||||
expect(handleTurn).toHaveBeenCalledWith(thread, "/cwd /tmp");
|
||||
});
|
||||
|
||||
it("delivers intercepted slash commands to the chat command host", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
|
||||
tempDataDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts } = createSlashTestThread({
|
||||
id: "telegram:777",
|
||||
channelId: "telegram:777",
|
||||
isDM: true,
|
||||
initialState: {
|
||||
sessionId: "session-1",
|
||||
participantKey: "telegram:id:999",
|
||||
participantLabel: "alice",
|
||||
welcomeSentAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const stopRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const baseStartRequest = slashTestStartRequest();
|
||||
const handler = __test__.createTelegramSlashCommandHandler({
|
||||
bot: { thread: () => thread } as never,
|
||||
bindingsPath,
|
||||
baseStartRequest: baseStartRequest as never,
|
||||
handleTurn: (async (
|
||||
turnThread: typeof thread,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await handleConnectorUserTurn({
|
||||
thread: turnThread as never,
|
||||
text,
|
||||
client: { stopRuntimeSession, deleteSession } 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: "telegram",
|
||||
botUserName: "test_bot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Telegram",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
});
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await handler({
|
||||
channel: { id: "telegram:777" },
|
||||
command: "/clear",
|
||||
text: "",
|
||||
raw: {
|
||||
message_id: 9,
|
||||
chat: { id: 777, type: "private" },
|
||||
from: { id: 999, username: "alice" },
|
||||
text: "/clear",
|
||||
entities: [{ type: "bot_command", offset: 0, length: 6 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteSession).toHaveBeenCalledWith("session-1", true);
|
||||
expect(posts).toContainEqual({ raw: "Started a fresh session." });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -266,6 +267,55 @@ async function persistTelegramThreadContext(input: {
|
||||
);
|
||||
}
|
||||
|
||||
type TelegramSlashCommandEvent = {
|
||||
channel: { id: string };
|
||||
command: string;
|
||||
text: string;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Telegram chat adapter intercepts any message whose leading entity is a
|
||||
* `bot_command` and delivers it to slash-command handlers instead of the
|
||||
* normal mention/subscribed-message handlers. Without a registered handler
|
||||
* the command is consumed and dropped, so connector commands like /clear
|
||||
* never reach the chat command host. Rebuild the originating chat thread and
|
||||
* forward the original message text (preserving any `@bot` addressing used
|
||||
* in group chats) into the same turn pipeline as regular messages.
|
||||
*/
|
||||
function createTelegramSlashCommandHandler(input: {
|
||||
bot: Pick<Chat, "thread">;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
handleTurn: (
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => Promise<void>;
|
||||
}): (event: TelegramSlashCommandEvent) => Promise<void> {
|
||||
return async (event) => {
|
||||
const raw = asRecord(event.raw);
|
||||
const commandText =
|
||||
readString(raw?.text) ??
|
||||
readString(raw?.caption) ??
|
||||
[event.command.trim(), event.text.trim()].filter(Boolean).join(" ");
|
||||
if (!commandText) {
|
||||
return;
|
||||
}
|
||||
const thread = input.bot.thread(
|
||||
event.channel.id,
|
||||
) as Thread<TelegramThreadState>;
|
||||
await thread.subscribe();
|
||||
await persistTelegramThreadContext({
|
||||
thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
rawMessage: event.raw,
|
||||
errorLabel: "Telegram",
|
||||
});
|
||||
await input.handleTurn(thread, commandText);
|
||||
};
|
||||
}
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: HubSessionClient;
|
||||
@@ -417,47 +467,53 @@ class TelegramConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
|
||||
.option(
|
||||
"-m, --bot-username <name>",
|
||||
"Telegram bot username; fetched from token if omitted",
|
||||
)
|
||||
.option("-k, --bot-token <token>", "Telegram bot token")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
|
||||
.option(
|
||||
"-m, --bot-username <name>",
|
||||
"Telegram bot username; fetched from token if omitted",
|
||||
)
|
||||
.option("-k, --bot-token <token>", "Telegram bot token")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectTelegramOptions {
|
||||
@@ -631,6 +687,17 @@ class TelegramConnector extends ConnectorBase<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only knowable up front when `--bot-username` was supplied; otherwise the
|
||||
* username is resolved from Telegram's API during startup, and the caller
|
||||
* has to start this connector locally instead of through the hub.
|
||||
*/
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectTelegramOptions,
|
||||
): string | undefined {
|
||||
return options.botUsername;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
inputOptions: ConnectTelegramOptions,
|
||||
rawArgs: string[],
|
||||
@@ -816,7 +883,9 @@ class TelegramConnector extends ConnectorBase<
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -841,6 +910,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
forceDisableTools: !options.enableTools,
|
||||
postFinalReply: async ({
|
||||
@@ -952,9 +1022,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
@@ -1006,6 +1074,15 @@ class TelegramConnector extends ConnectorBase<
|
||||
await handleTurn(thread, message.text);
|
||||
});
|
||||
|
||||
bot.onSlashCommand(
|
||||
createTelegramSlashCommandHandler({
|
||||
bot,
|
||||
bindingsPath,
|
||||
baseStartRequest: startRequest,
|
||||
handleTurn,
|
||||
}),
|
||||
);
|
||||
|
||||
await bot.initialize();
|
||||
const stopTaskUpdateStream =
|
||||
startConnectorTaskUpdateRelay<TelegramThreadState>({
|
||||
@@ -1150,6 +1227,7 @@ export const telegramConnector: ConnectCommandDefinition =
|
||||
new TelegramConnector();
|
||||
|
||||
export const __test__ = {
|
||||
createTelegramSlashCommandHandler,
|
||||
fetchTelegramBotUsername,
|
||||
readTelegramBotId,
|
||||
resolveTelegramBotUsername,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discordConnector } from "./discord";
|
||||
import { gchatConnector } from "./gchat";
|
||||
import { linearConnector } from "./linear";
|
||||
import { slackConnector } from "./slack";
|
||||
import { telegramConnector } from "./telegram";
|
||||
import { whatsappConnector } from "./whatsapp";
|
||||
|
||||
/**
|
||||
* Every connector runs with tools enabled unless the operator opts out, so this
|
||||
* has to hold for all of them at once rather than per adapter — the whole point
|
||||
* is that there is no adapter where the default is different.
|
||||
*/
|
||||
const connectors: Array<{
|
||||
name: string;
|
||||
connector: unknown;
|
||||
/** Minimal arguments that parse for this adapter. */
|
||||
baseArgs: string[];
|
||||
}> = [
|
||||
{
|
||||
name: "slack",
|
||||
connector: slackConnector,
|
||||
baseArgs: ["--user-name", "bot"],
|
||||
},
|
||||
{
|
||||
name: "discord",
|
||||
connector: discordConnector,
|
||||
baseArgs: ["--application-id", "app-1", "--bot-token", "token"],
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
connector: linearConnector,
|
||||
baseArgs: [
|
||||
"--user-name",
|
||||
"bot",
|
||||
"--api-key",
|
||||
"key",
|
||||
"--webhook-secret",
|
||||
"secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
connector: gchatConnector,
|
||||
baseArgs: ["--user-name", "bot"],
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
connector: whatsappConnector,
|
||||
baseArgs: ["--user-name", "bot", "--phone-number-id", "123"],
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
connector: telegramConnector,
|
||||
baseArgs: ["--bot-token", "123:token"],
|
||||
},
|
||||
];
|
||||
|
||||
function parse(
|
||||
connector: unknown,
|
||||
rawArgs: string[],
|
||||
): { enableTools: boolean } {
|
||||
return (
|
||||
connector as {
|
||||
parseArgs(rawArgs: string[]): { enableTools: boolean };
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
}
|
||||
|
||||
describe("connector tools default", () => {
|
||||
for (const { name, connector, baseArgs } of connectors) {
|
||||
it(`${name}: enables tools when nothing is passed`, () => {
|
||||
expect(parse(connector, baseArgs).enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it(`${name}: disables tools with --no-tools`, () => {
|
||||
expect(parse(connector, [...baseArgs, "--no-tools"]).enableTools).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${name}: an explicit --no-tools beats --enable-tools`, () => {
|
||||
// Ambiguous input resolves to the safer answer.
|
||||
expect(
|
||||
parse(connector, [...baseArgs, "--enable-tools", "--no-tools"])
|
||||
.enableTools,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps accepting --enable-tools so existing invocations still parse", () => {
|
||||
// Persisted autostart arguments and deployed scripts carry this flag; it is
|
||||
// redundant now but must not become an unknown-option error.
|
||||
for (const { connector, baseArgs } of connectors) {
|
||||
expect(
|
||||
parse(connector, [...baseArgs, "--enable-tools"]).enableTools,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
resolveThreadTurnQueueKey,
|
||||
} from "../thread-bindings";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
@@ -273,47 +274,53 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "WhatsApp bot username label")
|
||||
.option("--phone-number-id <id>", "WhatsApp Business phone number id")
|
||||
.option("--access-token <token>", "Meta access token")
|
||||
.option("--app-secret <secret>", "Meta app secret")
|
||||
.option("--verify-token <token>", "Webhook verify token")
|
||||
.option("--api-version <version>", "Graph API version", "v21.0")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--enable-tools", "Enable tools for WhatsApp sessions")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" WHATSAPP_ACCESS_TOKEN Meta access token",
|
||||
" WHATSAPP_APP_SECRET Meta app secret",
|
||||
" WHATSAPP_PHONE_NUMBER_ID WhatsApp Business phone number id",
|
||||
" WHATSAPP_VERIFY_TOKEN Webhook verification token",
|
||||
" WHATSAPP_BOT_USERNAME Bot username label",
|
||||
].join("\n"),
|
||||
);
|
||||
return (
|
||||
super
|
||||
.createCommand()
|
||||
.usage("--base-url <PUBLIC_BASE_URL> [options]")
|
||||
.option("--user-name <name>", "WhatsApp bot username label")
|
||||
.option("--phone-number-id <id>", "WhatsApp Business phone number id")
|
||||
.option("--access-token <token>", "Meta access token")
|
||||
.option("--app-secret <secret>", "Meta app secret")
|
||||
.option("--verify-token <token>", "Webhook verify token")
|
||||
.option("--api-version <version>", "Graph API version", "v21.0")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
.option("--api-key <key>", "Provider API key override")
|
||||
.option("--system <prompt>", "System prompt override")
|
||||
.option("--cwd <path>", "Workspace / cwd for runtime")
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for WhatsApp sessions")
|
||||
// Retained so existing invocations and persisted autostart arguments
|
||||
// keep parsing; tools are on unless --no-tools is passed.
|
||||
.option("--enable-tools", "Enable tools (default)")
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
)
|
||||
.option(
|
||||
"--rpc-address <host:port>",
|
||||
"RPC address",
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
)
|
||||
.option("--host <host>", "Webhook listen host")
|
||||
.option("--port <port>", "Webhook listen port")
|
||||
.option("--base-url <url>", "Public base URL for webhook configuration")
|
||||
.addHelpText(
|
||||
"after",
|
||||
[
|
||||
"",
|
||||
"Environment:",
|
||||
" WHATSAPP_ACCESS_TOKEN Meta access token",
|
||||
" WHATSAPP_APP_SECRET Meta app secret",
|
||||
" WHATSAPP_PHONE_NUMBER_ID WhatsApp Business phone number id",
|
||||
" WHATSAPP_VERIFY_TOKEN Webhook verification token",
|
||||
" WHATSAPP_BOT_USERNAME Bot username label",
|
||||
].join("\n"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override readOptions(command: Command): ConnectWhatsAppOptions {
|
||||
@@ -332,6 +339,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
mode?: string;
|
||||
interactive?: boolean;
|
||||
enableTools?: boolean;
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
port?: string;
|
||||
@@ -364,7 +372,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
systemPrompt: opts.system,
|
||||
mode: this.parseMode(opts.mode),
|
||||
interactive: Boolean(opts.interactive),
|
||||
enableTools: Boolean(opts.enableTools),
|
||||
enableTools: opts.tools !== false,
|
||||
rpcAddress:
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
@@ -455,6 +463,15 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override instanceIdFromOptions(
|
||||
options: ConnectWhatsAppOptions,
|
||||
): string | undefined {
|
||||
return resolveInstanceKey({
|
||||
phoneNumberId: options.phoneNumberId,
|
||||
userName: options.userName,
|
||||
});
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectWhatsAppOptions,
|
||||
rawArgs: string[],
|
||||
@@ -608,7 +625,9 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
thread: Thread<WhatsAppThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey = resolveThreadTurnQueueKey(thread);
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, queueKey, work);
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
@@ -633,6 +652,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
userInstructionService,
|
||||
chatCommandHost,
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: queueKey,
|
||||
getSessionMetadata: (currentThread, _clientId, currentState) => ({
|
||||
userName: options.userName,
|
||||
@@ -709,9 +729,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
await runTurn();
|
||||
return;
|
||||
}
|
||||
await enqueueThreadTurn(threadQueues, queueKey, async () => {
|
||||
await runTurn();
|
||||
});
|
||||
await enqueueTurn(runTurn);
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
|
||||
@@ -116,7 +116,29 @@ describe("ConnectorBase background launch", () => {
|
||||
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: child exited before becoming ready",
|
||||
expect.stringContaining(
|
||||
"launch failed: child exited before becoming ready",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("points at the child log so a startup failure is diagnosable", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
mocks.isProcessRunning.mockReturnValue(false);
|
||||
|
||||
await new TestConnector().runBackground(io);
|
||||
|
||||
const [message] = vi.mocked(io.writeErr).mock.calls[0] ?? [];
|
||||
expect(message).toContain("logs/connectors/test/test-connector.log");
|
||||
expect(mocks.spawnDetachedConnector).toHaveBeenCalledWith(
|
||||
["connect", "test"],
|
||||
["--token", "secret"],
|
||||
"CLINE_TEST_CONNECT_CHILD",
|
||||
expect.objectContaining({
|
||||
logPath: expect.stringContaining(
|
||||
"logs/connectors/test/test-connector.log",
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,7 +151,7 @@ describe("ConnectorBase background launch", () => {
|
||||
|
||||
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: timed out after 0ms",
|
||||
expect.stringContaining("launch failed: timed out after 0ms"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { isSupervisedConnectorProcess } from "@cline/shared";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
resolveConnectorDebugLogPath,
|
||||
spawnDetachedConnector,
|
||||
terminateProcess,
|
||||
tryClaimConnectorStateFile,
|
||||
writeJsonFile,
|
||||
} from "./common";
|
||||
import type {
|
||||
@@ -21,6 +31,65 @@ import type {
|
||||
const SHOW_HELP_ERROR = "__SHOW_HELP__";
|
||||
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const CONNECTOR_STARTUP_POLL_MS = 100;
|
||||
const CHILD_LOG_TAIL_BYTES = 8_192;
|
||||
const CHILD_LOG_TAIL_LINES = 3;
|
||||
const ESC = String.fromCharCode(27);
|
||||
const BEL = String.fromCharCode(7);
|
||||
const ANSI_SEQUENCE_PATTERN = new RegExp(
|
||||
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
|
||||
"g",
|
||||
);
|
||||
|
||||
function stripAnsiCodes(text: string): string {
|
||||
return text.replace(ANSI_SEQUENCE_PATTERN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface why a detached connector child died. The child's own error is the
|
||||
* useful part; the parent only knows that it exited, so quote the tail of the
|
||||
* child's log and point at the file for the rest.
|
||||
*/
|
||||
function formatChildLogHint(logPath: string): string {
|
||||
const suffix = ` See ${logPath} for details.`;
|
||||
let handle: number | undefined;
|
||||
try {
|
||||
const { size } = statSync(logPath);
|
||||
if (size === 0) {
|
||||
return suffix;
|
||||
}
|
||||
const length = Math.min(size, CHILD_LOG_TAIL_BYTES);
|
||||
const buffer = Buffer.alloc(length);
|
||||
handle = openSync(logPath, "r");
|
||||
const read = readSync(handle, buffer, 0, length, size - length);
|
||||
const lines = buffer
|
||||
.subarray(0, read)
|
||||
.toString("utf8")
|
||||
// A partial first line is likely when starting mid-file.
|
||||
.split("\n")
|
||||
.slice(size > length ? 1 : 0)
|
||||
.map((line) => stripAnsiCodes(line).trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-CHILD_LOG_TAIL_LINES);
|
||||
if (lines.length === 0) {
|
||||
return suffix;
|
||||
}
|
||||
return ` Last output from the child:\n${lines
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n")}\n${suffix.trimStart()}`;
|
||||
} catch {
|
||||
// The log is best-effort: a missing or unreadable file must never turn a
|
||||
// startup failure into a crash.
|
||||
return suffix;
|
||||
} finally {
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
closeSync(handle);
|
||||
} catch {
|
||||
// Nothing actionable if the descriptor is already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ConnectorBase<Options, State>
|
||||
implements ConnectCommandDefinition
|
||||
@@ -87,6 +156,31 @@ export abstract class ConnectorBase<Options, State>
|
||||
return this.runWithOptions(options, rawArgs, io, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance id `rawArgs` would run as, when that is knowable from the
|
||||
* arguments alone.
|
||||
*
|
||||
* The hub keys supervision by (channel, instanceId), so it needs the id
|
||||
* before anything is spawned. Adapters that can only determine it with a side
|
||||
* effect — Telegram resolves its bot username from the API when the flag is
|
||||
* omitted — return undefined, and the caller falls back to starting the
|
||||
* connector locally.
|
||||
*/
|
||||
resolveInstanceId(rawArgs: string[]): string | undefined {
|
||||
let options: Options;
|
||||
try {
|
||||
options = this.parseArgs(rawArgs);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const instanceId = this.instanceIdFromOptions(options);
|
||||
return instanceId?.trim() ? instanceId.trim() : undefined;
|
||||
}
|
||||
|
||||
protected instanceIdFromOptions(_options: Options): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
|
||||
let options: Options;
|
||||
try {
|
||||
@@ -170,6 +264,49 @@ export abstract class ConnectorBase<Options, State>
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusively claim the connector state path for this process before
|
||||
* connecting to Slack/Discord/etc. Prevents two foreground (`-i`) or
|
||||
* racing detached launches from both opening socket-mode with the same
|
||||
* bot token.
|
||||
*/
|
||||
protected claimConnectorInstance(input: {
|
||||
statePath: string;
|
||||
createState: (claimId: string) => State & { claimId: string; pid: number };
|
||||
readState: (path: string) => State | undefined;
|
||||
getPid: (state: State) => number;
|
||||
}): { claimed: true; claimId: string } | { claimed: false; running?: State } {
|
||||
const existing = input.readState(input.statePath);
|
||||
if (existing && isProcessRunning(input.getPid(existing))) {
|
||||
return { claimed: false, running: existing };
|
||||
}
|
||||
const claim = tryClaimConnectorStateFile(
|
||||
input.statePath,
|
||||
input.createState,
|
||||
);
|
||||
if (!claim) {
|
||||
const raced = input.readState(input.statePath);
|
||||
return {
|
||||
claimed: false,
|
||||
...(raced ? { running: raced } : {}),
|
||||
};
|
||||
}
|
||||
return { claimed: true, claimId: claim.claimId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a detached child's stdout/stderr is captured. Without this the
|
||||
* child is spawned with stdio "ignore", so a child that dies during startup
|
||||
* takes its only diagnostic with it and the parent can report nothing but
|
||||
* "child exited before becoming ready".
|
||||
*/
|
||||
protected resolveDetachedLogPath(statePath: string): string {
|
||||
return resolveConnectorDebugLogPath(
|
||||
this.name,
|
||||
basename(statePath, ".json") || this.name,
|
||||
);
|
||||
}
|
||||
|
||||
protected async maybeRunInBackground(input: {
|
||||
rawArgs: string[];
|
||||
io: ConnectIo;
|
||||
@@ -184,7 +321,13 @@ export abstract class ConnectorBase<Options, State>
|
||||
launchFailureMessage: string;
|
||||
startupTimeoutMs?: number;
|
||||
}): Promise<number | undefined> {
|
||||
if (input.interactive || process.env[input.childEnvVar] === "1") {
|
||||
if (
|
||||
input.interactive ||
|
||||
process.env[input.childEnvVar] === "1" ||
|
||||
// A supervised connector is the process the hub is tracking, so it must
|
||||
// run the adapter here instead of handing off to a detached child.
|
||||
isSupervisedConnectorProcess()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const runningState = input.readState(input.statePath);
|
||||
@@ -192,10 +335,16 @@ export abstract class ConnectorBase<Options, State>
|
||||
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
const logPath = this.resolveDetachedLogPath(input.statePath);
|
||||
const pid = spawnDetachedConnector(
|
||||
["connect", this.name],
|
||||
input.rawArgs,
|
||||
input.childEnvVar,
|
||||
{
|
||||
logPath,
|
||||
component: `${this.name}-connect`,
|
||||
metadata: { statePath: input.statePath },
|
||||
},
|
||||
);
|
||||
if (!pid) {
|
||||
input.io.writeErr(input.launchFailureMessage);
|
||||
@@ -212,7 +361,7 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
if (!isProcessRunning(pid)) {
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: child exited before becoming ready`,
|
||||
`${input.launchFailureMessage}: child exited before becoming ready.${formatChildLogHint(logPath)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -222,7 +371,7 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
await terminateProcess(pid);
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
|
||||
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms.${formatChildLogHint(logPath)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -189,3 +191,344 @@ describe("readSessionReplyText", () => {
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryClaimConnectorStateFile", () => {
|
||||
it("claims an empty path and rejects a second live claim", async () => {
|
||||
const { mkdtempSync, readFileSync, rmSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { tryClaimConnectorStateFile } = await import("./common");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
try {
|
||||
const first = tryClaimConnectorStateFile(
|
||||
statePath,
|
||||
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
|
||||
{
|
||||
isRunning: () => true,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
);
|
||||
expect(first).toBeDefined();
|
||||
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as {
|
||||
claimId: string;
|
||||
pid: number;
|
||||
};
|
||||
expect(parsed.claimId).toBe(first?.claimId);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
const second = tryClaimConnectorStateFile(
|
||||
statePath,
|
||||
(claimId) => ({
|
||||
claimId,
|
||||
pid: process.pid + 1,
|
||||
userName: "bot",
|
||||
}),
|
||||
{
|
||||
isRunning: () => true,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
);
|
||||
expect(second).toBeUndefined();
|
||||
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a dead-pid claim", async () => {
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { tryClaimConnectorStateFile } = await import("./common");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ pid: 1, userName: "stale" }),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
const claimed = tryClaimConnectorStateFile(
|
||||
statePath,
|
||||
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
|
||||
{
|
||||
isRunning: (pid) => pid === process.pid,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
);
|
||||
expect(claimed).toBeDefined();
|
||||
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows only one contender to replace the same stale generation", async () => {
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const firstPayload = `${JSON.stringify({
|
||||
claimId: "first",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const secondPayload = `${JSON.stringify({
|
||||
claimId: "second",
|
||||
pid: 3,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
firstPayload,
|
||||
{
|
||||
isRunning: () => false,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
secondPayload,
|
||||
{
|
||||
isRunning: () => false,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(firstPayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers when a stale-generation guard owner exits before replacement", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const replacementPayload = `${JSON.stringify({
|
||||
claimId: "replacement",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const generation = createHash("sha256").update(stalePayload).digest("hex");
|
||||
const orphanedGuardPath = `${statePath}.${generation}.claim`;
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
writeFileSync(
|
||||
orphanedGuardPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
claimId: "orphaned",
|
||||
pid: 3,
|
||||
processStartToken: "process-3",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
replacementPayload,
|
||||
{
|
||||
isRunning: () => false,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not succeed a live stale-generation guard owner", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const replacementPayload = `${JSON.stringify({
|
||||
claimId: "replacement",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const generation = createHash("sha256").update(stalePayload).digest("hex");
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
writeFileSync(
|
||||
`${statePath}.${generation}.claim`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
claimId: "live",
|
||||
pid: 3,
|
||||
processStartToken: "process-3",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
replacementPayload,
|
||||
{
|
||||
isRunning: (pid) => pid === 3,
|
||||
getStartToken: (pid) => `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(stalePayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers when an orphaned guard pid belongs to a different process", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
|
||||
"node:fs"
|
||||
);
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
|
||||
const statePath = join(dir, "instance.json");
|
||||
const stalePayload = `${JSON.stringify({
|
||||
claimId: "stale",
|
||||
pid: 1,
|
||||
userName: "stale",
|
||||
})}
|
||||
`;
|
||||
const replacementPayload = `${JSON.stringify({
|
||||
claimId: "replacement",
|
||||
pid: 2,
|
||||
userName: "bot",
|
||||
})}
|
||||
`;
|
||||
const generation = createHash("sha256").update(stalePayload).digest("hex");
|
||||
writeFileSync(statePath, stalePayload, "utf8");
|
||||
writeFileSync(
|
||||
`${statePath}.${generation}.claim`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
claimId: "orphaned",
|
||||
pid: 3,
|
||||
processStartToken: "original-process-3",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
expect(
|
||||
__test__.tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
stalePayload,
|
||||
replacementPayload,
|
||||
{
|
||||
isRunning: (pid) => pid === 3,
|
||||
getStartToken: (pid) =>
|
||||
pid === 3 ? "reused-process-3" : `process-${pid}`,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("detached connector log rotation", () => {
|
||||
it("keeps one generation once the log grows past the cap", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
|
||||
const logPath = join(dir, "cline-slack.log");
|
||||
writeFileSync(logPath, "x".repeat(__test__.DETACHED_LOG_MAX_BYTES + 1));
|
||||
|
||||
__test__.rotateOversizedLog(logPath);
|
||||
|
||||
expect(existsSync(logPath)).toBe(false);
|
||||
expect(readFileSync(`${logPath}.1`, "utf8").length).toBe(
|
||||
__test__.DETACHED_LOG_MAX_BYTES + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a small log in place so restarts keep their history", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
|
||||
const logPath = join(dir, "cline-slack.log");
|
||||
writeFileSync(logPath, "recent failure");
|
||||
|
||||
__test__.rotateOversizedLog(logPath);
|
||||
|
||||
expect(readFileSync(logPath, "utf8")).toBe("recent failure");
|
||||
expect(existsSync(`${logPath}.1`)).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when there is no log yet", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
|
||||
expect(() =>
|
||||
__test__.rotateOversizedLog(join(dir, "missing.log")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -28,6 +32,9 @@ export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
|
||||
*/
|
||||
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
|
||||
|
||||
/** Rotate a detached connector log once it passes this size. */
|
||||
const DETACHED_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -73,6 +80,70 @@ export function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessProbe = {
|
||||
isRunning: (pid: number) => boolean;
|
||||
getStartToken: (pid: number) => string | undefined;
|
||||
};
|
||||
|
||||
function getProcessStartToken(pid: number): string | undefined {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
if (commandEnd < 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Fields after the command name begin at field 3 (state), so field
|
||||
// 22 (starttime) is index 19.
|
||||
const startTime = stat
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/)[19];
|
||||
const bootId = readFileSync(
|
||||
"/proc/sys/kernel/random/boot_id",
|
||||
"utf8",
|
||||
).trim();
|
||||
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
|
||||
}
|
||||
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? spawnSync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
const startTime = result.status === 0 ? result.stdout.trim() : "";
|
||||
return startTime ? `${process.platform}:${startTime}` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProcessProbe: ProcessProbe = {
|
||||
isRunning: isProcessRunning,
|
||||
getStartToken: getProcessStartToken,
|
||||
};
|
||||
|
||||
export async function terminateProcess(pid: number): Promise<boolean> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return false;
|
||||
@@ -164,12 +235,30 @@ export function resolveConnectorDebugLogPath(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectors are long-lived and restart often, so an append-only log would grow
|
||||
* without bound on a host that runs them for weeks. Keep one previous
|
||||
* generation and start fresh once the current one gets large.
|
||||
*/
|
||||
function rotateOversizedLog(path: string): void {
|
||||
try {
|
||||
if (statSync(path).size < DETACHED_LOG_MAX_BYTES) {
|
||||
return;
|
||||
}
|
||||
rmSync(`${path}.1`, { force: true });
|
||||
renameSync(path, `${path}.1`);
|
||||
} catch {
|
||||
// No log yet, or it cannot be rotated: appending is still fine.
|
||||
}
|
||||
}
|
||||
|
||||
function tryOpenDetachedLogFd(path: string | undefined): number | undefined {
|
||||
if (!path?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
ensureParentDir(path);
|
||||
rotateOversizedLog(path);
|
||||
return openSync(path, "a");
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -269,6 +358,9 @@ export const __test__ = {
|
||||
buildDetachedConnectorArgs,
|
||||
buildDetachedConnectorCommand,
|
||||
buildDetachedConnectorEnv,
|
||||
tryReplaceStaleConnectorStateFile,
|
||||
rotateOversizedLog,
|
||||
DETACHED_LOG_MAX_BYTES,
|
||||
};
|
||||
|
||||
export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
@@ -289,6 +381,203 @@ export function writeJsonFile(path: string, value: unknown): void {
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a connector state path for this process.
|
||||
*
|
||||
* Uses O_EXCL so two concurrent `cline connect` launches cannot both observe
|
||||
* "no running instance" and both proceed. Returns undefined when another live
|
||||
* connector already owns the path (or a concurrent claim won the race).
|
||||
*/
|
||||
export function tryClaimConnectorStateFile(
|
||||
statePath: string,
|
||||
createState: (
|
||||
claimId: string,
|
||||
) => { claimId: string; pid: number } & Record<string, unknown>,
|
||||
processProbe: ProcessProbe = defaultProcessProbe,
|
||||
): { claimId: string } | undefined {
|
||||
ensureParentDir(statePath);
|
||||
const claimId = randomUUID();
|
||||
const state = createState(claimId);
|
||||
const payload = `${JSON.stringify(state, null, 2)}
|
||||
`;
|
||||
|
||||
if (tryCreateConnectorStateFile(statePath, payload)) {
|
||||
return { claimId };
|
||||
}
|
||||
|
||||
let observedPayload: string;
|
||||
try {
|
||||
observedPayload = readFileSync(statePath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const existing = JSON.parse(observedPayload) as { pid?: unknown };
|
||||
const existingPid =
|
||||
typeof existing.pid === "number" ? existing.pid : undefined;
|
||||
if (existingPid !== undefined && processProbe.isRunning(existingPid)) {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
observedPayload,
|
||||
payload,
|
||||
processProbe,
|
||||
)
|
||||
? { claimId }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function tryCreateConnectorStateFile(
|
||||
statePath: string,
|
||||
payload: string,
|
||||
): boolean {
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(statePath, "wx");
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined;
|
||||
if (code === "EEXIST") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
writeFileSync(fd, payload, "utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces exactly the stale generation that the caller observed.
|
||||
*
|
||||
* Each contender atomically links its ownership record into a guard keyed by
|
||||
* the observed generation. A live guard owner blocks replacement. If an owner
|
||||
* dies in the critical section, contenders append a successor guard rather
|
||||
* than deleting the existing one, so stale recovery remains crash-safe.
|
||||
*/
|
||||
function tryReplaceStaleConnectorStateFile(
|
||||
statePath: string,
|
||||
observedPayload: string,
|
||||
replacementPayload: string,
|
||||
processProbe: ProcessProbe = defaultProcessProbe,
|
||||
): boolean {
|
||||
let replacement: { claimId?: unknown; pid?: unknown };
|
||||
try {
|
||||
replacement = JSON.parse(replacementPayload) as {
|
||||
claimId?: unknown;
|
||||
pid?: unknown;
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof replacement.claimId !== "string" ||
|
||||
typeof replacement.pid !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const generation = createHash("sha256").update(observedPayload).digest("hex");
|
||||
const ownerPayload = `${JSON.stringify(
|
||||
{
|
||||
claimId: replacement.claimId,
|
||||
pid: replacement.pid,
|
||||
processStartToken: processProbe.getStartToken(replacement.pid),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`;
|
||||
const candidatePath = `${statePath}.${replacement.claimId}.candidate`;
|
||||
if (!tryCreateConnectorStateFile(candidatePath, ownerPayload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const guardPaths: string[] = [];
|
||||
let acquiredGuard = false;
|
||||
try {
|
||||
let guardPath = `${statePath}.${generation}.claim`;
|
||||
while (true) {
|
||||
guardPaths.push(guardPath);
|
||||
try {
|
||||
linkSync(candidatePath, guardPath);
|
||||
acquiredGuard = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined;
|
||||
if (code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let guardPayload: string;
|
||||
try {
|
||||
guardPayload = readFileSync(guardPath, "utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const guardOwner = JSON.parse(guardPayload) as {
|
||||
pid?: unknown;
|
||||
processStartToken?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof guardOwner.pid === "number" &&
|
||||
processProbe.isRunning(guardOwner.pid)
|
||||
) {
|
||||
const runningStartToken = processProbe.getStartToken(guardOwner.pid);
|
||||
if (
|
||||
typeof guardOwner.processStartToken !== "string" ||
|
||||
runningStartToken === undefined ||
|
||||
runningStartToken === guardOwner.processStartToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid ownership metadata cannot identify a live owner.
|
||||
}
|
||||
|
||||
const successor = createHash("sha256")
|
||||
.update(guardPath)
|
||||
.update("\0")
|
||||
.update(guardPayload)
|
||||
.digest("hex");
|
||||
guardPath = `${statePath}.${generation}.${successor}.claim`;
|
||||
}
|
||||
|
||||
if (readFileSync(statePath, "utf8") !== observedPayload) {
|
||||
return false;
|
||||
}
|
||||
rmSync(statePath);
|
||||
return tryCreateConnectorStateFile(statePath, replacementPayload);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
rmSync(candidatePath, { force: true });
|
||||
if (acquiredGuard) {
|
||||
for (const guardPath of guardPaths) {
|
||||
rmSync(guardPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SentMessage } from "chat";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { enqueueThreadTurn } from "./chat-runtime";
|
||||
import { handleConnectorUserTurn } from "./connector-host";
|
||||
|
||||
vi.mock("./hooks", () => ({
|
||||
@@ -43,7 +44,22 @@ function createThread(initialState: TestState = {}, isDM = true) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
async post(message: unknown) {
|
||||
posts.push(message);
|
||||
// Real thread implementations drain async-iterable replies; the
|
||||
// connector streams runtime output straight into post() for
|
||||
// transports without a custom final-reply hook.
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
Symbol.asyncIterator in message
|
||||
) {
|
||||
let streamed = "";
|
||||
for await (const chunk of message as AsyncIterable<string>) {
|
||||
streamed += chunk;
|
||||
}
|
||||
posts.push(streamed);
|
||||
} else {
|
||||
posts.push(message);
|
||||
}
|
||||
const sentMessage = {
|
||||
edit: async (nextMessage: unknown) => {
|
||||
posts.push(nextMessage);
|
||||
@@ -99,13 +115,21 @@ function createRuntimeClient(
|
||||
);
|
||||
const abortRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const sendRuntimeSession = vi.fn(async () => ({
|
||||
result: {
|
||||
text: responseText,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
}));
|
||||
const sendRuntimeSession = vi.fn(
|
||||
async (
|
||||
_sessionId: string,
|
||||
_request?: unknown,
|
||||
_options?: unknown,
|
||||
): Promise<{
|
||||
result?: { text: string; finishReason: string; iterations: number };
|
||||
}> => ({
|
||||
result: {
|
||||
text: responseText,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const readMessages = vi.fn(async () => messages);
|
||||
return {
|
||||
client: {
|
||||
@@ -138,6 +162,10 @@ function messageText(message: unknown): string {
|
||||
return String(message);
|
||||
}
|
||||
|
||||
async function runTurnImmediately(work: () => Promise<void>): Promise<void> {
|
||||
await work();
|
||||
}
|
||||
|
||||
describe("handleConnectorUserTurn", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -150,6 +178,52 @@ describe("handleConnectorUserTurn", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("posts no greeting when the adapter configures none", async () => {
|
||||
// Slack deliberately configures no first-contact message: the greeting is
|
||||
// gated on per-thread state, so a restart or a cleared history replayed it
|
||||
// on the user's next message.
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
participantKey: "slack:user:alice",
|
||||
participantLabel: "alice",
|
||||
});
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
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: "cline-slack",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
text: "/whereami",
|
||||
});
|
||||
|
||||
expect(
|
||||
posts.filter((message) => messageText(message).includes("Connected")),
|
||||
).toEqual([]);
|
||||
// The turn itself still answers.
|
||||
expect(messageText(posts.at(-1))).toContain(
|
||||
"participantKey=slack:user:alice",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a first-contact message only once per persisted thread state", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -542,6 +616,391 @@ 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("recovers when the bound session is wedged on a run that never drained", async () => {
|
||||
// Cline Mom's failure: the thread pointed at a session whose runtime still
|
||||
// had a run in flight, so every message came back as "SessionRuntime.shutdown
|
||||
// called while a run is in progress" instead of answering.
|
||||
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: "wedged-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 === "wedged-session") {
|
||||
// Crossing the hub's JSON boundary strips the error class, so only the
|
||||
// message survives — which is exactly what the connector sees.
|
||||
throw new Error(
|
||||
"SessionRuntime.shutdown called while a run is in progress (agentId=agent_123)",
|
||||
);
|
||||
}
|
||||
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(["wedged-session", "fresh-session"]);
|
||||
// The stale mapping is replaced, so the thread is not wedged next time.
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("run is in progress"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry forever when the replacement session is also missing", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("never delivered");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "also-dead-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async () => {
|
||||
throw Object.assign(new Error("session not found"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "are you there?",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
}),
|
||||
).rejects.toThrow(/session not found/);
|
||||
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts a new session when steering an active turn hits a dead session", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("recovered reply");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "dead-session") {
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
text: "recovered reply",
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
const activeTurns = new Map([
|
||||
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "actually do this instead",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns: activeTurns as never,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(messageText(posts.at(-1))).toBe("recovered reply");
|
||||
expect(
|
||||
posts.some((message) =>
|
||||
messageText(message).includes("Steering current task."),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes concurrent recovery from the same stale active turn", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
sessionId: "dead-session",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runtime = createRuntimeClient("unused");
|
||||
runtime.getSession.mockImplementation(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
}));
|
||||
runtime.startRuntimeSession.mockResolvedValue({
|
||||
sessionId: "fresh-session",
|
||||
});
|
||||
|
||||
let releaseStaleSteers = () => {};
|
||||
const bothStaleSteersStarted = new Promise<void>((resolve) => {
|
||||
releaseStaleSteers = resolve;
|
||||
});
|
||||
let staleSteerCount = 0;
|
||||
runtime.sendRuntimeSession.mockImplementation(
|
||||
async (sessionId: string, request?: unknown) => {
|
||||
if (sessionId === "dead-session") {
|
||||
staleSteerCount += 1;
|
||||
if (staleSteerCount === 2) {
|
||||
releaseStaleSteers();
|
||||
}
|
||||
await bothStaleSteersStarted;
|
||||
throw Object.assign(new Error("session not found: dead-session"), {
|
||||
code: "session_not_found",
|
||||
});
|
||||
}
|
||||
const prompt =
|
||||
request && typeof request === "object" && "prompt" in request
|
||||
? String((request as { prompt?: unknown }).prompt)
|
||||
: "";
|
||||
return {
|
||||
result: {
|
||||
text: `recovered: ${prompt}`,
|
||||
finishReason: "stop",
|
||||
iterations: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const activeTurns = new Map([
|
||||
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
|
||||
]);
|
||||
const threadQueues = new Map<string, Promise<void>>();
|
||||
const enqueueTurn = (work: () => Promise<void>) =>
|
||||
enqueueThreadTurn(threadQueues, "thread-1", work);
|
||||
const commonInput = {
|
||||
thread: thread as never,
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "slack",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Slack",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn,
|
||||
turnKey: "thread-1",
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
handleConnectorUserTurn({
|
||||
...commonInput,
|
||||
text: "first recovery message",
|
||||
}),
|
||||
handleConnectorUserTurn({
|
||||
...commonInput,
|
||||
text: "second recovery message",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
|
||||
).toEqual([
|
||||
"dead-session",
|
||||
"dead-session",
|
||||
"fresh-session",
|
||||
"fresh-session",
|
||||
]);
|
||||
expect(getState().sessionId).toBe("fresh-session");
|
||||
expect(activeTurns.size).toBe(0);
|
||||
expect(posts.map(messageText)).toEqual([
|
||||
"recovered: first recovery message",
|
||||
"recovered: second recovery message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates schedules with forced-disabled runtime options", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
@@ -977,6 +1436,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "current-participant",
|
||||
});
|
||||
|
||||
@@ -1225,6 +1685,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
resolveMuteTarget: () => ({
|
||||
participantKey: "discord:user:bob",
|
||||
participantLabel: "<@bob>",
|
||||
@@ -1426,6 +1887,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
|
||||
@@ -1436,7 +1898,11 @@ describe("handleConnectorUserTurn", () => {
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
|
||||
// Handing the follow-up to the running session is silent: no acknowledgement
|
||||
// line is added to the thread.
|
||||
expect(
|
||||
posts.some((message) => messageText(message).includes("Steering")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("steers when the same session is active under a different turn key", async () => {
|
||||
@@ -1476,6 +1942,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
@@ -1487,7 +1954,11 @@ describe("handleConnectorUserTurn", () => {
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
|
||||
// Handing the follow-up to the running session is silent: no acknowledgement
|
||||
// line is added to the thread.
|
||||
expect(
|
||||
posts.some((message) => messageText(message).includes("Steering")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a normal turn when the active session is in a different thread", async () => {
|
||||
@@ -1526,6 +1997,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
enqueueTurn: runTurnImmediately,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
HubSessionClient,
|
||||
UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { isUnusableSessionError } from "@cline/core";
|
||||
import type { SentMessage, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import {
|
||||
buildThreadStartRequest,
|
||||
clearSession,
|
||||
forgetThreadSession,
|
||||
getOrCreateSessionId,
|
||||
} from "./session-runtime";
|
||||
import {
|
||||
@@ -51,6 +53,20 @@ export type ActiveConnectorTurn = {
|
||||
participantKey?: string;
|
||||
};
|
||||
|
||||
type ConnectorTurnQueue = (work: () => Promise<void>) => Promise<void>;
|
||||
|
||||
type ConnectorTurnCoordination =
|
||||
| {
|
||||
activeTurns: Map<string, ActiveConnectorTurn>;
|
||||
enqueueTurn: ConnectorTurnQueue;
|
||||
turnKey?: string;
|
||||
}
|
||||
| {
|
||||
activeTurns?: undefined;
|
||||
enqueueTurn?: undefined;
|
||||
turnKey?: string;
|
||||
};
|
||||
|
||||
type EmptyRuntimeReplyResolver = () => Promise<string | undefined>;
|
||||
|
||||
type EmptyRuntimeReplyResolverFactory = (input: {
|
||||
@@ -135,6 +151,45 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
|
||||
await postConnectorText(thread, transport, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a thread's stale session mapping after the hub reported the mapped
|
||||
* session no longer exists, so the next turn starts a fresh session instead of
|
||||
* failing forever against a dead session id.
|
||||
*/
|
||||
async function forgetStaleThreadSession<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
logger: CliLoggerAdapter;
|
||||
transport: string;
|
||||
sessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const forgotten = await forgetThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
expectedSessionId: input.sessionId,
|
||||
});
|
||||
if (!forgotten) {
|
||||
return false;
|
||||
}
|
||||
input.logger.core.log(
|
||||
"Connector thread session no longer exists; starting a new session",
|
||||
{
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: input.sessionId,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyForcedToolDisable<TState extends ConnectorThreadState>(
|
||||
state: TState,
|
||||
forceDisableTools: boolean | undefined,
|
||||
@@ -200,9 +255,7 @@ function formatMuteTargetList(targets: ConnectorMuteTarget[]): string {
|
||||
return targets.map(formatMuteTargetLabel).join(", ");
|
||||
}
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
|
||||
thread: Thread<TState>;
|
||||
text: string;
|
||||
runtimeText?: string;
|
||||
@@ -231,8 +284,6 @@ export async function handleConnectorUserTurn<
|
||||
firstContactMessage?: string | ((currentState: TState) => string | undefined);
|
||||
chatCommandHost?: ChatCommandHost;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
activeTurns?: Map<string, ActiveConnectorTurn>;
|
||||
turnKey?: string;
|
||||
resolveMuteTarget?: (input: {
|
||||
target: string;
|
||||
thread: Thread<TState>;
|
||||
@@ -276,7 +327,11 @@ export async function handleConnectorUserTurn<
|
||||
threadId: string;
|
||||
error: Error;
|
||||
}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
} & ConnectorTurnCoordination;
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: ConnectorUserTurnInput<TState>): Promise<void> {
|
||||
const resolvedInput = input.text.trim();
|
||||
if (!resolvedInput) {
|
||||
return;
|
||||
@@ -892,56 +947,107 @@ export async function handleConnectorUserTurn<
|
||||
input.baseStartRequest,
|
||||
effectiveCurrentState,
|
||||
);
|
||||
const activeTurn =
|
||||
input.activeTurns?.get(turnKey) ??
|
||||
(input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.values()).find(
|
||||
(turn) =>
|
||||
const keyedActiveTurn = input.activeTurns?.get(turnKey);
|
||||
const activeTurnEntry = keyedActiveTurn
|
||||
? ([turnKey, keyedActiveTurn] as const)
|
||||
: input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.entries()).find(
|
||||
([, turn]) =>
|
||||
turn.sessionId === currentState.sessionId?.trim() &&
|
||||
turn.threadId === input.thread.id,
|
||||
)
|
||||
: undefined);
|
||||
if (activeTurn?.sessionId?.trim()) {
|
||||
: undefined;
|
||||
if (activeTurnEntry?.[1].sessionId?.trim()) {
|
||||
const [activeTurnKey, activeTurn] = activeTurnEntry;
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
);
|
||||
await input.client.sendRuntimeSession(
|
||||
activeTurn.sessionId,
|
||||
{
|
||||
config: startRequest,
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
await postConnectorText(
|
||||
input.thread,
|
||||
input.transport,
|
||||
"Steering current task.",
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
activeTurn.sessionId,
|
||||
{
|
||||
config: startRequest,
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isUnusableSessionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// The tracked turn points at a session that can no longer serve it —
|
||||
// the hub does not know it, or its runtime is stuck on a run that never
|
||||
// drained.
|
||||
// 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;
|
||||
}
|
||||
// No acknowledgement: the follow-up is handed to the running session and its
|
||||
// effect shows up in the answer. Announcing it added a line to every thread
|
||||
// and overstated what happens, since the prompt is queued for the session
|
||||
// rather than injected into the loop already running.
|
||||
return;
|
||||
}
|
||||
const sessionId = await getOrCreateSessionId({
|
||||
thread: input.thread,
|
||||
client: input.client,
|
||||
startRequest,
|
||||
logger: input.logger,
|
||||
clientId: input.clientId,
|
||||
transport: input.transport,
|
||||
bindingsPath: input.bindingsPath,
|
||||
errorLabel: input.errorLabel,
|
||||
hookCommand: input.hookCommand,
|
||||
hookBotUserName: input.botUserName,
|
||||
sessionMetadata: input.getSessionMetadata(
|
||||
input.thread,
|
||||
input.clientId,
|
||||
currentState,
|
||||
),
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
|
||||
await runConnectorRuntimeTurnWithRecovery({
|
||||
input,
|
||||
runtimeInput,
|
||||
turnKey,
|
||||
currentState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a queued connector turn, replacing a stale session mapping at most once
|
||||
* before replaying the user's input.
|
||||
*/
|
||||
async function runConnectorRuntimeTurnWithRecovery<
|
||||
TState extends ConnectorThreadState,
|
||||
>(params: {
|
||||
input: ConnectorUserTurnInput<TState>;
|
||||
runtimeInput: string;
|
||||
turnKey: string;
|
||||
currentState?: TState;
|
||||
staleSessionId?: string;
|
||||
}): Promise<void> {
|
||||
const { input, runtimeInput, turnKey } = params;
|
||||
const currentState =
|
||||
params.currentState ??
|
||||
(await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
));
|
||||
const effectiveCurrentState = applyForcedToolDisable(
|
||||
currentState,
|
||||
input.forceDisableTools,
|
||||
);
|
||||
const startRequest = buildThreadStartRequest(
|
||||
input.baseStartRequest,
|
||||
effectiveCurrentState,
|
||||
);
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
@@ -951,16 +1057,107 @@ export async function handleConnectorUserTurn<
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
};
|
||||
if (params.staleSessionId) {
|
||||
await forgetStaleThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
logger: input.logger,
|
||||
transport: input.transport,
|
||||
sessionId: params.staleSessionId,
|
||||
});
|
||||
}
|
||||
// A thread binding can outlive its runtime session (hub restart, session
|
||||
// deletion, retention cleanup). When that happens the turn fails with
|
||||
// `session_not_found`; drop the stale mapping and replay the turn once
|
||||
// against a brand new session instead of wedging the thread forever.
|
||||
const resolveSessionId = () =>
|
||||
getOrCreateSessionId({
|
||||
thread: input.thread,
|
||||
client: input.client,
|
||||
startRequest,
|
||||
logger: input.logger,
|
||||
clientId: input.clientId,
|
||||
transport: input.transport,
|
||||
bindingsPath: input.bindingsPath,
|
||||
errorLabel: input.errorLabel,
|
||||
hookCommand: input.hookCommand,
|
||||
hookBotUserName: input.botUserName,
|
||||
sessionMetadata: input.getSessionMetadata(
|
||||
input.thread,
|
||||
input.clientId,
|
||||
currentState,
|
||||
),
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
});
|
||||
let sessionId = await resolveSessionId();
|
||||
let allowStaleSessionRetry = params.staleSessionId === undefined;
|
||||
for (;;) {
|
||||
try {
|
||||
await runConnectorRuntimeTurn({
|
||||
input,
|
||||
sessionId,
|
||||
request,
|
||||
currentState,
|
||||
turnKey,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!allowStaleSessionRetry || !isUnusableSessionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
allowStaleSessionRetry = false;
|
||||
await forgetStaleThreadSession({
|
||||
thread: input.thread,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
errorLabel: input.errorLabel,
|
||||
logger: input.logger,
|
||||
transport: input.transport,
|
||||
sessionId,
|
||||
});
|
||||
sessionId = await resolveSessionId();
|
||||
}
|
||||
}
|
||||
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...currentState,
|
||||
sessionId,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single connector turn against an already-resolved session and streams
|
||||
* the reply back into the thread.
|
||||
*/
|
||||
async function runConnectorRuntimeTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(params: {
|
||||
input: ConnectorUserTurnInput<TState>;
|
||||
sessionId: string;
|
||||
request: ChatRunTurnRequest;
|
||||
currentState: TState;
|
||||
turnKey: string;
|
||||
}): Promise<void> {
|
||||
const { input, sessionId, request, currentState, turnKey } = params;
|
||||
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
|
||||
client: input.client,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
input.activeTurns?.set(turnKey, {
|
||||
const activeTurn: ActiveConnectorTurn = {
|
||||
sessionId,
|
||||
threadId: input.thread.id,
|
||||
participantKey: currentState.participantKey,
|
||||
});
|
||||
};
|
||||
input.activeTurns?.set(turnKey, activeTurn);
|
||||
await input.thread.startTyping();
|
||||
let toolStatusMessage: SentMessage | undefined;
|
||||
const postFinalReply = input.postFinalReply
|
||||
@@ -1025,21 +1222,13 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
} finally {
|
||||
input.pendingApprovals.delete(input.thread.id);
|
||||
input.activeTurns?.delete(turnKey);
|
||||
if (input.activeTurns?.get(turnKey) === activeTurn) {
|
||||
input.activeTurns.delete(turnKey);
|
||||
}
|
||||
if (toolStatusMessage) {
|
||||
await toolStatusMessage.delete().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...currentState,
|
||||
sessionId,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function maybeHandleConnectorApprovalReply<
|
||||
|
||||
@@ -62,7 +62,10 @@ vi.mock("../commands/auth", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
import {
|
||||
buildConnectorStartRequest,
|
||||
isReusableConnectorSession,
|
||||
} from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
@@ -161,3 +164,33 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("isReusableConnectorSession", () => {
|
||||
it("rejects missing and terminal sessions", () => {
|
||||
expect(isReusableConnectorSession(undefined)).toBe(false);
|
||||
expect(isReusableConnectorSession({ sessionId: "" })).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "completed" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "failed" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "aborted" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "cancelled" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts live and status-omitted sessions", () => {
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "running" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReusableConnectorSession({ sessionId: "s1", status: "idle" }),
|
||||
).toBe(true);
|
||||
expect(isReusableConnectorSession({ sessionId: "s1" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,29 @@ export function buildThreadStartRequest<TState extends ConnectorThreadState>(
|
||||
};
|
||||
}
|
||||
|
||||
/** Terminal hub statuses are not reusable for a new connector turn. */
|
||||
const TERMINAL_HUB_SESSION_STATUSES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export function isReusableConnectorSession(
|
||||
session: { sessionId?: string; status?: string } | undefined | null,
|
||||
): boolean {
|
||||
if (!session?.sessionId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
const status = session.status?.trim().toLowerCase();
|
||||
if (!status) {
|
||||
// Older hubs omit status; treat presence as reusable and let send-time
|
||||
// session_not_found recovery handle true zombies.
|
||||
return true;
|
||||
}
|
||||
return !TERMINAL_HUB_SESSION_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export async function getOrCreateSessionId<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
@@ -162,7 +185,7 @@ export async function getOrCreateSessionId<
|
||||
const existing = threadState.sessionId?.trim();
|
||||
if (existing) {
|
||||
const existingSession = await input.client.getSession(existing);
|
||||
if (existingSession) {
|
||||
if (isReusableConnectorSession(existingSession)) {
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
@@ -204,12 +227,17 @@ export async function getOrCreateSessionId<
|
||||
input.errorLabel,
|
||||
);
|
||||
input.logger.core.log(
|
||||
"Connector thread session missing; starting a new session",
|
||||
existingSession
|
||||
? "Connector thread session is terminal; starting a new session"
|
||||
: "Connector thread session missing; starting a new session",
|
||||
{
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
...(existingSession?.status
|
||||
? { status: existingSession.status }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -283,6 +311,43 @@ export async function getOrCreateSessionId<
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a thread's session mapping without touching the runtime session, but
|
||||
* only when it still points at the session the caller observed as stale.
|
||||
*
|
||||
* Used when the hub reports the mapped session no longer exists: the thread
|
||||
* binding may have been recovered concurrently, so a newer session id must
|
||||
* never be cleared by an older failure.
|
||||
*/
|
||||
export async function forgetThreadSession<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
expectedSessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const threadState = await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
);
|
||||
if (threadState.sessionId?.trim() !== input.expectedSessionId.trim()) {
|
||||
return false;
|
||||
}
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: undefined,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearSession<TState extends ConnectorThreadState>(input: {
|
||||
thread: Thread<TState>;
|
||||
client: HubSessionClient;
|
||||
|
||||
@@ -153,6 +153,26 @@ export function writeBindings<TState extends ConnectorThreadState>(
|
||||
writeJsonFile(path, bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key under which turns for `thread` must be serialised.
|
||||
*
|
||||
* This has to follow the same identity rule as {@link findBindingForThread},
|
||||
* because whatever shares a session has to share a queue. A DM reuses one
|
||||
* binding — and therefore one runtime session — for every message in the
|
||||
* channel, so keying the queue by thread id would let two messages in the same
|
||||
* DM run against that one session concurrently. That surfaces as
|
||||
* "SessionRuntime.shutdown called while a run is in progress", or as two
|
||||
* conversations interleaved in one session's history.
|
||||
*
|
||||
* Channel threads each own their binding, so they keep their own key and go on
|
||||
* running independently of one another.
|
||||
*/
|
||||
export function resolveThreadTurnQueueKey(
|
||||
thread: Pick<ConnectorBindingThreadIdentity, "id" | "channelId" | "isDM">,
|
||||
): string {
|
||||
return thread.isDM ? `dm:${thread.channelId}` : thread.id;
|
||||
}
|
||||
|
||||
export function findBindingForThread<TState extends ConnectorThreadState>(
|
||||
bindings: ConnectorBindingStore<TState>,
|
||||
thread: ConnectorBindingThreadIdentity,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enqueueThreadTurn } from "./chat-runtime";
|
||||
import { resolveThreadTurnQueueKey } from "./thread-bindings";
|
||||
|
||||
describe("resolveThreadTurnQueueKey", () => {
|
||||
it("gives every channel thread its own key", () => {
|
||||
// Channel threads each own a binding and a session, so they run in parallel.
|
||||
const first = resolveThreadTurnQueueKey({
|
||||
id: "slack:C1:1111.1",
|
||||
channelId: "slack:C1",
|
||||
isDM: false,
|
||||
});
|
||||
const second = resolveThreadTurnQueueKey({
|
||||
id: "slack:C1:2222.2",
|
||||
channelId: "slack:C1",
|
||||
isDM: false,
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).toBe("slack:C1:1111.1");
|
||||
});
|
||||
|
||||
it("collapses every message in one DM onto a single key", () => {
|
||||
// findBindingForThread reuses one binding for a whole DM channel, so those
|
||||
// messages share a session and must not run concurrently.
|
||||
const first = resolveThreadTurnQueueKey({
|
||||
id: "slack:D1:1111.1",
|
||||
channelId: "slack:D1",
|
||||
isDM: true,
|
||||
});
|
||||
const second = resolveThreadTurnQueueKey({
|
||||
id: "slack:D1:2222.2",
|
||||
channelId: "slack:D1",
|
||||
isDM: true,
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("keeps separate DM channels separate", () => {
|
||||
expect(
|
||||
resolveThreadTurnQueueKey({
|
||||
id: "slack:D1:1111.1",
|
||||
channelId: "slack:D1",
|
||||
isDM: true,
|
||||
}),
|
||||
).not.toBe(
|
||||
resolveThreadTurnQueueKey({
|
||||
id: "slack:D2:1111.1",
|
||||
channelId: "slack:D2",
|
||||
isDM: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("thread turn scheduling", () => {
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it("runs two messages in the same DM one after the other", async () => {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
const dm = { id: "slack:D1:1.1", channelId: "slack:D1", isDM: true };
|
||||
const later = { id: "slack:D1:2.2", channelId: "slack:D1", isDM: true };
|
||||
const order: string[] = [];
|
||||
const first = deferred();
|
||||
|
||||
const firstTurn = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(dm),
|
||||
async () => {
|
||||
order.push("first:start");
|
||||
await first.promise;
|
||||
order.push("first:end");
|
||||
},
|
||||
);
|
||||
const secondTurn = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(later),
|
||||
async () => {
|
||||
order.push("second:start");
|
||||
},
|
||||
);
|
||||
|
||||
// The second message must not touch the shared session until the first
|
||||
// message's run has finished.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(order).toEqual(["first:start"]);
|
||||
|
||||
first.resolve();
|
||||
await Promise.all([firstTurn, secondTurn]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second:start"]);
|
||||
});
|
||||
|
||||
it("runs two channel threads at the same time", async () => {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
const threadA = { id: "slack:C1:1.1", channelId: "slack:C1", isDM: false };
|
||||
const threadB = { id: "slack:C1:2.2", channelId: "slack:C1", isDM: false };
|
||||
const order: string[] = [];
|
||||
const blocked = deferred();
|
||||
|
||||
const turnA = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(threadA),
|
||||
async () => {
|
||||
order.push("a:start");
|
||||
await blocked.promise;
|
||||
order.push("a:end");
|
||||
},
|
||||
);
|
||||
const turnB = enqueueThreadTurn(
|
||||
queues,
|
||||
resolveThreadTurnQueueKey(threadB),
|
||||
async () => {
|
||||
order.push("b:start");
|
||||
},
|
||||
);
|
||||
|
||||
// B answers while A is still working: separate threads, separate sessions.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(order).toEqual(["a:start", "b:start"]);
|
||||
|
||||
blocked.resolve();
|
||||
await Promise.all([turnA, turnB]);
|
||||
expect(order).toEqual(["a:start", "b:start", "a:end"]);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,12 @@ export interface ConnectCommandDefinition {
|
||||
): Promise<number>;
|
||||
validate(args: string[], io: ConnectIo): Promise<number>;
|
||||
showHelp(io: ConnectIo): void;
|
||||
/**
|
||||
* Instance id `args` would run as, when it is knowable without side effects.
|
||||
* The hub keys connector supervision by (channel, instanceId) and needs it
|
||||
* before spawning; undefined sends the caller to the local start path.
|
||||
*/
|
||||
resolveInstanceId?(args: string[]): string | undefined;
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
}
|
||||
|
||||
+10
-2
@@ -2,9 +2,10 @@
|
||||
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import {
|
||||
claimHubDaemonProcess,
|
||||
claimSupervisedConnectorProcess,
|
||||
disposeAll,
|
||||
initVcr,
|
||||
isHubDaemonProcess,
|
||||
setConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { logCliProcessError } from "./logging/errors";
|
||||
@@ -22,11 +23,18 @@ initVcr(process.env.CLINE_VCR);
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (isHubDaemonProcess()) {
|
||||
} else if (claimHubDaemonProcess()) {
|
||||
// Claim rather than read: the sentinel is consumed here so the processes a
|
||||
// daemon-hosted session spawns do not inherit it and try to become daemons.
|
||||
// The hub daemon owns its process-level abort handling. Installing the CLI's
|
||||
// fatal rejection handler first would make expected abort rejections exit it.
|
||||
void import("@cline/core/hub/daemon-entry");
|
||||
} else {
|
||||
// Same reasoning as the daemon sentinel above: consume the supervised-connector
|
||||
// marker so the processes an agent session spawns cannot inherit it and mistake
|
||||
// themselves for the connector the hub is tracking.
|
||||
claimSupervisedConnectorProcess();
|
||||
|
||||
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
|
||||
if (cliLaunchSpec) {
|
||||
setConnectorCliLaunchSpec({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMigrationNoticeKeyAction } from "./notice-dialog";
|
||||
|
||||
vi.mock("@opentui-ui/dialog/react", () => ({
|
||||
useDialogKeyboard: () => undefined,
|
||||
}));
|
||||
|
||||
describe("resolveMigrationNoticeKeyAction", () => {
|
||||
it("opens the subscription page on Enter", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "return" })).toBe("open");
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "enter" })).toBe("open");
|
||||
});
|
||||
|
||||
it("dismisses on Escape", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "escape" })).toBe("dismiss");
|
||||
});
|
||||
|
||||
it("dismisses on any other unmodified key so users are never stuck behind the promo", () => {
|
||||
for (const name of ["q", "x", "space", "tab", "backspace", "up"]) {
|
||||
expect(resolveMigrationNoticeKeyAction({ name })).toBe("dismiss");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores modifier-held keys so holding Cmd/Ctrl to click the link never dismisses", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "c", ctrl: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "x", meta: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "x", super: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
// A bare modifier press (empty name) is ignored, not a dismiss.
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "" })).toBe("ignore");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,34 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
} from "../tui/utils/dialog-keys";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import open from "../utils/open";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
/**
|
||||
* Enter opens the subscription page; any other (unmodified) key dismisses the
|
||||
* dialog; modifier-held keys are ignored.
|
||||
*
|
||||
* The dialog used to be dismissible only with Esc, but Esc is the least
|
||||
* reliable key across terminals (it arrives as a bare `\x1b` that needs
|
||||
* timeout disambiguation, and Windows console input layers are known to
|
||||
* swallow it), which left users stuck behind the promo with no way out.
|
||||
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
|
||||
* subscription link never dismisses the dialog mid-click.
|
||||
*/
|
||||
export function resolveMigrationNoticeKeyAction(
|
||||
key: DialogDismissKey,
|
||||
): "open" | "dismiss" | "ignore" {
|
||||
if (!isAnyKeyDismiss(key)) return "ignore";
|
||||
return key.name === "return" || key.name === "enter" ? "open" : "dismiss";
|
||||
}
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
notice: CliMigrationNotice;
|
||||
@@ -30,13 +52,13 @@ export function MigrationNoticeContent(
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
const action = resolveMigrationNoticeKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "open") {
|
||||
openSubscriptionPage();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
resolve(true);
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
@@ -48,7 +70,7 @@ export function MigrationNoticeContent(
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
<text selectable>Try it now with a limited-time promo for $4.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
@@ -61,7 +83,9 @@ export function MigrationNoticeContent(
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
<text fg={palette.muted}>
|
||||
Press Enter to open, any other key to close
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
+83
-68
@@ -66,6 +66,7 @@ const dashboardMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const connectMocks = vi.hoisted(() => ({
|
||||
formatAdapterList: vi.fn(() => ""),
|
||||
runCleanupConnectorInstance: vi.fn(async () => 0),
|
||||
runConnectAdapter: vi.fn(async () => 0),
|
||||
runRestartConnector: vi.fn(async () => 0),
|
||||
runStopAllConnectors: vi.fn(async () => 0),
|
||||
@@ -96,16 +97,11 @@ const worktreeMocks = vi.hoisted(() => ({
|
||||
createTaskWorktree: vi.fn(),
|
||||
}));
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryList: vi.fn<() => Promise<number | string>>(async () => 0),
|
||||
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
|
||||
runHistoryDelete: vi.fn(async () => 0),
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
const historyResumeMocks = vi.hoisted(() => ({
|
||||
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
const loggingMocks = vi.hoisted(() => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
@@ -212,7 +208,6 @@ 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);
|
||||
@@ -241,8 +236,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
historyMocks.runHistoryExport.mockResolvedValue(0);
|
||||
historyMocks.runHistoryUpdate.mockReset();
|
||||
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
||||
historyResumeMocks.spawnHistoryResume.mockReset();
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
|
||||
sessionMocks.getSessionRow.mockReset();
|
||||
sessionMocks.getSessionRow.mockResolvedValue({
|
||||
sessionId: "sess_123",
|
||||
@@ -324,6 +317,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -400,6 +397,68 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a supervised cleanup to one connector instance", async () => {
|
||||
connectMocks.runCleanupConnectorInstance.mockClear();
|
||||
connectMocks.runConnectAdapter.mockClear();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--cleanup-instance",
|
||||
"cline-slack",
|
||||
"slack",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(connectMocks.runCleanupConnectorInstance).toHaveBeenCalledWith(
|
||||
"slack",
|
||||
"cline-slack",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects combining cleanup with another connect mode", async () => {
|
||||
connectMocks.runCleanupConnectorInstance.mockClear();
|
||||
connectMocks.runStopConnector.mockClear();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--cleanup-instance",
|
||||
"cline-slack",
|
||||
"--stop",
|
||||
"slack",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await runCli();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
|
||||
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a channel for a supervised cleanup", async () => {
|
||||
connectMocks.runCleanupConnectorInstance.mockClear();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--cleanup-instance",
|
||||
"x",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await runCli();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a targeted connector restart to one instance", async () => {
|
||||
process.argv = [
|
||||
"bun",
|
||||
@@ -480,7 +539,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
@@ -617,10 +676,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
it("creates a worktree for default interactive mode", async () => {
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "--worktree"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -723,7 +778,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialView: undefined,
|
||||
startupTarget: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -734,10 +789,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -767,10 +818,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/test-model",
|
||||
});
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -875,7 +922,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialPrompt: "sup",
|
||||
initialView: undefined,
|
||||
startupTarget: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1115,65 +1162,33 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.anything(),
|
||||
"sess_123",
|
||||
expect.objectContaining({
|
||||
initialView: "chat",
|
||||
startupTarget: "chat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes a history-picked session in a child process", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess_from_history",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates the child exit code when resuming from history picker", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces chat view when the history-picker child cannot launch", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
|
||||
it("opens history inside the interactive TUI for the history picker", async () => {
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
||||
expect(
|
||||
migrationNoticeMocks.getClineCliMigrationNotice,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
"sess_from_history",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
initialPrompt: undefined,
|
||||
initialView: "chat",
|
||||
startupTarget: "history",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
+68
-153
@@ -5,6 +5,7 @@ import type { ToolPolicy } from "@cline/core";
|
||||
|
||||
import { registerDisposable } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { registerHistoryCommand } from "./commands/history-command";
|
||||
import {
|
||||
CommanderError,
|
||||
commanderToParsedArgs,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
getPreferredKanbanInstaller,
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import type { TuiStartupTarget } from "./tui/types";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
@@ -136,11 +138,19 @@ function writePromptArgError(args: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function startupTargetTakesPrecedenceOverMigrationNotice(
|
||||
target: TuiStartupTarget | undefined,
|
||||
): boolean {
|
||||
return target === "config" || target === "history";
|
||||
}
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
installStreamErrorGuards();
|
||||
autoUpdateOnStartup();
|
||||
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const isFullTTY =
|
||||
process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
const configDir = resolveConfigDirArg(cliArgs);
|
||||
const { setClineDir, setHomeDir } = await import("@cline/shared/storage");
|
||||
if (configDir) {
|
||||
@@ -154,11 +164,13 @@ export async function runCli(): Promise<void> {
|
||||
// `--config <dir>` rather than the default home/config location.
|
||||
captureCliExtensionActivated();
|
||||
|
||||
let launchConfigView = false;
|
||||
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
|
||||
|
||||
// Subcommand routing via Commander
|
||||
const ctx: { exitCode?: number; resumeSessionId?: string } = {};
|
||||
const ctx: {
|
||||
exitCode?: number;
|
||||
startupTarget?: TuiStartupTarget;
|
||||
} = {};
|
||||
const io = { writeln, writeErr };
|
||||
const program = createProgram();
|
||||
// Re-enable built-in help/version output for the routing program
|
||||
@@ -249,7 +261,7 @@ export async function runCli(): Promise<void> {
|
||||
ctx.exitCode = code;
|
||||
},
|
||||
() => {
|
||||
launchConfigView = true;
|
||||
ctx.startupTarget = "config";
|
||||
},
|
||||
);
|
||||
return configCmd;
|
||||
@@ -372,6 +384,10 @@ export async function runCli(): Promise<void> {
|
||||
"--restart-instance <id>",
|
||||
"Restart one connector instance (used by daemon recovery)",
|
||||
)
|
||||
.option(
|
||||
"--cleanup-instance <id>",
|
||||
"Reap one dead connector instance, preserving autostart (used by hub supervision)",
|
||||
)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.addHelpText(
|
||||
@@ -381,15 +397,34 @@ export async function runCli(): Promise<void> {
|
||||
.action(async (adapter: string | undefined) => {
|
||||
const {
|
||||
formatAdapterList,
|
||||
runCleanupConnectorInstance,
|
||||
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");
|
||||
const exclusiveModes = [
|
||||
opts.stop,
|
||||
opts.restart || opts.restartInstance,
|
||||
opts.cleanupInstance,
|
||||
].filter(Boolean).length;
|
||||
if (exclusiveModes > 1) {
|
||||
io.writeErr(
|
||||
"connect accepts only one of --stop, --restart or --cleanup-instance",
|
||||
);
|
||||
ctx.exitCode = 1;
|
||||
} else if (opts.cleanupInstance) {
|
||||
if (!adapter) {
|
||||
io.writeErr("connect --cleanup-instance requires a channel");
|
||||
ctx.exitCode = 1;
|
||||
} else {
|
||||
ctx.exitCode = await runCleanupConnectorInstance(
|
||||
adapter,
|
||||
opts.cleanupInstance,
|
||||
io,
|
||||
);
|
||||
}
|
||||
} else if (opts.stop) {
|
||||
if (adapter) {
|
||||
ctx.exitCode = await runStopConnector(adapter, io);
|
||||
@@ -416,7 +451,7 @@ export async function runCli(): Promise<void> {
|
||||
connectCmd.args.slice(1),
|
||||
io,
|
||||
);
|
||||
} else if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
} else if (isFullTTY) {
|
||||
ctx.exitCode = await runConnectWizard();
|
||||
} else {
|
||||
writeln(`\nAdapters:\n${formatAdapterList()}`);
|
||||
@@ -428,7 +463,7 @@ export async function runCli(): Promise<void> {
|
||||
.command("mcp")
|
||||
.description("Manage MCP servers")
|
||||
.action(async () => {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) {
|
||||
if (isFullTTY) {
|
||||
ctx.exitCode = await runMcpWizard();
|
||||
} else {
|
||||
writeln(
|
||||
@@ -493,107 +528,17 @@ export async function runCli(): Promise<void> {
|
||||
await doctorCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
|
||||
const historyCmd = program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List session history or manage saved sessions")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--limit <count>", "Maximum number of sessions to show", "50")
|
||||
.option("--page <number>", "Page number for paginated results")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const opts = historyCmd.opts();
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const outputMode =
|
||||
program.opts().json || opts.json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryList } = await import("./commands/history");
|
||||
const result = await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
io,
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
ctx.resumeSessionId = result;
|
||||
// JSON listing should never return a session id; if it does, still exit here so
|
||||
// we never fall through to agent bootstrap (which can block on stdin in CI).
|
||||
if (outputMode === "json") {
|
||||
ctx.exitCode = 0;
|
||||
}
|
||||
} else {
|
||||
// Always set exit code for numeric results so `ctx.exitCode` is never left
|
||||
// undefined (that would fall through and load the full CLI runtime).
|
||||
ctx.exitCode = result ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
const historyDeleteCmd = historyCmd
|
||||
.command("delete")
|
||||
.description("Delete a session from history")
|
||||
.option("--session-id <id>", "Session ID to delete")
|
||||
.action(async () => {
|
||||
const opts = historyDeleteCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
writeErr("history delete requires --session-id <id>");
|
||||
ctx.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryDelete } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryDelete(opts.sessionId, outputMode, io);
|
||||
});
|
||||
|
||||
const historyUpdateCmd = historyCmd
|
||||
.command("update")
|
||||
.description("Update a session in history")
|
||||
.option("--metadata <json>", "Metadata as JSON string")
|
||||
.option("--prompt <text>", "New prompt text")
|
||||
.option("--session-id <id>", "Session ID to update")
|
||||
.option("--title <text>", "New title")
|
||||
.action(async () => {
|
||||
const opts = historyUpdateCmd.opts();
|
||||
if (!opts.sessionId) {
|
||||
writeErr("history update requires --session-id <id>");
|
||||
ctx.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryUpdate } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryUpdate(
|
||||
opts.sessionId,
|
||||
opts.prompt,
|
||||
opts.title,
|
||||
opts.metadata,
|
||||
outputMode,
|
||||
io,
|
||||
);
|
||||
});
|
||||
|
||||
const historyExportCmd = historyCmd
|
||||
.command("export <sessionId>")
|
||||
.description("Export a session as a standalone HTML file")
|
||||
.option("-o, --output <path>", "Output HTML file path")
|
||||
.action(async (sessionId: string) => {
|
||||
const opts = historyExportCmd.opts();
|
||||
const outputMode =
|
||||
program.opts().json || historyCmd.opts().json
|
||||
? ("json" as const)
|
||||
: ("text" as const);
|
||||
const { runHistoryExport } = await import("./commands/history");
|
||||
ctx.exitCode = await runHistoryExport(
|
||||
sessionId,
|
||||
opts.output,
|
||||
outputMode,
|
||||
io,
|
||||
);
|
||||
});
|
||||
registerHistoryCommand({
|
||||
program,
|
||||
io,
|
||||
setExitCode: (code) => {
|
||||
ctx.exitCode = code;
|
||||
},
|
||||
setStartupTarget: (target) => {
|
||||
ctx.startupTarget = target;
|
||||
},
|
||||
isInteractiveTTY: () => isFullTTY,
|
||||
});
|
||||
|
||||
program
|
||||
.command("hook")
|
||||
@@ -625,11 +570,7 @@ export async function runCli(): Promise<void> {
|
||||
.allowExcessArguments()
|
||||
.passThroughOptions()
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
if (
|
||||
cmd.args.length === 0 &&
|
||||
process.stdin.isTTY &&
|
||||
process.stdout.isTTY
|
||||
) {
|
||||
if (cmd.args.length === 0 && isFullTTY) {
|
||||
ctx.exitCode = await runScheduleWizard();
|
||||
return;
|
||||
}
|
||||
@@ -777,30 +718,8 @@ export async function runCli(): Promise<void> {
|
||||
// Default flow: no subcommand matched, or fall-through from config/history.
|
||||
let args = commanderToParsedArgs(program);
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
// The history picker already created (and tore down) an OpenTUI renderer
|
||||
// in this process; starting the interactive TUI here would create a
|
||||
// second one, which can crash natively during teardown. Resume in a
|
||||
// fresh `cline --id <session-id>` child process instead.
|
||||
const { spawnHistoryResume } = await import("./utils/history-resume");
|
||||
const childExitCode = await spawnHistoryResume({
|
||||
sessionId: resumeSessionId,
|
||||
normalizedArgs,
|
||||
remainingArgs: program.args,
|
||||
configDir,
|
||||
});
|
||||
if (childExitCode !== undefined) {
|
||||
process.exitCode = childExitCode;
|
||||
return;
|
||||
}
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let startupTarget = ctx.startupTarget;
|
||||
let resumeSessionId: string | undefined;
|
||||
if (args.id !== undefined) {
|
||||
const sessionId = args.id.trim();
|
||||
if (!sessionId) {
|
||||
@@ -809,16 +728,12 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
resumeSessionId = sessionId;
|
||||
startupTarget = "chat";
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = "1";
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
prompt: undefined,
|
||||
};
|
||||
} else {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
}
|
||||
if (launchConfigView) {
|
||||
if (startupTarget) {
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
@@ -883,7 +798,10 @@ export async function runCli(): Promise<void> {
|
||||
// Enters the Agent Client Protocol stdio transport and never falls through.
|
||||
if (args.acpMode) {
|
||||
const { runAcpMode } = await import("./acp/index");
|
||||
await runAcpMode();
|
||||
// Only an explicit `--auto-approve true` (or `--yolo`) enables
|
||||
// auto-approval in ACP mode; We do not respect the default to
|
||||
// avoid accidental auto-approval in ACP mode.
|
||||
await runAcpMode({ autoApproveTools: args.autoApproveOverride === true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -892,7 +810,7 @@ export async function runCli(): Promise<void> {
|
||||
!args.prompt &&
|
||||
!resumeSessionId &&
|
||||
!stdinHasPipedInput() &&
|
||||
(!process.stdin.isTTY || !process.stdout.isTTY)
|
||||
!isFullTTY
|
||||
) {
|
||||
writeErr("--worktree without a prompt requires an interactive terminal.");
|
||||
process.exitCode = 1;
|
||||
@@ -1239,12 +1157,6 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const runInteractive = await loadInteractiveRuntimeModule();
|
||||
let initialView: "chat" | "config" | undefined;
|
||||
if (launchConfigView) {
|
||||
initialView = "config";
|
||||
} else if (resumeSessionId) {
|
||||
initialView = "chat";
|
||||
}
|
||||
const initialClineProviderSettings =
|
||||
provider === "cline" ? selectedProviderSettings : undefined;
|
||||
let initialNotice:
|
||||
@@ -1255,7 +1167,10 @@ export async function runCli(): Promise<void> {
|
||||
notice: import("./kanban-migration/notice").CliMigrationNotice,
|
||||
) => void)
|
||||
| undefined;
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
if (
|
||||
!startupTargetTakesPrecedenceOverMigrationNotice(startupTarget) &&
|
||||
isFullTTY
|
||||
) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
@@ -1271,7 +1186,7 @@ export async function runCli(): Promise<void> {
|
||||
initialPrompt: args.prompt,
|
||||
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
|
||||
clineProviderSettings: initialClineProviderSettings,
|
||||
initialView,
|
||||
startupTarget,
|
||||
initialNotice,
|
||||
onInitialNoticeShown: markInitialNoticeShown,
|
||||
});
|
||||
|
||||
@@ -1012,9 +1012,9 @@ Review with the bundled skill.`,
|
||||
const linear = data.mcp.find((item) => item.name === "linear");
|
||||
const docs = data.mcp.find((item) => item.name === "docs");
|
||||
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error");
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
|
||||
expect(linear?.loadError).toBe("OAuth authorization failed");
|
||||
expect(docs?.description).toBe("sse, oauth authorized");
|
||||
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
|
||||
expect(docs?.loadError).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
|
||||
const sessionManagerMocks = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
@@ -39,10 +40,8 @@ const sessionEventsMocks = vi.hoisted(() => ({
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
assertHistorySessionIsDeletable,
|
||||
resolveReasoningForModelChange,
|
||||
resumeInteractiveSession,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("assertHistorySessionIsDeletable", () => {
|
||||
it("rejects deleting the active interactive session", () => {
|
||||
expect(() => assertHistorySessionIsDeletable("sess_1", "sess_1")).toThrow(
|
||||
"Cannot delete the active session",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows deleting another or pre-startup session", () => {
|
||||
expect(() =>
|
||||
assertHistorySessionIsDeletable("sess_1", "sess_2"),
|
||||
).not.toThrow();
|
||||
expect(() => assertHistorySessionIsDeletable("sess_1", "")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
@@ -108,3 +125,74 @@ describe("applyInteractiveModelChange", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeInteractiveSession", () => {
|
||||
const originalAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAgentResume === undefined) {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
} else {
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = originalAgentResume;
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the selected session directly without ensuring an empty session first", async () => {
|
||||
const messages = [
|
||||
{ id: "message-1", role: "user" as const, content: "hello" },
|
||||
];
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const resumeSession = vi.fn(async () => {
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
return messages;
|
||||
});
|
||||
const getAccumulatedUsage = vi.fn(async () => ({
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
totalCost: 0.42,
|
||||
}));
|
||||
const sessionRuntime = {
|
||||
ensureReady,
|
||||
resumeSession,
|
||||
getAccumulatedUsage,
|
||||
};
|
||||
|
||||
const result = await resumeInteractiveSession(
|
||||
sessionRuntime,
|
||||
"session-selected",
|
||||
);
|
||||
|
||||
expect(ensureReady).not.toHaveBeenCalled();
|
||||
expect(resumeSession).toHaveBeenCalledOnce();
|
||||
expect(resumeSession).toHaveBeenCalledWith("session-selected");
|
||||
expect(getAccumulatedUsage).toHaveBeenCalledWith({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
messages,
|
||||
totalCost: 0.42,
|
||||
});
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
});
|
||||
|
||||
it("restores the hook state when the selected session cannot resume", async () => {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
const resumeSession = vi.fn(async () => {
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
|
||||
throw new Error("resume failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeInteractiveSession(
|
||||
{
|
||||
resumeSession,
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
},
|
||||
"session-missing",
|
||||
),
|
||||
).rejects.toThrow("resume failed");
|
||||
|
||||
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
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,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
resolveClineWelcomeLine,
|
||||
} from "../tui/interactive-welcome";
|
||||
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import type { QueuedPromptItem, TuiStartupTarget } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
@@ -73,6 +75,17 @@ type ModelChangeReasoningConfig = {
|
||||
reasoningEffort?: Config["reasoningEffort"];
|
||||
};
|
||||
|
||||
export function assertHistorySessionIsDeletable(
|
||||
sessionId: string,
|
||||
activeSessionId: string,
|
||||
): void {
|
||||
if (activeSessionId && sessionId === activeSessionId) {
|
||||
throw new Error(
|
||||
"Cannot delete the active session. Start or resume another session first.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReasoningForModelChange(
|
||||
config: ModelChangeReasoningConfig,
|
||||
existing: Pick<ProviderSettings, "reasoning">,
|
||||
@@ -130,6 +143,37 @@ export async function applyInteractiveModelChange(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function resumeInteractiveSession(
|
||||
sessionRuntime: Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
"resumeSession" | "getAccumulatedUsage"
|
||||
>,
|
||||
sessionId: string,
|
||||
) {
|
||||
const previousAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = "1";
|
||||
let messages: Awaited<ReturnType<typeof sessionRuntime.resumeSession>>;
|
||||
try {
|
||||
messages = await sessionRuntime.resumeSession(sessionId);
|
||||
} catch (error) {
|
||||
if (previousAgentResume === undefined) {
|
||||
delete process.env.CLINE_HOOK_AGENT_RESUME;
|
||||
} else {
|
||||
process.env.CLINE_HOOK_AGENT_RESUME = previousAgentResume;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
totalCost: usage.totalCost,
|
||||
currentContextSize: getCurrentContextSize(messages),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -137,7 +181,7 @@ export async function runInteractive(
|
||||
options?: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
initialView?: "chat" | "config";
|
||||
startupTarget?: TuiStartupTarget;
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
@@ -470,7 +514,7 @@ export async function runInteractive(
|
||||
|
||||
tuiApp = await renderOpenTui({
|
||||
config,
|
||||
initialView: options?.initialView,
|
||||
startupTarget: options?.startupTarget,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
initialNotice: options?.initialNotice,
|
||||
onInitialNoticeShown: options?.onInitialNoticeShown,
|
||||
@@ -764,18 +808,23 @@ export async function runInteractive(
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onResumeSession: async (sessionId: string) => {
|
||||
await sessionRuntime.ensureReady();
|
||||
const messages = await sessionRuntime.resumeSession(sessionId);
|
||||
const usage = await sessionRuntime.getAccumulatedUsage({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
return {
|
||||
messages,
|
||||
totalCost: usage.totalCost,
|
||||
currentContextSize: getCurrentContextSize(messages),
|
||||
};
|
||||
// resumeSession initializes the manager and starts the selected session
|
||||
// directly. Ensuring a session first would mint an empty history entry
|
||||
// when the TUI was launched through `cline history`.
|
||||
onResumeSession: async (sessionId: string) =>
|
||||
await resumeInteractiveSession(sessionRuntime, sessionId),
|
||||
onExportHistorySession: async (sessionId, format) =>
|
||||
await exportHistorySession({
|
||||
sessionId,
|
||||
format,
|
||||
outputDirectory: config.cwd,
|
||||
}),
|
||||
onDeleteHistorySession: async (sessionId) => {
|
||||
assertHistorySessionIsDeletable(
|
||||
sessionId,
|
||||
sessionRuntime.getActiveSessionId(),
|
||||
);
|
||||
return (await deleteSession(sessionId)).deleted;
|
||||
},
|
||||
onCompact: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
@@ -804,7 +853,7 @@ export async function runInteractive(
|
||||
},
|
||||
});
|
||||
|
||||
if (!loadDeferredInitialMessages) {
|
||||
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
|
||||
setTimeout(() => {
|
||||
void sessionRuntime.ensureReady().catch((error) => {
|
||||
if (sessionRuntime.isShutdownRequested() || startupErrorReported) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { generateConversationHTML } from "./export";
|
||||
import { readSessionMessagesArtifact } from "./session";
|
||||
|
||||
export type HistoryExportFormat = "html" | "json";
|
||||
|
||||
export async function exportHistorySession(input: {
|
||||
sessionId: string;
|
||||
format: HistoryExportFormat;
|
||||
outputPath?: string;
|
||||
outputDirectory?: string;
|
||||
}): Promise<string> {
|
||||
const { sessionId, format, outputPath, outputDirectory } = input;
|
||||
const data = await readSessionMessagesArtifact(sessionId);
|
||||
if (!data) {
|
||||
throw new Error(`Session ${sessionId} not found or has no messages.json`);
|
||||
}
|
||||
|
||||
const targetPath = outputPath?.trim()
|
||||
? resolve(outputPath)
|
||||
: resolve(
|
||||
outputDirectory?.trim() || process.cwd(),
|
||||
`${sessionId}.${format}`,
|
||||
);
|
||||
const contents =
|
||||
format === "html"
|
||||
? generateConversationHTML(data, sessionId)
|
||||
: `${JSON.stringify(data, null, 2)}\n`;
|
||||
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, contents, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { CheckpointEntry } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
|
||||
|
||||
function userPrompt(text: string): Message {
|
||||
return { role: "user", content: [{ type: "text", text }] } as Message;
|
||||
}
|
||||
|
||||
function toolResult(): Message {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "t", content: "ok" }],
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
function assistant(text: string): Message {
|
||||
return { role: "assistant", content: [{ type: "text", text }] } as Message;
|
||||
}
|
||||
|
||||
const history: CheckpointEntry[] = [
|
||||
{ ref: "ref1", createdAt: 1, runCount: 1, kind: "commit" },
|
||||
{ ref: "ref2", createdAt: 2, runCount: 2, kind: "stash" },
|
||||
];
|
||||
|
||||
describe("buildCheckpointPickerItems", () => {
|
||||
it("numbers runs span-aware so tool-result messages don't inflate the count", () => {
|
||||
// A transcript with tool-result messages (role "user") between prompts,
|
||||
// exactly the shape that made the old raw-role counting emit run 5 for
|
||||
// the second prompt and abort restore.
|
||||
const messages: Message[] = [
|
||||
userPrompt("first request"),
|
||||
assistant("working"),
|
||||
toolResult(),
|
||||
assistant("working more"),
|
||||
toolResult(),
|
||||
userPrompt("second request"),
|
||||
assistant("done"),
|
||||
toolResult(),
|
||||
];
|
||||
|
||||
const items = buildCheckpointPickerItems(messages, history);
|
||||
|
||||
expect(items.map((item) => item.runCount)).toEqual([1, 2]);
|
||||
expect(items.map((item) => item.text)).toEqual([
|
||||
"first request",
|
||||
"second request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps each real user turn to the nearest checkpoint at or before it", () => {
|
||||
const messages: Message[] = [
|
||||
userPrompt("first request"),
|
||||
toolResult(),
|
||||
userPrompt("second request"),
|
||||
];
|
||||
|
||||
const items = buildCheckpointPickerItems(messages, [
|
||||
{ ref: "only", createdAt: 1, runCount: 1, kind: "commit" },
|
||||
]);
|
||||
|
||||
// Run 2 has no exact checkpoint; it falls back to the run-1 entry.
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ runCount: 1, text: "first request" }),
|
||||
expect.objectContaining({ runCount: 2, text: "second request" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts a compaction summary as spanning the runs it folded", () => {
|
||||
const compaction = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Compacted context" }],
|
||||
metadata: { kind: "compaction", userRunSpan: 2 },
|
||||
} as unknown as Message;
|
||||
const messages: Message[] = [compaction, userPrompt("third request")];
|
||||
|
||||
const items = buildCheckpointPickerItems(messages, [
|
||||
{ ref: "r3", createdAt: 3, runCount: 3, kind: "stash" },
|
||||
]);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ runCount: 3, text: "third request" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CheckpointEntry } from "@cline/core";
|
||||
import { getUserRunSpan } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import { formatDisplayUserInput, truncateStr } from "@cline/shared";
|
||||
import type { CheckpointPickerItem } from "./components/dialogs/checkpoint-picker";
|
||||
|
||||
/** Highest checkpoint recorded at or before `runCount`. */
|
||||
function checkpointForRun(
|
||||
checkpointHistory: readonly CheckpointEntry[],
|
||||
runCount: number,
|
||||
): CheckpointEntry | undefined {
|
||||
return checkpointHistory.reduce<CheckpointEntry | undefined>(
|
||||
(best, checkpoint) => {
|
||||
if (checkpoint.runCount > runCount) {
|
||||
return best;
|
||||
}
|
||||
if (!best || checkpoint.runCount > best.runCount) {
|
||||
return checkpoint;
|
||||
}
|
||||
return best;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function extractText(content: Message["content"]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { type: "text"; text: string } =>
|
||||
typeof b === "object" &&
|
||||
b !== null &&
|
||||
"type" in b &&
|
||||
(b as { type?: unknown }).type === "text" &&
|
||||
"text" in b &&
|
||||
typeof (b as { text?: unknown }).text === "string",
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `/undo` checkpoint picker rows from the raw conversation and the
|
||||
* recorded checkpoint history.
|
||||
*
|
||||
* The run count MUST advance with `getUserRunSpan`, exactly as the core does
|
||||
* when it numbers checkpoints and later resolves them. Tool-result messages
|
||||
* carry role "user" but contribute 0, and a compaction summary spans the turns
|
||||
* it folded. Counting raw "user" messages overcounts, so the picker would hand
|
||||
* restore a run number the core cannot map — surfacing as
|
||||
* "Could not find user message for run N" and aborting the restore.
|
||||
*/
|
||||
export function buildCheckpointPickerItems(
|
||||
rawMessages: readonly Message[],
|
||||
checkpointHistory: readonly CheckpointEntry[],
|
||||
): CheckpointPickerItem[] {
|
||||
const items: CheckpointPickerItem[] = [];
|
||||
let userRunCount = 0;
|
||||
for (const msg of rawMessages) {
|
||||
const span = getUserRunSpan(msg);
|
||||
if (span < 1) {
|
||||
continue;
|
||||
}
|
||||
userRunCount += span;
|
||||
const checkpoint = checkpointForRun(checkpointHistory, userRunCount);
|
||||
if (!checkpoint) {
|
||||
continue;
|
||||
}
|
||||
const text = extractText(msg.content);
|
||||
const preview = truncateStr(
|
||||
formatDisplayUserInput(text).replace(/\s+/g, " "),
|
||||
60,
|
||||
);
|
||||
if (!preview) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
runCount: userRunCount,
|
||||
text: preview,
|
||||
fullText: text,
|
||||
createdAt: checkpoint.createdAt,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type LocalSlashCommandName =
|
||||
| "plugins"
|
||||
| "account"
|
||||
| "model"
|
||||
| "theme"
|
||||
| "compact"
|
||||
| "skills"
|
||||
| "fork"
|
||||
@@ -62,6 +63,10 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
name: "model",
|
||||
description: "Switch model or provider",
|
||||
},
|
||||
{
|
||||
name: "theme",
|
||||
description: "Change color theme",
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
description: "View Cline account",
|
||||
@@ -112,6 +117,7 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
const SYSTEM_COMMAND_ORDER = [
|
||||
"settings",
|
||||
"model",
|
||||
"theme",
|
||||
"account",
|
||||
"mcp",
|
||||
"plugins",
|
||||
|
||||
@@ -3,8 +3,7 @@ import type {
|
||||
AutocompleteMode,
|
||||
AutocompleteOption,
|
||||
} from "../hooks/use-autocomplete";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
|
||||
const MAX_ROWS = 7;
|
||||
export const DROPDOWN_MAX_HEIGHT = MAX_ROWS + 2;
|
||||
@@ -19,7 +18,9 @@ export interface AutocompleteDropdownProps {
|
||||
}
|
||||
|
||||
export function AutocompleteDropdown(props: AutocompleteDropdownProps) {
|
||||
const { mode, options, selected, onSelect, accent = palette.act } = props;
|
||||
const theme = useTheme();
|
||||
const { mode, options, selected, onSelect } = props;
|
||||
const accent = props.accent ?? theme.accents.act;
|
||||
const { width: termWidth } = useTerminalDimensions();
|
||||
|
||||
if (!mode || options.length === 0) return null;
|
||||
@@ -122,8 +123,8 @@ function OptionRow(props: {
|
||||
accent: string;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
}) {
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const theme = useTheme();
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const { opt, isSelected, rowBudget, mode, accent, onSelect } = props;
|
||||
|
||||
if (opt.isHeader) {
|
||||
@@ -172,12 +173,12 @@ function OptionRow(props: {
|
||||
onMouseDown={() => onSelect(opt)}
|
||||
>
|
||||
<text wrapMode="none">
|
||||
<span fg={isSelected ? palette.textOnSelection : "gray"}>{prefix}</span>
|
||||
<span fg={isSelected ? palette.textOnSelection : defaultFg}>
|
||||
<span fg={isSelected ? theme.textOnSelection : "gray"}>{prefix}</span>
|
||||
<span fg={isSelected ? theme.textOnSelection : defaultFg}>
|
||||
{displayName}
|
||||
</span>
|
||||
{descText ? (
|
||||
<span fg={isSelected ? palette.textOnSelection : "gray"}>
|
||||
<span fg={isSelected ? theme.textOnSelection : "gray"}>
|
||||
{" ".repeat(descGap)}
|
||||
{descText}
|
||||
</span>
|
||||
|
||||
@@ -21,14 +21,8 @@ import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
} from "../cline-account";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getUserMessageBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import { getUserMessageBackground } from "../palette";
|
||||
import type { ResolvedTheme } from "../themes";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
@@ -218,7 +212,7 @@ function ToolCallView(props: {
|
||||
toolName: string;
|
||||
inputSummary: string;
|
||||
rawInput?: unknown;
|
||||
accent?: string;
|
||||
accent: string;
|
||||
defaultFg?: string;
|
||||
streaming: boolean;
|
||||
result?: {
|
||||
@@ -227,14 +221,8 @@ function ToolCallView(props: {
|
||||
error?: string;
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
toolName,
|
||||
inputSummary,
|
||||
streaming,
|
||||
result,
|
||||
accent = palette.act,
|
||||
defaultFg,
|
||||
} = props;
|
||||
const { toolName, inputSummary, streaming, result, accent, defaultFg } =
|
||||
props;
|
||||
const failed = result?.error != null;
|
||||
const warningFailure = isWarningToolError(result?.error);
|
||||
const params = formatToolParams(toolName, props.rawInput, inputSummary);
|
||||
@@ -279,7 +267,11 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
function ClineCreditsClinePassErrorView(props: {
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const linkColor = props.theme.accents.act;
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
@@ -301,7 +293,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={linkColor} selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
@@ -309,7 +301,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase ClinePass: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={linkColor} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -324,18 +316,26 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
|
||||
function ClineCreditsErrorView(props: {
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
return (
|
||||
<ClineCreditsClinePassErrorView
|
||||
defaultFg={props.defaultFg}
|
||||
theme={props.theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
const planAccent = props.theme.accents.plan;
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadIndividualSubscriptionPlans) {
|
||||
@@ -387,13 +387,13 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
)}
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={props.theme.accents.act} selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg={palette.act} selectable>
|
||||
<text fg={props.theme.accents.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
@@ -404,9 +404,9 @@ function ClinePassSubscriptionErrorView(props: {
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
const planAccent = props.theme.accents.plan;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
@@ -464,21 +464,22 @@ function CompactionDividerRow(props: {
|
||||
function ClinePassLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
|
||||
const accent = props.theme.accents.act;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<text fg={accent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={accent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">ClinePass limit reached</text>
|
||||
<text fg={props.theme.accents.error}>ClinePass limit reached</text>
|
||||
<text fg={props.defaultFg} selectable content={detail} />
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
@@ -491,7 +492,7 @@ function ClinePassLimitErrorView(props: {
|
||||
<code
|
||||
content="--provider cline"
|
||||
filetype="bash"
|
||||
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
|
||||
syntaxStyle={getSyntaxStyle(props.theme)}
|
||||
selectable
|
||||
/>
|
||||
<text fg={props.defaultFg} selectable content="." />
|
||||
@@ -504,20 +505,24 @@ function ClinePassLimitErrorView(props: {
|
||||
function ClineFreeModelLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const resetTime = extractClineFreeModelLimitResetTime(props.message);
|
||||
const accent = props.theme.accents.act;
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<text fg={accent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={accent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Daily free model limit reached</text>
|
||||
<text fg={props.theme.accents.error}>
|
||||
Daily free model limit reached
|
||||
</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -538,18 +543,22 @@ function ClineFreeModelLimitErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
|
||||
function ClineFreePromotionEndedErrorView(props: {
|
||||
defaultFg?: string;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const accent = props.theme.accents.act;
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<text fg={accent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
borderColor={accent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Free model promotion ended</text>
|
||||
<text fg={props.theme.accents.error}>Free model promotion ended</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -572,12 +581,12 @@ export function ChatEntryView(props: {
|
||||
/** Mode the entry was produced in (resolved with the current-mode fallback). */
|
||||
mode?: SyntaxAccentMode;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
theme: ResolvedTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const userMsgBg = getUserMessageBackground(terminalBg);
|
||||
const { entry, mode = "act", theme } = props;
|
||||
const accent = props.accent ?? theme.accents.act;
|
||||
const defaultFg = theme.defaultForeground;
|
||||
const userMsgBg = getUserMessageBackground(theme.background);
|
||||
|
||||
switch (entry.kind) {
|
||||
case "user":
|
||||
@@ -633,7 +642,7 @@ export function ChatEntryView(props: {
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
@@ -660,13 +669,13 @@ export function ChatEntryView(props: {
|
||||
|
||||
case "error":
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} theme={theme} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -677,7 +686,7 @@ export function ChatEntryView(props: {
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -686,7 +695,7 @@ export function ChatEntryView(props: {
|
||||
<ClinePassLimitErrorView
|
||||
message={entry.text}
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -695,16 +704,26 @@ export function ChatEntryView(props: {
|
||||
<ClineFreeModelLimitErrorView
|
||||
defaultFg={defaultFg}
|
||||
message={entry.text}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
|
||||
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
|
||||
return (
|
||||
<ClineFreePromotionEndedErrorView
|
||||
defaultFg={defaultFg}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
<text fg="red" selectable content={`Error: ${entry.text}`} />
|
||||
<text fg={theme.accents.error} content="* " />
|
||||
<text
|
||||
fg={theme.accents.error}
|
||||
selectable
|
||||
content={`Error: ${entry.text}`}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { TranscriptCommand } from "../hooks/transcript-keybinds";
|
||||
import { useTerminalTheme } from "../hooks/use-terminal-background";
|
||||
import { getModeAccent } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { getThemeModeAccent } from "../themes";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { ChatEntryView } from "./chat-entry";
|
||||
|
||||
@@ -31,8 +31,8 @@ export const ChatMessageList = forwardRef<
|
||||
>(function ChatMessageList(props, ref) {
|
||||
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const lastEntry = props.entries.at(-1);
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const accent = getModeAccent(props.uiMode ?? "act", terminalTheme);
|
||||
const theme = useTheme();
|
||||
const accent = getThemeModeAccent(theme, props.uiMode ?? "act");
|
||||
const userSubmissionScrollKey =
|
||||
lastEntry?.kind === "user_submitted" ? props.entries.length : 0;
|
||||
|
||||
@@ -103,12 +103,12 @@ export const ChatMessageList = forwardRef<
|
||||
<ChatEntryView
|
||||
key={key}
|
||||
entry={entry}
|
||||
accent={getModeAccent(entryMode, terminalTheme)}
|
||||
accent={getThemeModeAccent(theme, entryMode)}
|
||||
mode={entryMode === "plan" ? "plan" : "act"}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
|
||||
| "settings"
|
||||
| "change-model"
|
||||
| "change-provider"
|
||||
| "theme"
|
||||
| "account"
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
|
||||
description: "Switch provider and configure credentials",
|
||||
keywords: ["provider", "api key", "account", "auth"],
|
||||
},
|
||||
{
|
||||
action: "theme",
|
||||
label: "Change Theme",
|
||||
shortcut: "Opt+T",
|
||||
description: "Pick a color theme for the TUI",
|
||||
keywords: ["theme", "colors", "dark", "light", "appearance"],
|
||||
},
|
||||
{
|
||||
action: "mcp",
|
||||
label: "Manage MCP Servers",
|
||||
|
||||
@@ -127,6 +127,12 @@ const HELP_ROWS: HelpRow[] = [
|
||||
key: "/settings",
|
||||
desc: "Open interactive config browser",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-theme",
|
||||
key: "/theme",
|
||||
desc: "Change color theme",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-mcp",
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
} from "../../utils/dialog-keys";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
@@ -45,6 +49,26 @@ export function saveManualProviderApiKey(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key handling for the OAuth waiting screens: `K` switches to manual API key
|
||||
* entry when that fallback is available; any other (unmodified) key cancels
|
||||
* the pending auth attempt; modifier-held keys are ignored.
|
||||
*
|
||||
* Like the ClinePass promo dialog, this screen must never depend on Esc
|
||||
* alone: it is non-interactive, it may be waiting on a browser flow that
|
||||
* never completes, and Esc is the least reliably delivered key across
|
||||
* terminals (notably on Windows, where console input layers can swallow it).
|
||||
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
|
||||
* auth/verification link never cancels the flow mid-click.
|
||||
*/
|
||||
export function resolveOAuthWaitKeyAction(
|
||||
key: DialogDismissKey,
|
||||
allowApiKeyFallback: boolean | undefined,
|
||||
): "use_api_key" | "cancel" | "ignore" {
|
||||
if (!isAnyKeyDismiss(key)) return "ignore";
|
||||
return allowApiKeyFallback && key.name === "k" ? "use_api_key" : "cancel";
|
||||
}
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
@@ -53,6 +77,8 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
if (CLI_PROMO_CODE) {
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -9,22 +9,61 @@ import {
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
resolveOAuthWaitKeyAction,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("resolveOAuthWaitKeyAction", () => {
|
||||
it("switches to manual API key entry on K when the fallback is available", () => {
|
||||
expect(resolveOAuthWaitKeyAction({ name: "k" }, true)).toBe("use_api_key");
|
||||
});
|
||||
|
||||
it("cancels on K when the fallback is not available", () => {
|
||||
expect(resolveOAuthWaitKeyAction({ name: "k" }, false)).toBe("cancel");
|
||||
});
|
||||
|
||||
it("cancels on any other unmodified key so users are never stuck waiting on a browser flow", () => {
|
||||
for (const name of ["escape", "q", "return", "space", "up", "x"]) {
|
||||
expect(resolveOAuthWaitKeyAction({ name }, true)).toBe("cancel");
|
||||
expect(resolveOAuthWaitKeyAction({ name }, false)).toBe("cancel");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores modifier-held keys so holding Cmd/Ctrl to click the auth link never cancels", () => {
|
||||
expect(resolveOAuthWaitKeyAction({ name: "k", ctrl: true }, true)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveOAuthWaitKeyAction({ name: "c", ctrl: true }, false)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveOAuthWaitKeyAction({ name: "x", meta: true }, true)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveOAuthWaitKeyAction({ name: "x", super: true }, false)).toBe(
|
||||
"ignore",
|
||||
);
|
||||
// A bare modifier press (empty name) is ignored, not a cancel.
|
||||
expect(resolveOAuthWaitKeyAction({ name: "" }, true)).toBe("ignore");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl(undefined).startsWith(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
buildClinePassSubscriptionPageUrl(
|
||||
"https://staging-app.cline.bot",
|
||||
).startsWith(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "../searchable-list";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
resolveOAuthWaitKeyAction,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
@@ -877,21 +878,20 @@ export function OAuthLoginContent(
|
||||
}, []);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
cancelAuthAttempt();
|
||||
dismiss();
|
||||
const action = resolveOAuthWaitKeyAction(key, allowApiKeyFallback);
|
||||
if (action === "ignore") return;
|
||||
cancelAuthAttempt();
|
||||
if (action === "use_api_key") {
|
||||
resolve("use_api_key");
|
||||
return;
|
||||
}
|
||||
if (key.name === "k" && allowApiKeyFallback) {
|
||||
cancelAuthAttempt();
|
||||
resolve("use_api_key");
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
const escapeHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, Esc to cancel"
|
||||
: "Esc to cancel";
|
||||
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
const cancelHint = allowApiKeyFallback
|
||||
? "K to enter an API key instead, any other key to cancel"
|
||||
: "Press any key to cancel";
|
||||
const cancelHintColor = allowApiKeyFallback ? "white" : "gray";
|
||||
|
||||
if (mode === "device") {
|
||||
return (
|
||||
@@ -919,8 +919,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{deviceError && <text fg="red">{deviceError}</text>}
|
||||
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
<text fg={cancelHintColor}>
|
||||
<em>{cancelHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -942,8 +942,8 @@ export function OAuthLoginContent(
|
||||
|
||||
{error && <text fg="red">{error}</text>}
|
||||
|
||||
<text fg={escapeHintColor}>
|
||||
<em>{escapeHint}</em>
|
||||
<text fg={cancelHintColor}>
|
||||
<em>{cancelHint}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useThemeController } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { getThemeSwatchColors, THEMES } from "../../themes";
|
||||
|
||||
const SWATCH_BLOCK = "\u25a0";
|
||||
|
||||
export function ThemePickerContent(props: ChoiceContext<string>) {
|
||||
const { resolve, dismiss, dialogId } = props;
|
||||
const { height } = useTerminalDimensions();
|
||||
const controller = useThemeController();
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const index = THEMES.findIndex(
|
||||
(theme) => theme.id === controller.selectedThemeId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const controllerRef = useRef(controller);
|
||||
controllerRef.current = controller;
|
||||
|
||||
// Live preview: moving the selection repaints the whole TUI with the
|
||||
// highlighted theme so users see exactly what they would get.
|
||||
useEffect(() => {
|
||||
const theme = THEMES[selected];
|
||||
if (theme) {
|
||||
controllerRef.current.previewThemeId(theme.id);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
// Clear any dangling preview when the dialog closes without a confirm
|
||||
// (escape, backdrop click, dialog replaced). setThemeId already clears the
|
||||
// preview on confirm, so this is a no-op in that path.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current.previewThemeId(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter" || key.name === "tab") {
|
||||
const theme = THEMES[selectedRef.current];
|
||||
if (theme) {
|
||||
controllerRef.current.setThemeId(theme.id);
|
||||
resolve(theme.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === "up") {
|
||||
setSelected((index) => (index <= 0 ? THEMES.length - 1 : index - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === "down") {
|
||||
setSelected((index) => (index >= THEMES.length - 1 ? 0 : index + 1));
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
const maxVisible = Math.max(3, height - 10);
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
selected - Math.floor(maxVisible / 2),
|
||||
Math.max(0, THEMES.length - maxVisible),
|
||||
),
|
||||
);
|
||||
const visibleThemes = THEMES.slice(start, start + maxVisible);
|
||||
// Selection prefix (2 cells) + longest label + separating gap.
|
||||
const labelWidth = Math.max(...THEMES.map((theme) => theme.label.length)) + 4;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg="white">
|
||||
<strong>Theme</strong>
|
||||
</text>
|
||||
<text fg="gray">esc</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="column">
|
||||
{visibleThemes.map((theme, i) => {
|
||||
const absoluteIndex = start + i;
|
||||
const isSelected = absoluteIndex === selected;
|
||||
const isCurrent = theme.id === controller.selectedThemeId;
|
||||
const swatches = getThemeSwatchColors(theme);
|
||||
return (
|
||||
<box
|
||||
key={theme.id}
|
||||
flexDirection="row"
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
onMouseDown={() => {
|
||||
setSelected(absoluteIndex);
|
||||
controllerRef.current.setThemeId(theme.id);
|
||||
resolve(theme.id);
|
||||
}}
|
||||
height={1}
|
||||
>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : "white"}
|
||||
width={labelWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSelected ? "\u276f " : " "}
|
||||
{theme.label}
|
||||
</text>
|
||||
<text flexShrink={0}>
|
||||
{swatches.map((color, swatchIndex) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-size color strip
|
||||
key={swatchIndex}
|
||||
fg={color}
|
||||
>
|
||||
{SWATCH_BLOCK}
|
||||
</span>
|
||||
))}
|
||||
</text>
|
||||
<text fg={isSelected ? palette.textOnSelection : "gray"}>
|
||||
{" "}
|
||||
{theme.description}
|
||||
{isCurrent ? " (current)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
<em>{"\u2191/\u2193 preview, Enter to apply, Esc to cancel"}</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { palette } from "../palette";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import type { RuntimeToolInteraction } from "../types";
|
||||
import { formatApprovalParams } from "./dialogs/tool-approval";
|
||||
|
||||
@@ -152,7 +152,7 @@ function Shell(
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={palette.act}>{props.title}</text>
|
||||
<text fg={props.accent}>{props.title}</text>
|
||||
</box>
|
||||
{props.children}
|
||||
</box>
|
||||
@@ -165,17 +165,18 @@ function ChoiceButton(props: {
|
||||
selectedFg?: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
paddingX={1}
|
||||
backgroundColor={props.selected ? palette.selection : undefined}
|
||||
backgroundColor={props.selected ? theme.selection : undefined}
|
||||
onMouseDown={props.onPress}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
props.selected
|
||||
? (props.selectedFg ?? palette.textOnSelection)
|
||||
? (props.selectedFg ?? theme.textOnSelection)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -190,6 +191,7 @@ function ToolApprovalResponse(
|
||||
interaction: Extract<RuntimeToolInteraction, { kind: "tool_approval" }>;
|
||||
},
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const [selected, setSelected] = useState<"approve" | "deny">("approve");
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
@@ -231,7 +233,7 @@ function ToolApprovalResponse(
|
||||
inputForeground={props.inputForeground}
|
||||
>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Approve tool call?</text>
|
||||
<text fg={theme.accents.plan}>Approve tool call?</text>
|
||||
<text fg={props.accent} selectable>
|
||||
{request.toolName}
|
||||
</text>
|
||||
@@ -263,6 +265,7 @@ function AskQuestionResponse(
|
||||
},
|
||||
) {
|
||||
const { interaction } = props;
|
||||
const theme = useTheme();
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
@@ -439,13 +442,11 @@ function AskQuestionResponse(
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={
|
||||
optionSelected ? palette.selection : undefined
|
||||
}
|
||||
backgroundColor={optionSelected ? theme.selection : undefined}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
fg={optionSelected ? theme.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
@@ -453,7 +454,7 @@ function AskQuestionResponse(
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
? theme.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
flexGrow={1}
|
||||
@@ -472,17 +473,17 @@ function AskQuestionResponse(
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
backgroundColor={isTyping ? theme.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text
|
||||
fg={isTyping ? palette.textOnSelection : "gray"}
|
||||
fg={isTyping ? theme.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
<text fg={theme.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
{customText}
|
||||
</text>
|
||||
) : (
|
||||
|
||||
@@ -93,14 +93,3 @@ export function freeTierDescriptionFor(
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
@@ -86,13 +85,4 @@ describe("cline model picker entries", () => {
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
export {
|
||||
@@ -23,7 +22,6 @@ export {
|
||||
type ClineModelPickerItem,
|
||||
type ClineModelPickerTier,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
@@ -32,24 +30,6 @@ function tagColor(tag: string): string {
|
||||
return palette.act;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -75,10 +55,9 @@ export function ClineModelPicker(props: {
|
||||
entries: ClineModelPickerEntry[];
|
||||
selected: number;
|
||||
loading?: boolean;
|
||||
knownModels?: Record<string, unknown>;
|
||||
currentModelId?: string;
|
||||
}) {
|
||||
const { entries, selected, loading, knownModels, currentModelId } = props;
|
||||
const { entries, selected, loading, currentModelId } = props;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -122,7 +101,8 @@ export function ClineModelPicker(props: {
|
||||
}
|
||||
|
||||
const tags = entry.model.tags;
|
||||
const name = resolveDisplayName(entry.model.id, knownModels);
|
||||
// Names arrive display-ready from fetchClineRecommendedModels
|
||||
const name = entry.model.name || entry.model.id;
|
||||
const isCurrent = currentModelId === entry.model.id;
|
||||
rows.push(
|
||||
<box
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
@@ -25,29 +24,10 @@ function tagColor(tag: string): string {
|
||||
return palette.act;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return stripFreeMarker(hit.name);
|
||||
}
|
||||
}
|
||||
const fallback = modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
return stripFreeMarker(fallback);
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
props: ChoiceContext<string> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
entries: ClineModelPickerEntry[];
|
||||
},
|
||||
) {
|
||||
@@ -57,7 +37,6 @@ export function ClineModelSelectorContent(
|
||||
dialogId,
|
||||
currentModel,
|
||||
currentProviderName,
|
||||
knownModels,
|
||||
entries,
|
||||
} = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
@@ -95,7 +74,8 @@ export function ClineModelSelectorContent(
|
||||
rows.push({
|
||||
key: entry.model.id,
|
||||
kind: "model",
|
||||
label: resolveDisplayName(entry.model.id, knownModels),
|
||||
// Names arrive display-ready from fetchClineRecommendedModels
|
||||
label: entry.model.name || entry.model.id,
|
||||
tags: entry.model.tags,
|
||||
isCurrent: currentModel === entry.model.id,
|
||||
entryIndex: i,
|
||||
@@ -112,7 +92,7 @@ export function ClineModelSelectorContent(
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [entries, knownModels, currentModel]);
|
||||
}, [entries, currentModel]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -238,7 +218,6 @@ export function ClineModelSelectorDialogContent(
|
||||
props: ChoiceContext<string> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
loadEntries: () => Promise<ClineModelPickerEntry[]>;
|
||||
},
|
||||
) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user