mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b43fb7777c | |||
| 8ea71e3278 | |||
| f4abb7e14b | |||
| 1e08d8f277 | |||
| 8bebcd1f37 | |||
| 14f9def7ed | |||
| 2e6c25785e | |||
| b565e5b9ca | |||
| 0313c34b1a | |||
| d0f49330a3 | |||
| db31380268 | |||
| b123f2eb54 | |||
| 303722a559 | |||
| 5fff9cbb4c | |||
| 70e0a3731d | |||
| d52af3112d | |||
| eccb0db669 | |||
| a9af97a83f | |||
| 53388f5f9c | |||
| 093e9dd3d5 | |||
| d35f4a043b | |||
| bd9ee23f6c | |||
| 10ff456e93 | |||
| cb34ba5cf9 | |||
| 2ed4b811be | |||
| 7bf542ddab | |||
| 1cb61ab925 | |||
| 7bf495878e | |||
| 677a5cd915 | |||
| 4524e884a7 | |||
| 75d1d6654b | |||
| 45027ee4e3 | |||
| 7f8fd0b62d | |||
| 6df53ddf64 | |||
| 243b6fe9d9 | |||
| edd6e2c29b | |||
| 1eb28e553e | |||
| 42225f9219 | |||
| da759853ea | |||
| 6fed0f0d38 | |||
| b456ce73dd | |||
| 2b9d1c3e6a | |||
| 82277128aa | |||
| ee4edfeaf6 | |||
| 17b0ad7a76 | |||
| be8bb302a8 | |||
| f4f573c395 | |||
| e03b4577b4 | |||
| 16d36e53f2 | |||
| 74a1910505 | |||
| f3db7a0a98 | |||
| 2b62f7b447 | |||
| 6b8b5726c3 | |||
| f6d8e54089 | |||
| ef09caa3e6 | |||
| bdb5f30f5e | |||
| 2fa0be8940 | |||
| 2e5a565090 | |||
| eb95908737 | |||
| b091c72e40 | |||
| ebf5b52e9b | |||
| 13d801b578 | |||
| 642c2f643b | |||
| 528c6b8ff2 | |||
| 2f1c94505a | |||
| 5b7005b003 | |||
| e48a7c9c91 | |||
| 71e1c59ab8 | |||
| d7b61de54f | |||
| 7350294f7d | |||
| fe90cbfdf1 | |||
| 44cf50cee7 | |||
| b39c7e0319 | |||
| 378fdf147b | |||
| 9c62406da9 | |||
| a988d6a6ee | |||
| f1ec8c897c | |||
| 0b1f083531 | |||
| d79ad49de3 | |||
| ce78c667cf | |||
| 2140996303 | |||
| 0d1a72f009 | |||
| a6c0cfd5c3 | |||
| 17e8c0cbc9 | |||
| e2b97792b1 | |||
| e4a7c19432 | |||
| 589f32d13e | |||
| 879c085fdb |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
@@ -14,8 +14,8 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
|
||||
## Release contract
|
||||
|
||||
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
|
||||
|
||||
@@ -93,9 +93,7 @@ Release prep on `main` (PR, not direct push):
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f publish=true
|
||||
# (the legacy bundle always builds from the protected legacy-extension branch;
|
||||
# it is deliberately not an input)
|
||||
-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.
|
||||
@@ -158,9 +156,7 @@ For shipping a fix on the `legacy-extension` branch — or as the **structural r
|
||||
# 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
|
||||
# (the branch is hardcoded to legacy-extension in the workflow; it is
|
||||
# deliberately not an input)
|
||||
-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/...`).
|
||||
|
||||
@@ -206,8 +206,6 @@ jobs:
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
|
||||
@@ -215,32 +213,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
|
||||
# and link out to the full notes. The GitHub release body stays whole.
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -276,7 +248,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=main
|
||||
FEED=desktop-latest
|
||||
PRODUCT="Cline"
|
||||
PRODUCT="Cline Code"
|
||||
;;
|
||||
beta)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
fi
|
||||
ANCESTOR_REF=desktop-experimental
|
||||
FEED=desktop-beta
|
||||
PRODUCT="Cline Beta"
|
||||
PRODUCT="Cline Code Beta"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
# "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
@@ -506,33 +506,6 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body and updater manifest stay whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
@@ -607,14 +580,14 @@ jobs:
|
||||
if ! gh release view "$FEED" >/dev/null 2>&1; then
|
||||
if [ "$CHANNEL" = "beta" ]; then
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop beta (auto-update feed)" \
|
||||
--title "Cline Code desktop beta (auto-update feed)" \
|
||||
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
else
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop (auto-update feed)" \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
@@ -628,7 +601,7 @@ jobs:
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
echo "Published Cline desktop v${VERSION}"
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
|
||||
|
||||
@@ -639,16 +612,16 @@ jobs:
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -23,6 +23,11 @@ on:
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
@@ -125,37 +130,31 @@ jobs:
|
||||
name: Test legacy bundle
|
||||
runs-on: ubuntu-latest
|
||||
# The tested revision, exported so the build job builds EXACTLY what
|
||||
# this suite ran against. legacy-extension is a mutable branch name and
|
||||
# the build job starts later — re-resolving the name there could pick
|
||||
# up commits this gate never saw.
|
||||
# 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:
|
||||
# Always the protected legacy-extension branch — deliberately not
|
||||
# an input. An arbitrary ref here would be built into the published
|
||||
# VSIX by the environment-less build job, and the publish
|
||||
# environment approver only ever sees an opaque prebuilt artifact:
|
||||
# the approval would protect the marketplace PAT but not the
|
||||
# shipped bytes. Hardcoding the branch makes its protection rules
|
||||
# load-bearing for releases. Legacy hotfix testing has its own
|
||||
# workflow (ext-vscode-publish-legacy.yml).
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
|
||||
- name: Record tested revision
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- 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 }}
|
||||
@@ -490,35 +489,6 @@ jobs:
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
{
|
||||
echo "slack_content<<CHANGELOG_EOF"
|
||||
echo "$SLACK_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
@@ -574,7 +544,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -21,17 +21,20 @@ on:
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
# Read-only by default. The publish job elevates itself to contents: write for
|
||||
# the tag push and GitHub release; nothing here needs packages/checks/PR
|
||||
# write. Keeping the default minimal matters doubly in this workflow because
|
||||
# the test job runs BEFORE any environment approval — it must never hold a
|
||||
# write token while executing checked-out code.
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -47,23 +50,18 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
# Always the protected legacy-extension branch — deliberately not
|
||||
# an input. This job runs full npm lifecycle scripts from the
|
||||
# checked-out code with no environment approval, and the publish
|
||||
# job below does the same next to the marketplace PATs; an
|
||||
# arbitrary ref here would hand both of them attacker-controlled
|
||||
# code. Hardcoding the branch makes its protection rules
|
||||
# load-bearing for releases.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- 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 }}
|
||||
@@ -101,20 +99,16 @@ jobs:
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
# For the tag push in Resolve Release Tag and the GitHub release.
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main; hardcoded — see the test
|
||||
# job's checkout comment). fetch-depth: 0 + tags so we can
|
||||
# create/push the release tag and compute the previous tag.
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
@@ -123,7 +117,7 @@ jobs:
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: legacy-extension
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
@@ -264,33 +258,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -320,7 +287,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -14,12 +14,9 @@ name: ext-vscode-publish-nightly
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
# Manual dispatch only. The nightly cron was removed deliberately: the
|
||||
# PublishNightly environment gained required reviewers, and an unattended
|
||||
# cron run would just sit `waiting` on that approval, hold this workflow's
|
||||
# concurrency group, and silently cancel every later scheduled run behind it
|
||||
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
|
||||
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
@@ -77,9 +74,8 @@ jobs:
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: the || fallback is retained so this stays correct if a
|
||||
# non-dispatch trigger is ever added back (inputs are empty strings
|
||||
# on e.g. `schedule` events, where the declared default does not apply).
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
@@ -234,33 +234,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -330,7 +303,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -80,9 +80,8 @@ jobs:
|
||||
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}-latest
|
||||
timeout-minutes: 20
|
||||
# Nothing in this job uses OIDC, so it does not need an id-token
|
||||
# permission.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
@@ -94,9 +93,6 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Cache keys below are exact-match only (no restore-keys prefix
|
||||
# fallbacks); a miss just means a cold install, which is acceptable.
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
uses: actions/cache@v4
|
||||
@@ -104,6 +100,8 @@ jobs:
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -112,6 +110,8 @@ jobs:
|
||||
with:
|
||||
path: apps/vscode/.vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
@@ -123,6 +123,8 @@ jobs:
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
@@ -169,12 +171,9 @@ jobs:
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
|
||||
# Repo-root relative: the job's `working-directory` default applies to `run`
|
||||
# steps only, so an apps/vscode-relative path here silently matches nothing
|
||||
# and every failing run uploads no recordings at all.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: playwright-recordings-${{ matrix.runner }}
|
||||
path: |
|
||||
apps/vscode/test-results/
|
||||
test-results/playwright/
|
||||
|
||||
@@ -282,33 +282,6 @@ jobs:
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -360,7 +333,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -46,12 +46,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/ui imports @cline/shared/browser (generated-media), which
|
||||
# resolves to dist output — build it before anything typechecks or
|
||||
# builds the ui package.
|
||||
- name: Build shared package
|
||||
run: bun -F @cline/shared build
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
@@ -64,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
|
||||
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.13]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
|
||||
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
|
||||
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
|
||||
|
||||
## [4.1.12]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
|
||||
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
|
||||
|
||||
## [4.1.11]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
|
||||
|
||||
### Added
|
||||
|
||||
- Let models that support it generate images during a task. Generated images render inline in the conversation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix code actions failing with "command not found" on VS Code 1.134.
|
||||
- Fix `@` file mentions breaking on paths that contain spaces.
|
||||
- Show the diff edit view for multi-line edits in files with CRLF line endings.
|
||||
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
|
||||
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
|
||||
- Honor the classic truncation range when migrating legacy tasks.
|
||||
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
|
||||
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
|
||||
- Point provider signup links at each provider's API key page instead of a generic landing page.
|
||||
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
|
||||
- Stop offering image, voice, and other non-chat models in chat model pickers.
|
||||
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
|
||||
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
|
||||
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
|
||||
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
|
||||
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
|
||||
|
||||
### Changed
|
||||
|
||||
- Show the billed cost for Cline gateway usage.
|
||||
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
|
||||
|
||||
### Fixed (legacy bundle)
|
||||
|
||||
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
|
||||
|
||||
## [4.1.10]
|
||||
|
||||
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.57
|
||||
|
||||
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
|
||||
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
|
||||
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
|
||||
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
|
||||
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
|
||||
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
|
||||
|
||||
## 3.0.56
|
||||
|
||||
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
|
||||
- Skill slash commands now load through the skills tool instead of expanding into your message. History and resume show the `/command` you typed instead of the whole skill body, and the instructions reach the model once instead of twice. Workflows still expand, as does zen mode, whose preset has no skills tool
|
||||
- Image, voice, and other non-chat models are no longer offered in the onboarding and model pickers or ACP model listings, and are rejected for `--model`
|
||||
- Fixed TUI dialog colors not following theme changes live
|
||||
- Fixed the account dialog's selection chevron so it matches the other dialogs
|
||||
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown as a tool card
|
||||
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hooks running fire-and-forget with their output and `cancel` control discarded
|
||||
- Fixed `run_commands` failing with ENOENT when a structured command carried a full command line with no `args`
|
||||
- PowerShell commands now fail fast on the first error instead of emitting an error record per enumerated item and still reporting success
|
||||
- Fixed Gemini custom base URLs configured as a host root
|
||||
- Fixed `cline schedule` commands against a remote hub, which now register a workspace client so they are authorized under the new workspace-scoped schedule rules
|
||||
- Usage now displays the billed gateway cost
|
||||
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
|
||||
|
||||
## 3.0.55
|
||||
|
||||
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.57",
|
||||
"version": "3.0.55",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -70,10 +70,6 @@ import {
|
||||
sendSessionInfoUpdate,
|
||||
} from "./session-updates";
|
||||
|
||||
const CHAT_MODEL_QUERY_OPTIONS = {
|
||||
filter: "chat",
|
||||
} satisfies Llms.GetModelsForProviderOptions;
|
||||
|
||||
interface SessionState {
|
||||
id: string;
|
||||
cwd: string;
|
||||
@@ -189,10 +185,7 @@ export class AcpAgent implements Agent {
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
providerId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
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.
|
||||
@@ -263,10 +256,7 @@ export class AcpAgent implements Agent {
|
||||
// provider's own catalog just like newSession.
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
providerId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
session = {
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
@@ -299,7 +289,6 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([availableModelId, info]) => ({
|
||||
@@ -484,10 +473,7 @@ export class AcpAgent implements Agent {
|
||||
// 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,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const providerModels = await Llms.getModelsForProvider(value);
|
||||
session.currentModelId = await resolveDefaultModelId(
|
||||
value,
|
||||
session.currentModelId,
|
||||
@@ -921,10 +907,7 @@ async function buildAllConfigOptions(
|
||||
): Promise<SessionConfigOption[]> {
|
||||
const [providerOption, providerModels] = await Promise.all([
|
||||
buildProviderConfigOption(session.currentProviderId),
|
||||
Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
),
|
||||
Llms.getModelsForProvider(session.currentProviderId),
|
||||
]);
|
||||
return [
|
||||
providerOption,
|
||||
|
||||
@@ -17,7 +17,6 @@ const mocks = vi.hoisted(() => ({
|
||||
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
|
||||
disableConnectorAutostart: vi.fn(),
|
||||
getPersistedConnectorConnection: vi.fn(),
|
||||
getProcessStartToken: vi.fn(() => undefined),
|
||||
getConnector: vi.fn(),
|
||||
listActiveConnectors: vi.fn(),
|
||||
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
|
||||
@@ -30,7 +29,6 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@cline/core", () => ({
|
||||
disableConnectorAutostart: mocks.disableConnectorAutostart,
|
||||
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
|
||||
getProcessStartToken: mocks.getProcessStartToken,
|
||||
listActiveConnectors: mocks.listActiveConnectors,
|
||||
persistConnectorConnection: mocks.persistConnectorConnection,
|
||||
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
|
||||
|
||||
@@ -3,20 +3,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockLocalHubHasNoActiveSessions,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockRequestHubDrain,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockLocalHubHasNoActiveSessions: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockRequestHubDrain: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -31,10 +27,8 @@ const {
|
||||
vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
requestHubDrain: mockRequestHubDrain,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
@@ -101,147 +95,6 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand() {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
return {
|
||||
cmd,
|
||||
output,
|
||||
errors,
|
||||
exitCode: () => exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
it("sends an un-drain request with drain --off", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain", "--off"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain --off",
|
||||
{ off: true },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: false,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("drains without the off flag by default", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
|
||||
const { cmd, output, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["drain"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub drain",
|
||||
{ off: false },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
draining: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
|
||||
const { cmd, output, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(exitCode()).toBe(0);
|
||||
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
|
||||
// The drain was never lifted manually: the drained hub was replaced.
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(output[0] || "")).toEqual({
|
||||
upgraded: true,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRequestHubDrain.mockResolvedValue(true);
|
||||
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
|
||||
|
||||
const { cmd, errors, exitCode } = createCommand();
|
||||
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
|
||||
|
||||
expect(exitCode()).toBe(1);
|
||||
expect(errors[0]).toContain("still serving sessions");
|
||||
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
|
||||
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"token",
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const { cmd } = createCommand();
|
||||
cmd.configureOutput({ writeErr: () => {} });
|
||||
for (const sub of cmd.commands) {
|
||||
sub.configureOutput({ writeErr: () => {} });
|
||||
}
|
||||
await expect(
|
||||
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
|
||||
).rejects.toThrow("--wait requires a non-negative number of seconds.");
|
||||
expect(mockRequestHubDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
localHubHasNoActiveSessions,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
requestHubDrain,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -56,16 +54,6 @@ function resolveCliHubOwnerContext() {
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function parseWaitSeconds(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
throw new InvalidArgumentError(
|
||||
"--wait requires a non-negative number of seconds.",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -162,115 +150,5 @@ export function createHubCommand(
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("drain")
|
||||
.description("Refuse new mutating work while accepted runs finish")
|
||||
.option("--reason <text>", "Why the hub is draining")
|
||||
.option("--off", "Lift the drain and accept new mutating work again")
|
||||
.action(
|
||||
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
io.writeErr("No hub is running.");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const draining = cmdOptions.off !== true;
|
||||
const ok = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
cmdOptions.reason ??
|
||||
(draining ? "cline hub drain" : "cline hub drain --off"),
|
||||
{ off: !draining },
|
||||
);
|
||||
if (!ok) {
|
||||
io.writeErr(
|
||||
draining ? "Hub drain request failed." : "Hub un-drain request failed.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
io.writeln(JSON.stringify({ draining, url: discovery.url }));
|
||||
}),
|
||||
);
|
||||
|
||||
hub
|
||||
.command("upgrade")
|
||||
.description(
|
||||
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
|
||||
)
|
||||
.option(
|
||||
"--wait <seconds>",
|
||||
"How long to wait for the hub to go idle",
|
||||
parseWaitSeconds,
|
||||
120,
|
||||
)
|
||||
.action(
|
||||
action(async (cmdOptions: { wait: number }) => {
|
||||
const opts = hub.opts<{
|
||||
cwd: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
}>();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url) {
|
||||
const drained = await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade",
|
||||
).catch(() => false);
|
||||
// An aborted upgrade must hand the hub back: leaving it
|
||||
// draining refuses all new mutating work until a restart.
|
||||
const undrain = async (): Promise<void> => {
|
||||
if (!drained) {
|
||||
return;
|
||||
}
|
||||
await requestHubDrain(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
"cline hub upgrade aborted",
|
||||
{ off: true },
|
||||
).catch(() => false);
|
||||
};
|
||||
try {
|
||||
const deadline = Date.now() + cmdOptions.wait * 1_000;
|
||||
let idle = false;
|
||||
// Check at least once so --wait 0 still observes an idle hub.
|
||||
for (;;) {
|
||||
idle = await localHubHasNoActiveSessions(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
).catch(() => true);
|
||||
if (idle || Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
if (!idle) {
|
||||
await undrain();
|
||||
io.writeErr(
|
||||
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
await stopHubServer(opts.cwd);
|
||||
} catch (error) {
|
||||
await undrain();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { url } = await ensureDetachedHubServer(opts.cwd, {
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
io.writeln(JSON.stringify({ upgraded: true, url }));
|
||||
}),
|
||||
);
|
||||
|
||||
return hub;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockHubClientCommand = vi.hoisted(() => vi.fn());
|
||||
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
@@ -17,17 +16,7 @@ vi.mock("@cline/core", async () => {
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
NodeHubClient: class {
|
||||
command = mockHubClientCommand;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
mockNodeHubClientCtor(options);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
close(): void {}
|
||||
},
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
@@ -85,7 +74,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -107,21 +96,18 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["No schedules found."]);
|
||||
// Schedule commands are workspace-scoped: the hub client must register
|
||||
// with a workspace context (and the hub auth token) before commanding.
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
authToken: "test-token",
|
||||
}),
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.list",
|
||||
payload: {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps JSON list output unchanged when --json is provided", async () => {
|
||||
@@ -129,7 +115,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -151,7 +137,7 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["[]"]);
|
||||
expect(mockHubClientCommand).toHaveBeenCalled();
|
||||
expect(mockSendHubCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +157,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -203,19 +189,15 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
cwd: "/tmp/workspace",
|
||||
authToken: "test-token",
|
||||
}),
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -233,7 +215,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -264,11 +246,14 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -307,7 +292,7 @@ describe("runScheduleCommand create", () => {
|
||||
expect(errors).toEqual([
|
||||
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
|
||||
]);
|
||||
expect(mockHubClientCommand).not.toHaveBeenCalled();
|
||||
expect(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -315,7 +300,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_delivery" } },
|
||||
});
|
||||
@@ -354,17 +339,21 @@ describe("runScheduleCommand create", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -381,7 +370,7 @@ describe("runScheduleCommand import", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -422,12 +411,16 @@ describe("runScheduleCommand import", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -451,7 +444,7 @@ describe("runScheduleCommand export", () => {
|
||||
prompt: "review status",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
};
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
@@ -491,9 +484,14 @@ describe("runScheduleCommand export", () => {
|
||||
|
||||
const written = await readFile(targetPath, "utf8");
|
||||
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
|
||||
scheduleId: "sched_abc",
|
||||
});
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.get",
|
||||
payload: { scheduleId: "sched_abc" },
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(targetPath, { force: true });
|
||||
}
|
||||
@@ -509,7 +507,7 @@ describe("runScheduleCommand export", () => {
|
||||
name: "Weekly Sync",
|
||||
cronPattern: "0 9 * * 1",
|
||||
};
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
NodeHubClient,
|
||||
sendHubCommand,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
@@ -11,51 +11,28 @@ import {
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export class HubScheduleClient {
|
||||
private hub: Promise<NodeHubClient> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly workspaceRoot: string,
|
||||
private readonly authToken?: string,
|
||||
private readonly endpoint: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
close(): void {
|
||||
const hub = this.hub;
|
||||
this.hub = undefined;
|
||||
void hub?.then((client) => client.close()).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Schedule commands are authorized against the workspace bound to the
|
||||
// connection's client registration, so all commands must share one
|
||||
// registered connection instead of fire-and-forget envelopes.
|
||||
private connectedHub(): Promise<NodeHubClient> {
|
||||
this.hub ??= (async () => {
|
||||
const client = new NodeHubClient({
|
||||
url: this.url,
|
||||
clientType: "cli-schedule",
|
||||
displayName: "Cline CLI scheduler",
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
authToken: this.authToken,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
client.close();
|
||||
this.hub = undefined;
|
||||
throw error;
|
||||
}
|
||||
return client;
|
||||
})();
|
||||
return this.hub;
|
||||
}
|
||||
close(): void {}
|
||||
|
||||
private async command(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const client = await this.connectedHub();
|
||||
const reply = await client.command(command as never, payload);
|
||||
const reply = await sendHubCommand(this.endpoint, {
|
||||
clientId: "cline-schedule",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -120,7 +97,6 @@ export class LocalScheduleClient {
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
private readonly commands = new HubScheduleCommandService(this.service);
|
||||
constructor(private readonly workspaceRoot: string) {}
|
||||
|
||||
close(): void {
|
||||
void this.service.dispose();
|
||||
@@ -130,21 +106,12 @@ export class LocalScheduleClient {
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await this.commands.handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-schedule-local",
|
||||
workspaceContext: {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
cwd: this.workspaceRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
const reply = await this.commands.handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-schedule-local",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
@@ -218,27 +185,24 @@ export async function ensureSchedulerHub(
|
||||
if (!address?.trim()) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const requestedEndpoint = parseHubEndpointOverride(address);
|
||||
const { url: hubUrl, authToken } = await ensureCliHubServer(
|
||||
const { url: hubUrl } = await ensureCliHubServer(
|
||||
workspaceRoot,
|
||||
requestedEndpoint,
|
||||
);
|
||||
const endpoint = parseHubEndpointOverride(hubUrl);
|
||||
return {
|
||||
ok: true,
|
||||
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
|
||||
client: new HubScheduleClient(endpoint),
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { HubSessionClient, HubSessionRow } from "@cline/core";
|
||||
import {
|
||||
ensureParentDir,
|
||||
getProcessStartToken,
|
||||
resolveClineDataDir,
|
||||
} from "@cline/core";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
withResolvedClineBuildEnv,
|
||||
@@ -89,6 +85,60 @@ type ProcessProbe = {
|
||||
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,
|
||||
|
||||
@@ -1028,7 +1028,6 @@ export async function handleConnectorUserTurn<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
@@ -1118,7 +1117,6 @@ async function runConnectorRuntimeTurnWithRecovery<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
);
|
||||
const request: ChatRunTurnRequest = {
|
||||
config: startRequest,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDialogPalette } from "../tui/hooks/use-theme";
|
||||
import { palette } from "../tui/palette";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -35,7 +35,6 @@ export function MigrationNoticeContent(
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const palette = useDialogPalette();
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "./commands/update";
|
||||
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
|
||||
import type { TuiStartupTarget } from "./tui/types";
|
||||
import { filterChatModels } from "./utils/chat-models";
|
||||
import { getCliBuildInfo } from "./utils/common";
|
||||
import {
|
||||
buildCliCompactionConfig,
|
||||
@@ -1030,9 +1029,7 @@ export async function runCli(): Promise<void> {
|
||||
`${c.dim}[model-catalog] catalog resolution failed (${message})${c.reset}`,
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels
|
||||
? Object.keys(filterChatModels(knownModels))
|
||||
: [];
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const resolvedReasoning = resolveCliReasoning({
|
||||
thinking: args.thinking,
|
||||
thinkingExplicitlySet: args.thinkingExplicitlySet,
|
||||
|
||||
@@ -3,9 +3,7 @@ import { homedir } from "node:os";
|
||||
import { basename, resolve } from "node:path";
|
||||
import {
|
||||
buildWorkspaceMetadata,
|
||||
isSkillsToolAvailable,
|
||||
mergeRulesForSystemPrompt,
|
||||
readGlobalSettings,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
|
||||
@@ -81,30 +79,9 @@ function resolveMentionPath(filePath: string): string {
|
||||
return resolve(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a typed `/skill` command must be textually expanded into the
|
||||
* prompt. When the session registers the runtime's `skills` tool (its
|
||||
* description requires the model to invoke it on slash-command references),
|
||||
* the typed command passes through and the instructions arrive as a tool
|
||||
* result — keeping the persisted transcript as what the user typed. When the
|
||||
* tool is unavailable (yolo preset, user toggle), expansion is the only
|
||||
* delivery path.
|
||||
*/
|
||||
export function shouldExpandSkillSlashCommands(mode?: string): boolean {
|
||||
try {
|
||||
return !isSkillsToolAvailable({
|
||||
mode: mode === "plan" || mode === "yolo" ? mode : "act",
|
||||
disabledToolIds: new Set(readGlobalSettings().disabledTools ?? []),
|
||||
});
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildUserInputMessage(
|
||||
rawPrompt: string,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
options?: { mode?: string },
|
||||
): Promise<{
|
||||
prompt: string;
|
||||
userImages: string[];
|
||||
@@ -113,9 +90,7 @@ export async function buildUserInputMessage(
|
||||
// First, resolve slash commands if the core config service is available.
|
||||
let prompt = rawPrompt;
|
||||
if (userInstructionService) {
|
||||
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt, {
|
||||
expandSkillCommands: shouldExpandSkillSlashCommands(options?.mode),
|
||||
});
|
||||
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt);
|
||||
}
|
||||
|
||||
if (!hasFileMentions(prompt)) {
|
||||
|
||||
@@ -277,9 +277,7 @@ export async function runAgent(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
mode: config.mode,
|
||||
});
|
||||
} = await buildUserInputMessage(prompt, userInstructionService);
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("applyInteractiveModelChange", () => {
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
modes: {},
|
||||
providers: {},
|
||||
modes: {},
|
||||
}));
|
||||
|
||||
@@ -621,9 +621,7 @@ export async function runInteractive(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionService, {
|
||||
mode,
|
||||
});
|
||||
} = await buildUserInputMessage(input, userInstructionService);
|
||||
const mergedUserImages = [
|
||||
...(attachments?.userImages ?? []),
|
||||
...userImages,
|
||||
|
||||
@@ -78,11 +78,7 @@ export async function runZen(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
// Zen runs in yolo mode, whose preset has no skills tool — skill
|
||||
// commands must keep expanding textually.
|
||||
mode: "yolo",
|
||||
});
|
||||
} = await buildUserInputMessage(prompt, userInstructionService);
|
||||
|
||||
const startRequest: ChatStartSessionRequest = {
|
||||
workspaceRoot,
|
||||
|
||||
@@ -13,7 +13,6 @@ export function getToolCatalog(
|
||||
): ToolCatalogEntry[] {
|
||||
const modelToolSettings = resolveModelToolSettings();
|
||||
return getCoreBuiltinToolCatalog({
|
||||
clientType: "cli",
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
enabledModelToolIds: new Set(
|
||||
Object.entries(modelToolSettings)
|
||||
|
||||
@@ -169,39 +169,6 @@ describe("slash command registry", () => {
|
||||
expect(expandUserCommandPrompt("/settings", registry)).toBe("/settings");
|
||||
});
|
||||
|
||||
it("keeps skill commands typed when skill expansion is off, but still wraps workflows", () => {
|
||||
const registry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
{
|
||||
name: "review",
|
||||
instructions: "Review carefully",
|
||||
description: "Review files",
|
||||
kind: "skill",
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
instructions: "Run the release workflow",
|
||||
description: "Release",
|
||||
kind: "workflow",
|
||||
},
|
||||
],
|
||||
});
|
||||
const options = { expandSkillCommands: false };
|
||||
|
||||
// The skills tool delivers the instructions; the transcript keeps the
|
||||
// typed command.
|
||||
expect(
|
||||
expandUserCommandPrompt("/review this file", registry, options),
|
||||
).toBe("/review this file");
|
||||
expect(
|
||||
expandUserCommandPrompt("please /review this file", registry, options),
|
||||
).toBe("please /review this file");
|
||||
// Workflows are not served by the skills tool and keep expanding.
|
||||
expect(expandUserCommandPrompt("/release now", registry, options)).toBe(
|
||||
'<user_command slash="release">Run the release workflow</user_command> now',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expand commands omitted from a refreshed user-command registry", () => {
|
||||
const staleRegistry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
|
||||
@@ -245,33 +245,19 @@ export function formatSlashCommandAutocompleteValue(
|
||||
return `/${entry.name} `;
|
||||
}
|
||||
|
||||
export interface ExpandUserCommandPromptOptions {
|
||||
/**
|
||||
* Whether matched skill commands are wrapped into the prompt. Pass false
|
||||
* when the session registers the runtime's skills tool: the typed
|
||||
* `/skill args` then goes through as-is and the model loads the
|
||||
* instructions via the tool. Workflows always expand (the tool does not
|
||||
* serve them). Defaults to true.
|
||||
*/
|
||||
expandSkillCommands?: boolean;
|
||||
}
|
||||
|
||||
export function expandUserCommandPrompt(
|
||||
input: string,
|
||||
registry: SlashCommandRegistry,
|
||||
options?: ExpandUserCommandPromptOptions,
|
||||
): string {
|
||||
if (input.includes("<user_command")) {
|
||||
return input;
|
||||
}
|
||||
const skipCommand = (command: SlashCommandRegistryEntry): boolean =>
|
||||
command.kind === "skill" && options?.expandSkillCommands === false;
|
||||
|
||||
const expandedSlashCommands = input.replace(
|
||||
USER_COMMAND_SLASH_PATTERN,
|
||||
(match, prefix: string, name: string) => {
|
||||
const command = resolveSlashCommand(registry, name);
|
||||
if (command?.execution !== "user-command" || skipCommand(command)) {
|
||||
if (command?.execution !== "user-command") {
|
||||
return match;
|
||||
}
|
||||
return `${prefix}${formatUserCommandBlock(command.instructions, command.name)}`;
|
||||
@@ -286,11 +272,7 @@ export function expandUserCommandPrompt(
|
||||
return input;
|
||||
}
|
||||
const command = resolveSlashCommand(registry, match[1] ?? "");
|
||||
if (
|
||||
!command ||
|
||||
command.execution !== "user-command" ||
|
||||
skipCommand(command)
|
||||
) {
|
||||
if (!command || command.execution !== "user-command") {
|
||||
return input;
|
||||
}
|
||||
const rest = (match[2] ?? "").trim();
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useRenderer } from "@opentui/react";
|
||||
import { DialogContainerRenderable } from "@opentui-ui/dialog";
|
||||
import { useDialogState } from "@opentui-ui/dialog/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { getDialogSurface } from "../themes";
|
||||
|
||||
/**
|
||||
* Keeps dialog panel backgrounds in sync with the active theme.
|
||||
*
|
||||
* The dialog library computes a panel's style once when the dialog opens
|
||||
* (from the container's dialogOptions), so theme changes made while a dialog
|
||||
* is open — most visibly the live preview while scrolling the theme picker —
|
||||
* would leave the panel on the old surface color. This component pushes the
|
||||
* theme's dialog surface into the container (for dialogs opened later) and
|
||||
* onto every open dialog renderable (repainting them in place).
|
||||
*
|
||||
* Must be mounted inside the DialogProvider. Re-runs when a dialog opens so
|
||||
* the first dialog after mount is covered too (the container is only added
|
||||
* to the renderer root after this component's initial effect).
|
||||
*/
|
||||
export function DialogThemeSync() {
|
||||
const renderer = useRenderer();
|
||||
const theme = useTheme();
|
||||
const dialogCount = useDialogState((state: { count: number }) => state.count);
|
||||
const surface = getDialogSurface(theme);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: dialogCount re-runs the sync when a dialog opens, covering dialogs opened before the container-level option applied (see docblock).
|
||||
useEffect(() => {
|
||||
const container = renderer.root
|
||||
.getChildren()
|
||||
.find(
|
||||
(child): child is DialogContainerRenderable =>
|
||||
child instanceof DialogContainerRenderable,
|
||||
);
|
||||
if (!container) return;
|
||||
container.dialogOptions = { style: { backgroundColor: surface } };
|
||||
for (const [, dialogRenderable] of container.getDialogRenderables()) {
|
||||
dialogRenderable.backgroundColor = surface;
|
||||
}
|
||||
}, [renderer, surface, dialogCount]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
formatClineCredits,
|
||||
isClineAccountAuthErrorMessage,
|
||||
} from "../../cline-account";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export type AccountDialogAction =
|
||||
| "change-model"
|
||||
@@ -121,7 +121,6 @@ function AccountActionRow(props: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
const fg = props.selected ? palette.textOnSelection : undefined;
|
||||
return (
|
||||
<box
|
||||
@@ -139,7 +138,7 @@ function AccountActionRow(props: {
|
||||
fg={props.selected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.selected ? "❯" : " "}
|
||||
{props.selected ? ">" : " "}
|
||||
</text>
|
||||
<text fg={fg} flexShrink={0}>
|
||||
{props.action.label}
|
||||
@@ -162,7 +161,6 @@ function OrganizationRow(props: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -179,7 +177,7 @@ function OrganizationRow(props: {
|
||||
fg={props.selected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.selected ? "❯" : " "}
|
||||
{props.selected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={props.selected ? palette.textOnSelection : undefined}
|
||||
@@ -236,7 +234,6 @@ export function AccountDialogContent(
|
||||
switchAccount,
|
||||
onAccountChange,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [state, setState] = useState<AccountState>({
|
||||
status: "loading",
|
||||
message: "Loading account details...",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export function AskQuestionContent(
|
||||
props: ChoiceContext<string | null> & {
|
||||
@@ -11,7 +11,6 @@ export function AskQuestionContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dialogId, question, options } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [inputKey, setInputKey] = useState(0);
|
||||
const customRef = useRef("");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export type CheckpointRestoreMode = "chat-only" | "chat-and-workspace";
|
||||
|
||||
@@ -29,7 +29,6 @@ export function CheckpointConfirmContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, messagePreview } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const selectedRef = useRef(0);
|
||||
const selectedMode = OPTIONS[selected]?.value;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export interface CheckpointPickerItem {
|
||||
runCount: number;
|
||||
@@ -36,7 +36,6 @@ export function CheckpointPickerContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, items } = props;
|
||||
const palette = useDialogPalette();
|
||||
const lastIndex = Math.max(0, items.length - 1);
|
||||
const [selected, setSelected] = useState(lastIndex);
|
||||
const selectedRef = useRef(lastIndex);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildCommandPaletteItems,
|
||||
type CommandPaletteResult,
|
||||
@@ -32,7 +32,6 @@ export function CommandPaletteContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, canForkSession, contentWidth } = props;
|
||||
const palette = useDialogPalette();
|
||||
const { height } = useTerminalDimensions();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../interactive-config";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getExtDetailFooterText,
|
||||
getExtDetailRows,
|
||||
@@ -23,7 +23,6 @@ export function ExtDetailContent(
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
},
|
||||
) {
|
||||
const palette = useDialogPalette();
|
||||
const [item, setItem] = useState(props.item);
|
||||
const [toggleError, setToggleError] = useState<string | undefined>();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
type HelpRow =
|
||||
| { kind: "heading"; id: string; text: string }
|
||||
@@ -255,7 +255,6 @@ const KEY_WIDTH = 20;
|
||||
|
||||
export function HelpDialogContent(props: ChoiceContext<void>) {
|
||||
const { dismiss, dialogId } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
@@ -12,7 +12,6 @@ export function HubUpdateRequiredContent(
|
||||
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
|
||||
) {
|
||||
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export interface McpEntry {
|
||||
name: string;
|
||||
@@ -70,7 +70,6 @@ export function McpManagerContent(
|
||||
servers: McpEntry[];
|
||||
},
|
||||
) {
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [servers, setServers] = useState(props.servers);
|
||||
const [changed, setChanged] = useState(false);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
@@ -63,7 +63,6 @@ export function ProviderPickerContent(
|
||||
props: ChoiceContext<string> & { currentProviderId: string },
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, currentProviderId } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [providers, setProviders] = useState<ProviderItem[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
@@ -273,7 +272,6 @@ export function UseExistingOrReconfigureContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
|
||||
const palette = useDialogPalette();
|
||||
const options: ExistingProviderOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
@@ -353,7 +351,6 @@ function ClinePassBrowserPageContent(
|
||||
url,
|
||||
openedStatus,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState("Opening browser...");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -492,7 +489,6 @@ export function ProviderConfigInputContent(
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
const config = useMemo(
|
||||
() => getProviderConfigFields(providerId),
|
||||
@@ -660,7 +656,6 @@ export function CodexCliStatusContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
@@ -754,7 +749,6 @@ export function OAuthLoginContent(
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -975,7 +969,6 @@ export function OAuthApiKeyInputContent(
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { SlashCommandRegistryEntry } from "../../commands/slash-command-registry";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export const SKILLS_MARKETPLACE_ACTION = "__skills_marketplace__";
|
||||
export const SKILLS_MARKETPLACE_URL = "https://skills.sh/";
|
||||
@@ -26,7 +26,6 @@ function matchesFilter(
|
||||
|
||||
export function SkillsPickerContent(props: SkillsPickerContentProps) {
|
||||
const { resolve, dismiss, dialogId, commands } = props;
|
||||
const palette = useDialogPalette();
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { useDialogPalette, useThemeController } from "../../hooks/use-theme";
|
||||
import { useThemeController } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { getThemeSwatchColors, THEMES } from "../../themes";
|
||||
|
||||
const SWATCH_BLOCK = "\u25a0";
|
||||
@@ -11,7 +12,6 @@ export function ThemePickerContent(props: ChoiceContext<string>) {
|
||||
const { resolve, dismiss, dialogId } = props;
|
||||
const { height } = useTerminalDimensions();
|
||||
const controller = useThemeController();
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const index = THEMES.findIndex(
|
||||
(theme) => theme.id === controller.selectedThemeId,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ToolApprovalRequest } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import type React from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
@@ -139,7 +139,6 @@ export function formatApprovalParams(
|
||||
export function ToolApprovalContent(
|
||||
props: ChoiceContext<boolean> & { request: ToolApprovalRequest },
|
||||
) {
|
||||
const palette = useDialogPalette();
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "y" || key.name === "return") {
|
||||
props.resolve(true);
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type { DialogPalette } from "../../themes";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -25,7 +24,7 @@ export {
|
||||
freeTierDescriptionFor,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string, palette: DialogPalette): string {
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
@@ -59,7 +58,6 @@ export function ClineModelPicker(props: {
|
||||
currentModelId?: string;
|
||||
}) {
|
||||
const { entries, selected, loading, currentModelId } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -121,7 +119,7 @@ export function ClineModelPicker(props: {
|
||||
{tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type { DialogPalette } from "../../themes";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -19,7 +18,7 @@ type ClineModelEntriesState =
|
||||
| { status: "loaded"; entries: ClineModelPickerEntry[] }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function tagColor(tag: string, palette: DialogPalette): string {
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
@@ -40,7 +39,6 @@ export function ClineModelSelectorContent(
|
||||
currentProviderName,
|
||||
entries,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
|
||||
@@ -190,7 +188,7 @@ export function ClineModelSelectorContent(
|
||||
{row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
@@ -224,7 +222,6 @@ export function ClineModelSelectorDialogContent(
|
||||
},
|
||||
) {
|
||||
const { dismiss, dialogId, loadEntries } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [state, setState] = useState<ClineModelEntriesState>({
|
||||
status: "loading",
|
||||
message: "Loading Cline models...",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Llms } from "@cline/core";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
export interface ModelOption {
|
||||
@@ -65,7 +65,6 @@ export function ModelIdInputContent(
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, currentModel, currentProviderName } =
|
||||
props;
|
||||
const palette = useDialogPalette();
|
||||
const [modelId, setModelId] = useState(currentModel);
|
||||
const [error, setError] = useState("");
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
@@ -329,7 +328,6 @@ export function ThinkingLevelContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(() => {
|
||||
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
|
||||
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
|
||||
@@ -518,7 +516,6 @@ function CreateCustomModelRow(props: {
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { isSelected, dimmed, onSelect } = props;
|
||||
const palette = useDialogPalette();
|
||||
const active = isSelected && !dimmed;
|
||||
const bg = active
|
||||
? palette.selection
|
||||
@@ -556,7 +553,6 @@ function ModelRow(props: {
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
const { model, isSelected, dimmed, isCurrent, onSelect } = props;
|
||||
const palette = useDialogPalette();
|
||||
const active = isSelected && !dimmed;
|
||||
const bg = active
|
||||
? palette.selection
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
|
||||
export function ProviderRow({
|
||||
providerName,
|
||||
@@ -8,7 +8,6 @@ export function ProviderRow({
|
||||
providerName: string;
|
||||
focused: boolean;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
return (
|
||||
<box flexDirection="row" paddingX={1} gap={1}>
|
||||
<text fg={focused ? palette.selection : "gray"} flexShrink={0}>
|
||||
|
||||
@@ -55,6 +55,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands optional output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -75,6 +76,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -160,6 +162,7 @@ function EditOutput(props: {
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -216,6 +219,7 @@ function ApplyPatchOutput(props: {
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -263,6 +267,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
: displayText;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands long output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -278,6 +283,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -304,6 +310,7 @@ export function ToolOutput(props: ToolOutputProps) {
|
||||
const showDetail =
|
||||
errorExpanded && presentation.detail !== presentation.summary.trim();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles error details.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { shouldExpandSkillSlashCommands } from "../../runtime/prompt";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
|
||||
@@ -309,12 +308,6 @@ export function usePromptInputController(input: {
|
||||
const promptForSubmit = expandUserCommandPrompt(
|
||||
expandedPrompt,
|
||||
slashCommandRegistry,
|
||||
// Skills load through the runtime's skills tool when it is
|
||||
// available for the current mode; the typed command then goes
|
||||
// through as-is so the transcript keeps what the user typed.
|
||||
{
|
||||
expandSkillCommands: shouldExpandSkillSlashCommands(session.uiMode),
|
||||
},
|
||||
);
|
||||
|
||||
session.setHasSubmitted(true);
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
type DialogPalette,
|
||||
getDialogPalette,
|
||||
type ResolvedTheme,
|
||||
resolveTheme,
|
||||
} from "../themes";
|
||||
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
|
||||
|
||||
export interface TerminalColors {
|
||||
background: string | null;
|
||||
@@ -47,17 +41,6 @@ export function useTheme(): ResolvedTheme {
|
||||
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-following colors for dialog content. Unlike the static `palette`
|
||||
* constant, this re-resolves whenever the active theme changes, so open
|
||||
* dialogs repaint live during theme previews (e.g. scrolling the theme
|
||||
* picker).
|
||||
*/
|
||||
export function useDialogPalette(): DialogPalette {
|
||||
const theme = useTheme();
|
||||
return useMemo(() => getDialogPalette(theme), [theme]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background that adaptive colors (input field, user bubbles, rules) derive
|
||||
* from: the theme's painted background when set, else the detected one.
|
||||
|
||||
@@ -169,15 +169,6 @@ export function getUserMessageBackground(terminalBg: string | null): string {
|
||||
return liftedFromTerminalBg(terminalBg, BASE_LIFT, 0, 0);
|
||||
}
|
||||
|
||||
// Dialog panels lift the theme background the same way, but a touch softer:
|
||||
// they cover a large area and sit over a dimmed backdrop, so a smaller step
|
||||
// already reads as "raised" without washing out the theme's hue.
|
||||
const DIALOG_SURFACE_LIFT = 0.16;
|
||||
|
||||
export function getDialogSurfaceBackground(terminalBg: string | null): string {
|
||||
return liftedFromTerminalBg(terminalBg, DIALOG_SURFACE_LIFT, 0, 0);
|
||||
}
|
||||
|
||||
export function getModeInputForeground(
|
||||
mode: string,
|
||||
terminalBg: string | null,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "../utils/repo-status";
|
||||
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
|
||||
import type { TranscriptScrollHandle } from "./components/chat-message-list";
|
||||
import { DialogThemeSync } from "./components/dialog-theme-sync";
|
||||
import {
|
||||
CheckpointConfirmContent,
|
||||
type CheckpointRestoreMode,
|
||||
@@ -1039,7 +1038,6 @@ export function Root(
|
||||
<TerminalColorsContext value={terminalColors}>
|
||||
<ThemeProvider initialThemeId={props.initialThemeId}>
|
||||
<DialogProvider size="medium">
|
||||
<DialogThemeSync />
|
||||
<SessionProvider
|
||||
config={props.config}
|
||||
initialEntries={initialEntries}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { diffPalettes, themePalette } from "./palette";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
getDialogAccents,
|
||||
getDialogPalette,
|
||||
getDialogSurface,
|
||||
getThemeDefinition,
|
||||
getThemeModeAccent,
|
||||
getThemeSwatchColors,
|
||||
@@ -167,66 +164,4 @@ describe("theme helpers", () => {
|
||||
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
|
||||
expect(getThemeDefinition("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getDialogPalette follows the theme's dialog accents", () => {
|
||||
const dracula = getDialogPalette(resolveTheme("dracula", noDetection));
|
||||
expect(dracula.act).toBe("#bd93f9");
|
||||
expect(dracula.selection).toBe("#bd93f9");
|
||||
expect(dracula.success).toBe("#50fa7b");
|
||||
expect(dracula.error).toBe("#ff5555");
|
||||
expect(dracula.textOnSelection).toBe("#000000");
|
||||
|
||||
// Light themes fall back to dark accents (dialog surfaces stay dark).
|
||||
const solarizedLight = getDialogPalette(
|
||||
resolveTheme("solarized-light", noDetection),
|
||||
);
|
||||
expect(solarizedLight.act).toBe(themePalette.dark.act);
|
||||
|
||||
for (const definition of THEMES) {
|
||||
const dialogPalette = getDialogPalette(
|
||||
resolveTheme(definition.id, noDetection),
|
||||
);
|
||||
expect(dialogPalette.selection).toBe(dialogPalette.act);
|
||||
expect(["#000000", "#ffffff"]).toContain(dialogPalette.textOnSelection);
|
||||
}
|
||||
});
|
||||
|
||||
it("getDialogSurface lifts dark theme backgrounds and keeps hue", () => {
|
||||
// Dark themes derive the panel from their own background: a different,
|
||||
// lighter color than both the background and the neutral default.
|
||||
for (const id of ["tokyo-night", "dracula", "gruvbox-dark", "nord"]) {
|
||||
const theme = resolveTheme(id, noDetection);
|
||||
const surface = getDialogSurface(theme);
|
||||
expect(surface).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(surface).not.toBe(theme.background);
|
||||
expect(surface).not.toBe(DEFAULT_DIALOG_SURFACE);
|
||||
}
|
||||
|
||||
// Auto with no detected background has nothing to derive from.
|
||||
expect(getDialogSurface(resolveTheme(AUTO_THEME_ID, noDetection))).toBe(
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
);
|
||||
// Auto on a detected dark terminal lifts the detected background.
|
||||
expect(
|
||||
getDialogSurface(
|
||||
resolveTheme(AUTO_THEME_ID, {
|
||||
background: "#000000",
|
||||
foreground: null,
|
||||
}),
|
||||
),
|
||||
).not.toBe(DEFAULT_DIALOG_SURFACE);
|
||||
|
||||
// Light themes keep the neutral dark panel (dialog content still uses
|
||||
// the dark accent fallback, and hardcoded light text must stay legible).
|
||||
expect(getDialogSurface(resolveTheme("light", noDetection))).toBe(
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
);
|
||||
expect(getDialogSurface(resolveTheme("solarized-light", noDetection))).toBe(
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
);
|
||||
|
||||
// The palette exposes the same surface.
|
||||
const dracula = resolveTheme("dracula", noDetection);
|
||||
expect(getDialogPalette(dracula).surface).toBe(getDialogSurface(dracula));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
diffPalettes,
|
||||
getDefaultForeground,
|
||||
getDialogSurfaceBackground,
|
||||
getTerminalTheme,
|
||||
hexToOklab,
|
||||
oklabToHex,
|
||||
@@ -568,53 +567,6 @@ export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
|
||||
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
|
||||
}
|
||||
|
||||
/** Neutral dark panel used when no theme background is available to derive
|
||||
* a surface from (auto theme on an undetected terminal, light themes). */
|
||||
export const DEFAULT_DIALOG_SURFACE = "#262626";
|
||||
|
||||
/**
|
||||
* Background for dialog panels. Dark themes get their own background lifted
|
||||
* one perceptual step (OKLAB), so the panel keeps the theme's hue and reads
|
||||
* as a raised surface of the same world. Light themes and undetectable
|
||||
* backgrounds keep the neutral dark panel that matches the dark accent
|
||||
* fallback in getDialogAccents.
|
||||
*/
|
||||
export function getDialogSurface(theme: ResolvedTheme): string {
|
||||
if (theme.variant !== "dark" || !theme.background) {
|
||||
return DEFAULT_DIALOG_SURFACE;
|
||||
}
|
||||
return getDialogSurfaceBackground(theme.background);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colors for content rendered on the always-dark dialog surface, following
|
||||
* the active theme's accents (see getDialogAccents). Mirrors the shape of
|
||||
* the static `palette` so dialog components can consume it as a drop-in
|
||||
* replacement that re-resolves on every theme change (including previews).
|
||||
*/
|
||||
export interface DialogPalette extends ThemeAccents {
|
||||
selection: string;
|
||||
textOnSelection: string;
|
||||
muted: string;
|
||||
/** Panel background the dialog content is rendered on. */
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export function getDialogPalette(theme: ResolvedTheme): DialogPalette {
|
||||
const accents = getDialogAccents(theme);
|
||||
const selection = accents.act;
|
||||
return {
|
||||
...accents,
|
||||
selection,
|
||||
textOnSelection:
|
||||
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
|
||||
? "#000000"
|
||||
: "#ffffff",
|
||||
muted: "gray",
|
||||
surface: getDialogSurface(theme),
|
||||
};
|
||||
}
|
||||
|
||||
/** Small color strip rendered next to each entry in the theme picker. */
|
||||
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
|
||||
const variant = definition.variant === "auto" ? "dark" : definition.variant;
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
|
||||
import { resolveModelDisplayName } from "../components/status-bar";
|
||||
import { useDialogPalette, useThemeController } from "../hooks/use-theme";
|
||||
import { type DialogPalette, getThemeDefinition } from "../themes";
|
||||
import { useThemeController } from "../hooks/use-theme";
|
||||
import { palette } from "../palette";
|
||||
import { getDialogAccents, getThemeDefinition } from "../themes";
|
||||
import {
|
||||
type ConfigAction,
|
||||
canDeleteConfigFooterRow,
|
||||
@@ -117,13 +118,11 @@ function getVisibleWindow<T>(
|
||||
return { items: items.slice(start, end), startIndex: start };
|
||||
}
|
||||
|
||||
function getCompactionModeColor(
|
||||
mode: CliCompactionMode,
|
||||
palette: DialogPalette,
|
||||
): string {
|
||||
if (mode === "agentic") return palette.success;
|
||||
return mode === "basic" ? "yellow" : "gray";
|
||||
}
|
||||
const COMPACTION_MODE_COLORS: Record<CliCompactionMode, string> = {
|
||||
agentic: palette.success,
|
||||
basic: "yellow",
|
||||
off: "gray",
|
||||
};
|
||||
|
||||
export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
|
||||
config: Config;
|
||||
@@ -410,7 +409,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const [toggleError, setToggleError] = useState<string | undefined>();
|
||||
const [navPos, setNavPos] = useState(0);
|
||||
const themeController = useThemeController();
|
||||
const palette = useDialogPalette();
|
||||
const dialogAccents = getDialogAccents(themeController.theme);
|
||||
const currentThemeLabel =
|
||||
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
|
||||
|
||||
@@ -824,10 +823,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
let valueColor: string;
|
||||
if (row.id === "mode") {
|
||||
value = mode === "plan" ? "Plan" : "Act";
|
||||
valueColor = mode === "plan" ? palette.plan : palette.act;
|
||||
valueColor =
|
||||
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
|
||||
} else if (row.id === "theme") {
|
||||
value = currentThemeLabel;
|
||||
valueColor = palette.act;
|
||||
valueColor = dialogAccents.act;
|
||||
} else if (row.id === "auto-approve") {
|
||||
value = autoApprove ? "● on" : "○ off";
|
||||
valueColor = autoApprove ? palette.success : "gray";
|
||||
@@ -836,7 +836,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
valueColor = autoUpdateEnabled ? palette.success : "gray";
|
||||
} else if (row.id === "compaction") {
|
||||
value = formatCliCompactionMode(compactionMode);
|
||||
valueColor = getCompactionModeColor(compactionMode, palette);
|
||||
valueColor = COMPACTION_MODE_COLORS[compactionMode];
|
||||
} else {
|
||||
value = verbose ? "● on" : "○ off";
|
||||
valueColor = verbose ? palette.success : "gray";
|
||||
|
||||
@@ -15,7 +15,7 @@ import { listSessions } from "../../session/session";
|
||||
import { mergeHistoryStatusRows } from "../../utils/history-format";
|
||||
import { formatUsd } from "../../utils/output";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import { useDialogPalette } from "../hooks/use-theme";
|
||||
import { palette } from "../palette";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
@@ -98,7 +98,6 @@ function HistoryListContent({
|
||||
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
|
||||
registerKeyHandler,
|
||||
}: HistoryListContentProps) {
|
||||
const palette = useDialogPalette();
|
||||
const { width } = useTerminalDimensions();
|
||||
const [rows, setRows] = useState<SessionHistoryRecord[]>(
|
||||
() => initialRows ?? [],
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import {
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
@@ -234,7 +233,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const ids = new Set<string>();
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
for (const m of result.value.models.filter(isChatProviderModel)) {
|
||||
for (const m of result.value.models) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
}
|
||||
@@ -284,7 +283,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId,
|
||||
providerConfig,
|
||||
);
|
||||
return models.filter(isChatProviderModel).map(toModelEntry);
|
||||
return models.map(toModelEntry);
|
||||
})
|
||||
.then((models) => {
|
||||
setModelEntries(models);
|
||||
|
||||
@@ -128,31 +128,6 @@ describe("onboarding model helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps non-chat models out of the onboarding model picker", () => {
|
||||
expect(
|
||||
toModelEntriesFromKnownModels({
|
||||
"operation-only-whisper": {
|
||||
name: "Operation-only Whisper",
|
||||
operation: "transcription",
|
||||
},
|
||||
"whisper-large-v3": {
|
||||
name: "Whisper Large V3",
|
||||
modalities: { input: ["audio"], output: ["text"] },
|
||||
},
|
||||
"llama-chat": {
|
||||
name: "Llama Chat",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "llama-chat",
|
||||
name: "Llama Chat",
|
||||
supportsReasoning: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats OAuth provider labels for onboarding status views", () => {
|
||||
expect(getOAuthProviderLabel("cline")).toBe("Cline");
|
||||
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import type {
|
||||
ChatModelModalities,
|
||||
ModelModality,
|
||||
ModelOperation,
|
||||
} from "@cline/shared";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
|
||||
@@ -157,16 +151,11 @@ export interface ProviderModelItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
supportsReasoning?: boolean;
|
||||
operation?: ModelOperation;
|
||||
inputModalities?: ModelModality[];
|
||||
outputModalities?: ModelModality[];
|
||||
}
|
||||
|
||||
export interface KnownModelInfo {
|
||||
name?: string;
|
||||
capabilities?: string[];
|
||||
operation?: ModelOperation;
|
||||
modalities?: ChatModelModalities;
|
||||
}
|
||||
|
||||
export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
|
||||
@@ -196,13 +185,6 @@ export function toModelEntriesFromKnownModels(
|
||||
): ModelEntry[] {
|
||||
if (!knownModels) return [];
|
||||
return Object.entries(knownModels)
|
||||
.filter(([, info]) =>
|
||||
isChatProviderModel({
|
||||
operation: info.operation,
|
||||
inputModalities: info.modalities?.input,
|
||||
outputModalities: info.modalities?.output,
|
||||
}),
|
||||
)
|
||||
.map(([id, info]) => ({
|
||||
id,
|
||||
name: info.name || id,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterChatModels, isChatProviderModel } from "./chat-models";
|
||||
|
||||
describe("chat model filtering", () => {
|
||||
it("keeps unknown and text-capable catalog models", () => {
|
||||
const models = {
|
||||
legacy: { name: "Legacy" },
|
||||
chat: {
|
||||
name: "Chat",
|
||||
modalities: { input: ["text"] as const, output: ["text"] as const },
|
||||
},
|
||||
mixed: {
|
||||
name: "Mixed",
|
||||
modalities: {
|
||||
input: ["text", "image"] as const,
|
||||
output: ["text", "image"] as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(filterChatModels(models))).toEqual([
|
||||
"legacy",
|
||||
"chat",
|
||||
"mixed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes dedicated transcription and media-generation models", () => {
|
||||
const models = {
|
||||
operationOnly: { operation: "speech-generation" as const },
|
||||
whisper: {
|
||||
modalities: { input: ["audio"] as const, output: ["text"] as const },
|
||||
},
|
||||
tts: {
|
||||
modalities: { input: ["text"] as const, output: ["audio"] as const },
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterChatModels(models)).toEqual({});
|
||||
});
|
||||
|
||||
it("filters flattened provider model responses with the same rule", () => {
|
||||
expect(isChatProviderModel({ operation: "transcription" })).toBe(false);
|
||||
expect(
|
||||
isChatProviderModel({
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChatProviderModel({
|
||||
inputModalities: ["text", "image"],
|
||||
outputModalities: ["text"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isChatProviderModel({})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
type ChatModelModalities,
|
||||
isChatCompatibleModel,
|
||||
type ModelModality,
|
||||
type ModelOperation,
|
||||
} from "@cline/shared";
|
||||
|
||||
export type ChatCatalogModel = {
|
||||
readonly operation?: ModelOperation;
|
||||
readonly modalities?: ChatModelModalities;
|
||||
readonly [key: string]: unknown;
|
||||
};
|
||||
|
||||
export function filterChatModels<T extends ChatCatalogModel>(
|
||||
models: Readonly<Record<string, T>>,
|
||||
): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(models).filter(([, model]) => isChatCompatibleModel(model)),
|
||||
);
|
||||
}
|
||||
|
||||
export function isChatProviderModel(model: {
|
||||
readonly operation?: ModelOperation;
|
||||
readonly inputModalities?: readonly ModelModality[];
|
||||
readonly outputModalities?: readonly ModelModality[];
|
||||
}): boolean {
|
||||
return isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dirname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { ClientSettingsManager, ProviderSettingsManager } from "@cline/core";
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
|
||||
import type { BrowserConfig } from "./types";
|
||||
|
||||
@@ -19,6 +19,12 @@ export const cliIndexPath = normalize(
|
||||
);
|
||||
|
||||
export const providerSettingsManager = new ProviderSettingsManager();
|
||||
export const desktopClientSettingsManager = new ClientSettingsManager({
|
||||
clientId: "desktop",
|
||||
});
|
||||
desktopClientSettingsManager.initializeModesIfMissing(
|
||||
providerSettingsManager.read().modes,
|
||||
);
|
||||
|
||||
export const browserConfig: BrowserConfig = {
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
|
||||
@@ -17,19 +17,25 @@ import {
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
parseProviderModeSettings,
|
||||
readGlobalSettings,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
saveModeSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setTelemetryOptOutGlobally,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { getClineEnvironmentConfig, ProviderModeSchema } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
desktopClientSettingsManager,
|
||||
providerSettingsManager,
|
||||
workspaceRoot,
|
||||
} from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -103,6 +109,7 @@ export async function handleDesktopCommand(
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
modeSettings: desktopClientSettingsManager.read().modes,
|
||||
});
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
@@ -112,6 +119,18 @@ export async function handleDesktopCommand(
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
}
|
||||
if (command === "save_mode_settings") {
|
||||
const mode = ProviderModeSchema.parse(args?.mode);
|
||||
const settings =
|
||||
args?.settings == null
|
||||
? undefined
|
||||
: parseProviderModeSettings(mode, args.settings);
|
||||
return await saveModeSettings(
|
||||
providerSettingsManager,
|
||||
{ mode, settings },
|
||||
desktopClientSettingsManager,
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
return saveLocalProviderSettings(providerSettingsManager, {
|
||||
...readProviderSettingsUpdate(args),
|
||||
@@ -253,7 +272,7 @@ export async function handleDesktopCommand(
|
||||
return path;
|
||||
}
|
||||
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
|
||||
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
}
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot, cwd: workspaceRoot };
|
||||
|
||||
@@ -2,6 +2,7 @@ import process from "node:process";
|
||||
import {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
isChatProviderModel,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isChatCompatibleModel } from "@cline/shared";
|
||||
import type {
|
||||
WebviewInboundMessage,
|
||||
WebviewProviderModel,
|
||||
@@ -88,19 +88,10 @@ export async function loadModels(
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.filter(isChatProviderModel)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
operation: model.operation,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
inputModalities: model.inputModalities,
|
||||
|
||||
@@ -27,20 +27,13 @@ function getCommands(): HubScheduleCommandService {
|
||||
async function clientCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await getCommands().handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-hub-schedules",
|
||||
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
|
||||
},
|
||||
);
|
||||
const reply = await getCommands().handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -77,17 +70,14 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
workspaceRoot = process.cwd(),
|
||||
): Promise<unknown> {
|
||||
const commandHub = (hubCommand: string, payload?: Record<string, unknown>) =>
|
||||
clientCommand(hubCommand, payload, workspaceRoot);
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
commandHub("schedule.list", {
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
commandHub("schedule.active"),
|
||||
commandHub("schedule.upcoming", { limit: 30 }),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const scheduleRows = Array.isArray(schedules.schedules)
|
||||
? schedules.schedules
|
||||
@@ -98,7 +88,7 @@ export async function handleRoutineScheduleCommand(
|
||||
(schedule as Record<string, unknown>).scheduleId,
|
||||
);
|
||||
if (!scheduleId) return undefined;
|
||||
const reply = await commandHub("schedule.list_executions", {
|
||||
const reply = await clientCommand("schedule.list_executions", {
|
||||
scheduleId,
|
||||
limit: 1,
|
||||
});
|
||||
@@ -125,7 +115,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await commandHub("schedule.create", {
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
@@ -159,7 +149,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await commandHub("schedule.update", {
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
@@ -190,25 +180,25 @@ export async function handleRoutineScheduleCommand(
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await commandHub("schedule.disable", { scheduleId });
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await commandHub("schedule.enable", { scheduleId });
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const existing = await commandHub("schedule.get", { scheduleId });
|
||||
const existing = await clientCommand("schedule.get", { scheduleId });
|
||||
if (!existing.schedule)
|
||||
throw new Error(`schedule not found: ${scheduleId}`);
|
||||
const reply = await commandHub("schedule.trigger", {
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await commandHub("schedule.delete", { scheduleId });
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
|
||||
@@ -88,7 +88,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Cline Desktop", name: "Cline Desktop" };
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
}
|
||||
return {
|
||||
key: client.clientId,
|
||||
|
||||
@@ -15,12 +15,7 @@ export type WebviewUsage = {
|
||||
|
||||
export type WebviewProviderModel = Pick<
|
||||
ProviderModel,
|
||||
| "id"
|
||||
| "name"
|
||||
| "operation"
|
||||
| "supportsReasoning"
|
||||
| "inputModalities"
|
||||
| "outputModalities"
|
||||
"id" | "name" | "supportsReasoning" | "inputModalities" | "outputModalities"
|
||||
> & {
|
||||
supportsThinking?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildProviderModelCatalog } from "./provider-model-catalog";
|
||||
import type { Provider } from "./provider-schema";
|
||||
|
||||
function provider(modelList: NonNullable<Provider["modelList"]>): Provider {
|
||||
return {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
models: modelList.length,
|
||||
color: "#000000",
|
||||
letter: "T",
|
||||
enabled: true,
|
||||
modelList,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildProviderModelCatalog", () => {
|
||||
it("keeps legacy and mixed chat models while excluding dedicated media endpoints", () => {
|
||||
const catalog = buildProviderModelCatalog([
|
||||
provider([
|
||||
{ id: "legacy", name: "Legacy" },
|
||||
{
|
||||
id: "operation-only-transcription",
|
||||
name: "Operation-only Transcription",
|
||||
operation: "transcription",
|
||||
},
|
||||
{
|
||||
id: "mixed",
|
||||
name: "Mixed",
|
||||
inputModalities: ["text", "image"],
|
||||
outputModalities: ["text", "image"],
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "transcription",
|
||||
name: "Transcription",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
name: "Image",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["image"],
|
||||
},
|
||||
{
|
||||
id: "image-operation",
|
||||
name: "Image Operation",
|
||||
operation: "image-generation",
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(catalog.enabledProviderIds).toEqual(["test"]);
|
||||
expect(catalog.providerModels.test).toEqual(["legacy", "mixed"]);
|
||||
expect(catalog.providerReasoningModels.test).toEqual(["mixed"]);
|
||||
});
|
||||
|
||||
it("omits enabled providers with no chat-compatible models", () => {
|
||||
const catalog = buildProviderModelCatalog([
|
||||
provider([
|
||||
{
|
||||
id: "speech",
|
||||
name: "Speech",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["audio"],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(catalog.enabledProviderIds).toEqual([]);
|
||||
expect(catalog.providerModels.test).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { isChatCompatibleModel } from "@cline/shared";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
Provider,
|
||||
@@ -18,14 +17,12 @@ export type ProviderModelCatalog = {
|
||||
|
||||
function toModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
.filter(
|
||||
(model) =>
|
||||
(model.inputModalities === undefined ||
|
||||
model.inputModalities.includes("text")) &&
|
||||
(model.outputModalities === undefined ||
|
||||
model.outputModalities.includes("text")),
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
@@ -34,14 +31,11 @@ function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter(
|
||||
(model) =>
|
||||
model.supportsReasoning &&
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
(model.inputModalities === undefined ||
|
||||
model.inputModalities.includes("text")) &&
|
||||
(model.outputModalities === undefined ||
|
||||
model.outputModalities.includes("text")) &&
|
||||
model.supportsReasoning,
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelModality, ModelOperation } from "@cline/shared";
|
||||
import type { ProviderModesSettings } from "@cline/shared/browser";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
@@ -6,11 +6,12 @@ export interface ProviderModel {
|
||||
supportsAttachments?: boolean;
|
||||
supportsVision?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
operation?: ModelOperation;
|
||||
inputModalities?: ModelModality[];
|
||||
outputModalities?: ModelModality[];
|
||||
}
|
||||
|
||||
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
|
||||
|
||||
export type ProviderConfigFieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
@@ -68,6 +69,7 @@ export interface ProviderSettingsUpdate {
|
||||
export interface ProviderCatalogResponse {
|
||||
providers: Provider[];
|
||||
settingsPath: string;
|
||||
modes: ProviderModesSettings;
|
||||
}
|
||||
|
||||
export interface ProviderModelsResponse {
|
||||
|
||||
@@ -8,10 +8,6 @@ export default defineConfig({
|
||||
root: rootDir,
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@\/(.+)$/,
|
||||
replacement: resolve(rootDir, "src/webview/src/$1"),
|
||||
},
|
||||
{
|
||||
find: /^@cline\/core$/,
|
||||
replacement: resolve(rootDir, "../../sdk/packages/core/src/index.ts"),
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
# Cline Desktop Changelog
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.15
|
||||
## 0.0.14-beta.1
|
||||
|
||||
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
|
||||
- Refreshed app icons and branding
|
||||
- Reskinned the first-run onboarding, with an interactive welcome graphic
|
||||
- Plugins, MCP servers, and Skills are now one Plugins hub with a dedicated Marketplace page
|
||||
- The composer's model selector now leads with Recommended and Free tiers (Subscribed and Free on ClinePass), labeled by display name with descriptions, instead of an alphabetized list of raw model ids. Provider settings show the same badges and descriptions
|
||||
- Agents can now create and manage durable todos and one-time or recurring schedules
|
||||
- Fixed checkpoint restore wedging permanently. Sessions that were never prompted — and persistence-only updates — reported a bogus "running" status, so anything gated on an active turn stayed blocked forever
|
||||
- Fixed "No sessions found" flashing while session history was still loading
|
||||
- Fixed the work summary undercounting elapsed time when thinking before a tool call attached to the answer instead of the run
|
||||
- Fixed the settings gear keeping its hover state while the Account screen is open
|
||||
- Fixed ClinePass not being recognized as OAuth-managed in the chat credential gate, which asked for credentials it already had
|
||||
- Fixed copying a user message bringing along its internal envelope
|
||||
- Fixed multi-line code blocks collapsing onto a single line
|
||||
- Image, voice, and other non-chat models are no longer offered in chat model pickers
|
||||
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hook output and `cancel` control being discarded
|
||||
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown
|
||||
- PowerShell commands now fail fast on the first error instead of flooding output and still reporting success
|
||||
- Usage now displays the billed gateway cost
|
||||
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
|
||||
|
||||
## 0.0.14
|
||||
|
||||
- The app now posts native macOS notifications when a task finishes or needs your input, so you can leave Cline working in the background. Configure them under Settings → Notifications.
|
||||
- Voice input: dictate into the composer with the microphone button and your speech is transcribed as you talk, using the provider and model you have configured.
|
||||
- Commands stream their output into the transcript as they run instead of appearing all at once when the command exits. Output keeps its terminal colors, is scrollable without being yanked back to the bottom, and a long-running command can be sent to the background with "Proceed while running" so the agent moves on while it finishes.
|
||||
- Models that support image generation can now produce images during a task, and they render inline in the transcript.
|
||||
- Finished agent runs collapse into a single "Worked for 4m 12s and made 14 tool calls" summary you can expand, so the final answer stays in view instead of being buried under the working rows.
|
||||
- Reasoning traces and tool rows now open and close with an animation instead of snapping, and respect your reduced-motion setting.
|
||||
- Redesigned the question card the agent shows when it needs a decision: options are selected explicitly and submitted with a button, multiple-choice questions are supported, and there are arrow-key and A–Z shortcuts. Internal request IDs, iteration counts, and timestamps no longer appear on the card.
|
||||
- The Web search toggle in Settings now explains that only providers with built-in web search honor it, and shows which of your connected providers are ready to use it — or warns you, with a link to Models, when none of them are.
|
||||
- Refreshed assistant markdown — chat-scaled headings, quieter code blocks with a hover copy button, and table cards — now rendered through the same pipeline as the rest of Cline, so the desktop app and the cloud dashboard finally look alike.
|
||||
- Message hover actions float over the transcript instead of reserving blank space under every message, so conversations pack more tightly.
|
||||
- Restyled session hover cards: they open immediately, drop the duplicated ID and updated time, and no longer animate as you move down the list.
|
||||
- There is now a separate "Cline Code Beta" app that installs side by side with this one and tracks the experimental branch. It identifies itself as beta in the sidebar, Settings, window title, and tray, so you always know which build you are in.
|
||||
- Fixed turns that settle through the event stream — queued prompts, and the first prompt of a fresh session — staying stuck on the streaming shimmer with no final output, healing only when you sent another message. The transcript now reconciles against the saved history as soon as the turn ends.
|
||||
- Fixed sessions being given the Yolo-mode system prompt whenever auto-approve was on, even though the runtime was started in Act mode. Auto-approval is now an independent tool policy and no longer changes the advertised mode.
|
||||
- `/skill` and `/workflow` commands no longer dump the whole skill body into the chat as your message. Your typed command stays as typed, the model loads the instructions through the skills tool, and sessions are no longer titled with the first line of a skill's markdown. Sessions saved before this fix render compactly too.
|
||||
- Fixed command execution breaking for an entire session when a model emitted a full command line with no separate arguments — anything containing a space failed with `ENOENT`.
|
||||
- Restoring a checkpoint now trims the saved transcript too, so the chat no longer keeps showing turns whose file changes were just reverted.
|
||||
- Gemini custom base URLs work again, including host-root values saved before the SDK migration and proxy roots like `http://localhost:4000/gemini`, which were silently missing the API version segment and 404ing.
|
||||
- LiteLLM input token limits reported by the server are preserved instead of being replaced with a 128K default.
|
||||
- Fixed misaligned columns in the Usage table, and added a See More link to the full usage dashboard.
|
||||
- Fixed routine dialog dropdowns not responding to mouse clicks.
|
||||
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
|
||||
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
|
||||
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
|
||||
- Onboarding now includes a GitHub integration step.
|
||||
- Early proof of concept for running sessions in SSH remote environments.
|
||||
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
|
||||
|
||||
## 0.0.13
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Desktop Experimental Branch & Beta Channel
|
||||
|
||||
How experimental desktop features are developed on the `desktop-experimental`
|
||||
branch, shipped to users as **Cline Beta**, and graduated into `main`.
|
||||
branch, shipped to users as **Cline Code Beta**, and graduated into `main`.
|
||||
The release mechanics (workflow internals, secrets) live in
|
||||
[`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml)
|
||||
and the `publish-desktop` skill
|
||||
@@ -12,8 +12,8 @@ this doc is the process.
|
||||
|
||||
The beta is a **separate app**, not a mode of the stable app:
|
||||
|
||||
- Product name `Cline Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline` / `bot.cline.app`) — set by
|
||||
- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta`
|
||||
(stable is `Cline Code` / `bot.cline.app`) — set by
|
||||
[`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which
|
||||
is layered over `tauri.release.conf.json` at build time.
|
||||
- Both apps install and run **side by side**, so people can compare beta
|
||||
|
||||
@@ -6,9 +6,8 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin
|
||||
|
||||
From `apps/examples/desktop-app/`:
|
||||
|
||||
- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential
|
||||
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
|
||||
- `bun run dev:web` - Next.js UI only (`http://localhost:3125`)
|
||||
- `bun run dev:sidecar` - sidecar backend only
|
||||
- `bun run dev` - Tauri desktop dev
|
||||
- `bun run build` - build web assets
|
||||
- `bun run build:sidecar` - build the Bun sidecar bundle
|
||||
@@ -31,6 +30,59 @@ API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
|
||||
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
|
||||
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
|
||||
|
||||
## SSH Remote Environments (v0)
|
||||
|
||||
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
|
||||
profile does not activate it. From the welcome chat, open the environment
|
||||
selector beside the workspace picker and choose the saved host; that selection
|
||||
starts the SSH connection at the remote user's home directory. Choose **Add
|
||||
project…** from the normal workspace selector to browse that machine and select
|
||||
a project, or choose **Local** in the environment selector to disconnect. Recent
|
||||
and last-used workspaces are remembered separately for each SSH host and for
|
||||
the local machine.
|
||||
|
||||
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
|
||||
configuration (including its configured port), or enter a port to override it.
|
||||
The desktop keeps its webview and native integration local; only the
|
||||
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
|
||||
workspace discovery, Git metadata, and session persistence therefore run on the
|
||||
SSH host, while approvals and live session events return to the desktop.
|
||||
|
||||
The desktop stores host metadata at
|
||||
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
|
||||
identity-file path, never private-key contents. On first connect it uploads a
|
||||
content-addressed, branch-matched, self-contained Hub helper under
|
||||
`~/.cline/code/remote/`, binds the Hub to remote loopback, and forwards it to a
|
||||
random local loopback port. Linux x64 and arm64 helpers are bundled by
|
||||
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
|
||||
supported in v0. The current helper is roughly 110 MB because it includes its
|
||||
own runtime. It is copied once per matching desktop build and cached, with no
|
||||
`apt`, `npm`, root access,
|
||||
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
|
||||
remote Hub but leaves the helper cached for a faster reconnect. The helper
|
||||
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
|
||||
MCP executables remain visible.
|
||||
|
||||
The desktop uses its own discovery record, so an existing Cline CLI/Hub on the
|
||||
same account is neither replaced nor stopped. Both Hub processes can coexist
|
||||
while the desktop is connected; this isolation is intentional for the proof of
|
||||
concept so a branch-matched desktop helper cannot disrupt another Cline build.
|
||||
|
||||
v0 intentionally leaves file attachments and opening a remote file in a local
|
||||
editor disabled. Text, images, file mentions/search, Git branch operations,
|
||||
session history, and remote agent tools are supported. The current desktop
|
||||
provider access/API token is sent through the authenticated tunnel for the
|
||||
session; reusable OAuth refresh credentials are not copied into remote provider
|
||||
settings.
|
||||
|
||||
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
|
||||
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
|
||||
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
|
||||
connection at the SSH user's home, starts an agent session in the test
|
||||
workspace with the selected desktop provider, asks the agent to read
|
||||
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
|
||||
that its messages can be read back.
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
@@ -56,7 +108,7 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
|
||||
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
|
||||
shipped apps can't verify new updates).
|
||||
|
||||
There is also a beta channel ("Cline Beta", a separate app that installs
|
||||
There is also a beta channel ("Cline Code Beta", a separate app that installs
|
||||
side by side with stable) cut from the `desktop-experimental` branch and
|
||||
served by the rolling `desktop-beta` release — the same never-delete rule
|
||||
applies to it. The experimental-branch process and beta release flow live in
|
||||
@@ -142,6 +194,16 @@ Desktop transport envelope:
|
||||
## Data + Storage
|
||||
|
||||
- Session artifacts are written under `~/.cline/data/sessions/<sessionId>/` (or `CLINE_SESSION_DATA_DIR`).
|
||||
- Desktop avatar packages live under `~/.cline/avatars/<avatar-name>/`. Each package
|
||||
contains a v2 `spritesheet.webp` (or PNG) and either an `avatar.json` or
|
||||
`pet.json` manifest with
|
||||
`id`, `displayName`, `description`, `spriteVersionNumber: 2`, and
|
||||
`spritesheetPath`. If both manifests exist, `avatar.json` takes precedence.
|
||||
The bundled Cline Bot avatar is selected and enabled by default, with Mom also
|
||||
available as a bundled option.
|
||||
Visibility and the selected installed avatar are configured independently under
|
||||
**Settings → General → Desktop avatar**; both values are stored in
|
||||
`~/.cline/avatars/selected.json`.
|
||||
- Canonical replay/export artifact: `<sessionId>.messages.json`.
|
||||
- `<sessionId>.messages.json` is expected to contain ordered messages plus assistant `modelInfo` and `metrics` (including cache token fields when provided by the model runtime).
|
||||
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.14-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"predev:headless": "bun run build:ui",
|
||||
"dev:headless": "bun run scripts/dev-headless.ts",
|
||||
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
@@ -28,15 +26,19 @@
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "4.0.31",
|
||||
"@ai-sdk/google": "4.0.44",
|
||||
"@ai-sdk/openai": "4.0.41",
|
||||
"@ai-sdk/react": "4.0.44",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
@@ -68,9 +70,8 @@
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"ai": "^7.0.58",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -80,7 +81,6 @@
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
@@ -92,7 +92,7 @@
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
|
||||
@@ -38,22 +38,54 @@ const sidecarOutfile = (targetTriple: string): string => {
|
||||
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
|
||||
};
|
||||
|
||||
const buildSidecar = async (targetTriple: string): Promise<string> => {
|
||||
const outfile = sidecarOutfile(targetTriple);
|
||||
const buildSidecar = async (
|
||||
targetTriple: string,
|
||||
outfile = sidecarOutfile(targetTriple),
|
||||
entrypoint = "./sidecar/index.ts",
|
||||
minify = false,
|
||||
): Promise<string> => {
|
||||
const bunTarget = resolveBunCompileTarget(targetTriple);
|
||||
// Telemetry config must be inlined into the compiled binary: a packaged
|
||||
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
|
||||
// without this the sidecar silently ships with telemetry disabled.
|
||||
// Verify with `<binary> --telemetry-selfcheck` after building.
|
||||
const defines = telemetryDefineArgs();
|
||||
const optimizationArgs = minify ? ["--minify"] : [];
|
||||
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
|
||||
// launch directory before our entrypoint runs. Remote helpers are launched
|
||||
// from an SSH user's home directory, so that behavior can both make the
|
||||
// helper fail on an unrelated dotenv file and leak workspace credentials
|
||||
// into the Hub process. Packaged binaries must depend only on their explicit
|
||||
// process environment and compiled configuration.
|
||||
const runtimeIsolationArgs = [
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
];
|
||||
if (bunTarget) {
|
||||
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
|
||||
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
} else {
|
||||
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
|
||||
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
|
||||
}
|
||||
return outfile;
|
||||
};
|
||||
|
||||
// SSH environments run the same Hub build as the desktop in a dedicated
|
||||
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
|
||||
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
|
||||
const buildRemoteHelpers = async (): Promise<void> => {
|
||||
for (const targetTriple of [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
]) {
|
||||
await buildSidecar(
|
||||
targetTriple,
|
||||
`./src-tauri/bin/remote-helpers/code-sidecar-${targetTriple}`,
|
||||
"./sidecar/remote-helper.ts",
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
|
||||
// but expects sidecars (externalBin) to already be fat binaries named
|
||||
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
|
||||
@@ -68,12 +100,13 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
|
||||
|
||||
const main = async () => {
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
await $`mkdir -p src-tauri/bin`;
|
||||
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
|
||||
if (targetTriple === "universal-apple-darwin") {
|
||||
await buildUniversalMacSidecar();
|
||||
return;
|
||||
} else {
|
||||
await buildSidecar(targetTriple);
|
||||
}
|
||||
await buildSidecar(targetTriple);
|
||||
await buildRemoteHelpers();
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const approvalToken = randomUUID();
|
||||
const children: ReturnType<typeof Bun.spawn>[] = [];
|
||||
|
||||
async function reserveAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to reserve a sidecar port"));
|
||||
return;
|
||||
}
|
||||
server.close((error) =>
|
||||
error ? reject(error) : resolve(address.port),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function spawn(command: string[], env: Record<string, string>) {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd: import.meta.dir + "/..",
|
||||
env: { ...process.env, ...env },
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function stopChildren(): void {
|
||||
for (const child of children) {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", stopChildren);
|
||||
process.on("SIGTERM", stopChildren);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sidecarPort = await reserveAvailablePort();
|
||||
const endpoint = `ws://127.0.0.1:${sidecarPort}/transport?approval_token=${approvalToken}`;
|
||||
const sidecar = spawn(["bun", "run", "sidecar/index.ts"], {
|
||||
CLINE_SIDECAR_APPROVAL_TOKEN: approvalToken,
|
||||
CLINE_SIDECAR_PORT: String(sidecarPort),
|
||||
});
|
||||
const web = spawn(
|
||||
["bun", "run", "next", "dev", "webview", "-p", "3125", "--turbo"],
|
||||
{ NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: endpoint },
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([sidecar.exited, web.exited]);
|
||||
stopChildren();
|
||||
await Promise.allSettled(children.map((child) => child.exited));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -121,7 +121,7 @@ const main = () => {
|
||||
|
||||
const notes = notesFile
|
||||
? readFileSync(notesFile, "utf8").trim()
|
||||
: `Cline v${version}`;
|
||||
: `Cline Code v${version}`;
|
||||
|
||||
const manifest = buildUpdateManifest({
|
||||
version,
|
||||
|
||||
@@ -15,7 +15,7 @@ const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
|
||||
const VALUE_FLAGS = new Set(["--platform", "--target"]);
|
||||
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
||||
|
||||
const APP_NAME = "Cline";
|
||||
const APP_NAME = "Cline Code";
|
||||
const APP_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BUNDLE_ROOT = path.join(
|
||||
APP_ROOT,
|
||||
|
||||
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
|
||||
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
|
||||
});
|
||||
|
||||
it("inlines the cloud-agents toggle only when set at build time", () => {
|
||||
const withoutToggle = defineMap(telemetryDefineArgs({}));
|
||||
expect(withoutToggle).not.toHaveProperty(
|
||||
"process.env.CLINE_CODE_CLOUD_AGENTS",
|
||||
);
|
||||
|
||||
const withToggle = defineMap(
|
||||
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
|
||||
);
|
||||
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
|
||||
});
|
||||
|
||||
it("JSON-escapes values so headers with quotes survive the define", () => {
|
||||
const defines = defineMap(
|
||||
telemetryDefineArgs({
|
||||
|
||||
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
] as const;
|
||||
|
||||
/** Optional build-time overrides for packaged dogfood builds. */
|
||||
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
|
||||
|
||||
/**
|
||||
* Every env var `getTelemetryBuildTimeConfig` reads
|
||||
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
|
||||
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
|
||||
const define = (name: string, value: string) => {
|
||||
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
|
||||
};
|
||||
for (const name of OPTIONAL_SECRET_ENV_VARS) {
|
||||
for (const name of [
|
||||
...OPTIONAL_SECRET_ENV_VARS,
|
||||
...OPTIONAL_FEATURE_ENV_VARS,
|
||||
]) {
|
||||
const value = env[name];
|
||||
if (value) {
|
||||
define(name, value);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ClineCore,
|
||||
ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
SessionSource,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import { RemoteEnvironmentService } from "../sidecar/remote-environments";
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-"));
|
||||
const service = new RemoteEnvironmentService({
|
||||
profilesPath: join(temporaryDirectory, "remote-environments.json"),
|
||||
helperBinaryPath: required("CLINE_SSH_TEST_HELPER"),
|
||||
knownHostsPath: join(temporaryDirectory, "known_hosts"),
|
||||
commandTimeoutMs: 60_000,
|
||||
uploadTimeoutMs: 5 * 60_000,
|
||||
});
|
||||
let core: ClineCore | undefined;
|
||||
|
||||
try {
|
||||
const helperPath = required("CLINE_SSH_TEST_HELPER");
|
||||
const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE");
|
||||
const profile = await service.upsert({
|
||||
name: "SSH proof host",
|
||||
host: required("CLINE_SSH_TEST_HOST"),
|
||||
user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined,
|
||||
identityFile: required("CLINE_SSH_TEST_KEY"),
|
||||
});
|
||||
const connection = await service.connect(profile.id);
|
||||
const marker = await service.run(profile.id, {
|
||||
command: "sed",
|
||||
args: ["-n", "1p", "REMOTE_MARKER.txt"],
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
|
||||
const providerSettings = new ProviderSettingsManager();
|
||||
const stored = providerSettings.read();
|
||||
const providerId = stored.lastUsedProvider;
|
||||
if (!providerId)
|
||||
throw new Error("No configured desktop provider is available");
|
||||
const settings = providerSettings.getProviderSettings(providerId);
|
||||
if (!settings)
|
||||
throw new Error(`No settings found for provider ${providerId}`);
|
||||
const modelId = settings.model || "meta/muse-spark-1.2";
|
||||
const providerConfig = {
|
||||
...toProviderConfig(
|
||||
{ ...settings, model: modelId },
|
||||
{ includeKnownModels: false },
|
||||
),
|
||||
};
|
||||
delete providerConfig.refreshToken;
|
||||
const oauth = await new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager: providerSettings,
|
||||
}).resolveProviderApiKey({ providerId });
|
||||
const apiKey =
|
||||
oauth?.apiKey ||
|
||||
resolveProviderApiKeyFromSettings(providerSettings, providerId);
|
||||
if (!apiKey)
|
||||
throw new Error(`No credential found for provider ${providerId}`);
|
||||
|
||||
core = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
remote: {
|
||||
endpoint: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
},
|
||||
});
|
||||
const eventNames: string[] = [];
|
||||
const unsubscribe = core.subscribe((event) => {
|
||||
eventNames.push(event.type);
|
||||
});
|
||||
const started = await core.start({
|
||||
config: {
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
providerConfig,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
systemPrompt: "",
|
||||
mode: "act",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
},
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
});
|
||||
const result = await core.send({
|
||||
sessionId: started.sessionId,
|
||||
prompt:
|
||||
"Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.",
|
||||
});
|
||||
const sessions = await core.list(20, { hydrate: false });
|
||||
const messages = await core.readMessages(started.sessionId);
|
||||
unsubscribe();
|
||||
await core.dispose("desktop_ssh_proof_reconnect");
|
||||
core = undefined;
|
||||
await service.disconnect(profile.id);
|
||||
|
||||
const reconnected = await service.connect(profile.id);
|
||||
core = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
remote: {
|
||||
endpoint: reconnected.endpoint,
|
||||
authToken: reconnected.authToken,
|
||||
workspaceRoot: reconnected.workspaceRoot,
|
||||
cwd: reconnected.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
},
|
||||
});
|
||||
const sessionsAfterReconnect = await core.list(20, { hydrate: false });
|
||||
const messagesAfterReconnect = await core.readMessages(started.sessionId);
|
||||
|
||||
const resultText = result?.text ?? "";
|
||||
const report = {
|
||||
connected: true,
|
||||
remote: `${connection.platform}/${connection.arch}`,
|
||||
connectionRoot: connection.workspaceRoot,
|
||||
workspaceRoot: started.manifest.workspace_root,
|
||||
sessionId: started.sessionId,
|
||||
listContainsSession: sessions.some(
|
||||
(session) => session.sessionId === started.sessionId,
|
||||
),
|
||||
messageCount: messages.length,
|
||||
reconnected: true,
|
||||
reconnectListContainsSession: sessionsAfterReconnect.some(
|
||||
(session) => session.sessionId === started.sessionId,
|
||||
),
|
||||
reconnectMessageCount: messagesAfterReconnect.length,
|
||||
helperBytes: (await stat(helperPath)).size,
|
||||
sshMarker: marker.stdout.trim(),
|
||||
agentText: resultText,
|
||||
agentObservedMarker: resultText.includes("remote workspace proof"),
|
||||
eventNames: [...new Set(eventNames)],
|
||||
};
|
||||
if (
|
||||
report.sshMarker !== "remote workspace proof" ||
|
||||
!report.agentObservedMarker ||
|
||||
!report.listContainsSession ||
|
||||
!report.reconnectListContainsSession ||
|
||||
report.messageCount < 2 ||
|
||||
report.reconnectMessageCount < 2 ||
|
||||
!report.eventNames.includes("agent_event")
|
||||
) {
|
||||
throw new Error(`SSH proof failed: ${JSON.stringify(report)}`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
} finally {
|
||||
await core?.dispose("desktop_ssh_proof_complete");
|
||||
await service.dispose();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -17,7 +17,11 @@ sidecar/
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
|
||||
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
|
||||
├── cline-auth.ts # Refresh-aware Cline auth token resolution
|
||||
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
|
||||
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
├── types.ts # Shared types
|
||||
@@ -49,7 +53,7 @@ const sessionManager = await ClineCore.create({
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
@@ -89,7 +93,7 @@ online:
|
||||
|
||||
```typescript
|
||||
const pendingApprovals = new Map<string, {
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
resolve: (result: ToolApprovalResult) => void | Promise<void>;
|
||||
request: ToolApprovalRequest;
|
||||
}>();
|
||||
|
||||
@@ -97,6 +101,10 @@ const pendingApprovals = new Map<string, {
|
||||
// When frontend responds → resolve promise
|
||||
```
|
||||
|
||||
Cloud sessions route approvals the same way, but the resolver forwards the
|
||||
response to the sandbox Hub (`approval.respond`), which is why `resolve` may
|
||||
be async.
|
||||
|
||||
### 3. Provider Management — Direct ProviderSettingsManager
|
||||
|
||||
```typescript
|
||||
@@ -138,7 +146,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | shared Hub through `ClineCore` |
|
||||
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions route to `CloudSessionManager` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
|
||||
@@ -147,26 +155,36 @@ Supported commands:
|
||||
| `save_provider_settings` | `saveLocalProviderSettings` |
|
||||
| `add_provider` | `addLocalProvider` |
|
||||
| `run_provider_oauth_login` | `loginLocalProvider` |
|
||||
| `list_chat_sessions` | `SqliteSessionStore` + file discovery |
|
||||
| `list_discovered_sessions` | Merged discovery |
|
||||
| `read_session_messages` | Session data readers |
|
||||
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions (2s budget) |
|
||||
| `list_discovered_sessions` | Merged discovery (local + cloud) |
|
||||
| `read_session_messages` | Session data readers; cloud sessions read through the sandbox Hub |
|
||||
| `read_session_hooks` | Session data readers |
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
|
||||
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
|
||||
| `get_desktop_settings` | `readDesktopSettings()` |
|
||||
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
|
||||
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
|
||||
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
|
||||
| `list_mcp_servers` | Direct file I/O |
|
||||
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
|
||||
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
|
||||
| `upsert_mcp_server` | Direct file I/O |
|
||||
| `delete_mcp_server` | Direct file I/O |
|
||||
| `get_git_branch` | async `execFile("git", ...)` |
|
||||
|
||||
Realtime mode sessions expose only one browser-callable tool, `run_cline`, when
|
||||
the selected realtime model supports tool calling. The webview implements that
|
||||
tool by sending the request through the active Cline chat session and returning
|
||||
its persisted result to the realtime provider for playback. Cline remains the
|
||||
owner of workspace context, agent tools, MCP, approvals, and session history;
|
||||
provider credentials remain in the sidecar.
|
||||
| `list_git_branches` | async `execFile("git", ...)` |
|
||||
| `checkout_git_branch` | async `execFile("git", ...)` |
|
||||
| `search_workspace_files` | `getFileIndex` |
|
||||
| `get_process_context` | In-memory context |
|
||||
| `poll_tool_approvals` | In-memory pending map |
|
||||
| `respond_tool_approval` | In-memory promise resolution |
|
||||
| `poll_ask_questions` | In-memory pending map |
|
||||
| `respond_ask_question` | In-memory promise resolution |
|
||||
| `list_routine_schedules` | shared Hub schedule commands |
|
||||
| `list_user_instruction_configs` | Direct core API |
|
||||
| `pick_workspace_directory` | OS native dialog |
|
||||
@@ -175,8 +193,7 @@ Supported commands:
|
||||
## Dev Workflow
|
||||
|
||||
```bash
|
||||
bun run dev:headless # Start sidecar and Next.js with a fresh shared approval credential
|
||||
bun run dev:sidecar # Start only the sidecar (no browser approval surface)
|
||||
bun run dev:web # Start only Next.js (no authenticated approval connection)
|
||||
bun run dev:sidecar # Start sidecar on port 3126
|
||||
bun run dev:web # Start Next.js on port 3125
|
||||
bun run dev # Both concurrently
|
||||
```
|
||||
|
||||
@@ -13,31 +13,53 @@ import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
copySessionGeneratedArtifacts,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
prewarmWorkspaceMetadata,
|
||||
resolveDesktopSessionMode,
|
||||
rewriteDesktopTeamPrompt,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import { handleCoreSessionEvent } from "./context";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("resolveDesktopSessionMode", () => {
|
||||
it("does not turn auto-approved Act sessions into Yolo sessions", () => {
|
||||
expect(
|
||||
resolveDesktopSessionMode({ mode: "act", autoApproveTools: true }),
|
||||
).toBe("act");
|
||||
expect(resolveDesktopSessionMode({ autoApproveTools: true })).toBe("act");
|
||||
});
|
||||
function localRuntimeContext(
|
||||
sessionManager: Record<string, unknown>,
|
||||
options: { sessionIds?: string[]; workspaceRoot?: string } = {},
|
||||
) {
|
||||
const workspaceRoot = options.workspaceRoot ?? "/workspace";
|
||||
return {
|
||||
runtimeBindings: new Map([
|
||||
[
|
||||
"local",
|
||||
{
|
||||
environmentId: "local",
|
||||
kind: "local" as const,
|
||||
workspaceRoot,
|
||||
sessionManager,
|
||||
hubClient: {
|
||||
command: vi.fn(async () => undefined),
|
||||
},
|
||||
unsubscribeSessionEvents: () => {},
|
||||
},
|
||||
],
|
||||
]),
|
||||
sessionEnvironmentIds: new Map(
|
||||
(options.sessionIds ?? []).map((sessionId) => [sessionId, "local"]),
|
||||
),
|
||||
activeEnvironmentId: "local",
|
||||
remoteEnvironments: null,
|
||||
localWorkspaceRoot: workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
it("preserves explicit Plan and Yolo modes", () => {
|
||||
expect(resolveDesktopSessionMode({ mode: "plan" })).toBe("plan");
|
||||
expect(resolveDesktopSessionMode({ mode: "yolo" })).toBe("yolo");
|
||||
});
|
||||
});
|
||||
function localSessionManager(ctx: SidecarContext): Record<string, unknown> {
|
||||
return ctx.runtimeBindings.get("local")?.sessionManager as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
describe("rewriteDesktopTeamPrompt", () => {
|
||||
it("rewrites /team for the core runtime", () => {
|
||||
@@ -213,7 +235,7 @@ describe("pathless session starts", () => {
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
...localRuntimeContext({ start }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
@@ -237,15 +259,139 @@ describe("pathless session starts", () => {
|
||||
sessionId: "session-pathless",
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
environmentId: "local",
|
||||
});
|
||||
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks sessions initiated by realtime voice with the realtime source", async () => {
|
||||
const start = vi.fn(async () => ({
|
||||
sessionId: "session-realtime",
|
||||
manifest: {
|
||||
cwd: "/workspace/project",
|
||||
workspace_root: "/workspace/project",
|
||||
},
|
||||
manifestPath: "/tmp/session-realtime.json",
|
||||
messagesPath: "/tmp/session-realtime.messages.json",
|
||||
}));
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
...localRuntimeContext({ start }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
source: "realtime",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "realtime" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment-bound session attach", () => {
|
||||
it("does not fall through to another host when the requested environment lacks the session", async () => {
|
||||
const sessionId = "same-session-id";
|
||||
const localGet = vi.fn(async () => ({
|
||||
sessionId,
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/local/project",
|
||||
workspaceRoot: "/local/project",
|
||||
}));
|
||||
const remoteGet = vi.fn(async () => undefined);
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
sessionEnvironmentIds: new Map([[sessionId, "local"]]),
|
||||
activeEnvironmentId: "local",
|
||||
runtimeBindings: new Map([
|
||||
[
|
||||
"local",
|
||||
{
|
||||
environmentId: "local",
|
||||
kind: "local",
|
||||
workspaceRoot: "/local/project",
|
||||
sessionManager: { get: localGet },
|
||||
hubClient: { command: vi.fn() },
|
||||
unsubscribeSessionEvents: () => {},
|
||||
},
|
||||
],
|
||||
[
|
||||
"pi-host",
|
||||
{
|
||||
environmentId: "pi-host",
|
||||
kind: "ssh",
|
||||
workspaceRoot: "/home/pi",
|
||||
sessionManager: { get: remoteGet },
|
||||
hubClient: { command: vi.fn() },
|
||||
unsubscribeSessionEvents: () => {},
|
||||
},
|
||||
],
|
||||
]),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "attach",
|
||||
sessionId,
|
||||
config: { environmentId: "pi-host" },
|
||||
}),
|
||||
).rejects.toThrow(`Session ${sessionId} not found`);
|
||||
expect(remoteGet).toHaveBeenCalledWith(sessionId);
|
||||
expect(localGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session forks", () => {
|
||||
it("copies generated artifacts into the forked session", () => {
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "desktop-fork-artifacts-"));
|
||||
const sourceSessionId = "source-session";
|
||||
const targetSessionId = "forked-session";
|
||||
const sourceArtifactsDir = join(sessionsDir, sourceSessionId, "artifacts");
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = sessionsDir;
|
||||
mkdirSync(sourceArtifactsDir, { recursive: true });
|
||||
writeFileSync(join(sourceArtifactsDir, "generated.mp4"), "video");
|
||||
writeFileSync(join(sourceArtifactsDir, "generated.mp3"), "audio");
|
||||
|
||||
copySessionGeneratedArtifacts(sourceSessionId, targetSessionId);
|
||||
|
||||
expect(
|
||||
readFileSync(
|
||||
join(sessionsDir, targetSessionId, "artifacts", "generated.mp4"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("video");
|
||||
expect(
|
||||
readFileSync(
|
||||
join(sessionsDir, targetSessionId, "artifacts", "generated.mp3"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("audio");
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(sessionsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the selected workspace checkpoint before forking for message editing", async () => {
|
||||
const sourceSessionId = `source-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
@@ -284,29 +430,32 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
checkpoint: {
|
||||
latest: { ref: "second", createdAt: 2, runCount: 2 },
|
||||
history: [
|
||||
{ ref: "first", createdAt: 1, runCount: 1 },
|
||||
{ ref: "second", createdAt: 2, runCount: 2 },
|
||||
],
|
||||
...localRuntimeContext(
|
||||
{
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
checkpoint: {
|
||||
latest: { ref: "second", createdAt: 2, runCount: 2 },
|
||||
history: [
|
||||
{ ref: "first", createdAt: 1, runCount: 1 },
|
||||
{ ref: "second", createdAt: 2, runCount: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
{ sessionIds: [sourceSessionId] },
|
||||
),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
@@ -408,26 +557,29 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
checkpoint: {
|
||||
latest: { ref: "first", createdAt: 1, runCount: 1 },
|
||||
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
|
||||
...localRuntimeContext(
|
||||
{
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
metadata: {
|
||||
checkpoint: {
|
||||
latest: { ref: "first", createdAt: 1, runCount: 1 },
|
||||
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
readMessages: vi.fn(async () => sourceMessages),
|
||||
restore,
|
||||
send,
|
||||
},
|
||||
})),
|
||||
readMessages: vi.fn(async () => sourceMessages),
|
||||
restore,
|
||||
send,
|
||||
},
|
||||
{ sessionIds: [sourceSessionId, siblingSessionId] },
|
||||
),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
@@ -489,20 +641,23 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
...localRuntimeContext(
|
||||
{
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
source: "desktop",
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
})),
|
||||
readMessages,
|
||||
restore,
|
||||
start,
|
||||
},
|
||||
{ sessionIds: [sourceSessionId] },
|
||||
),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
} as unknown as SidecarContext;
|
||||
@@ -540,7 +695,7 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { restore },
|
||||
...localRuntimeContext({ restore }, { sessionIds: [sourceSessionId] }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
@@ -571,13 +726,16 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
status: "running",
|
||||
})),
|
||||
restore,
|
||||
},
|
||||
...localRuntimeContext(
|
||||
{
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
status: "running",
|
||||
})),
|
||||
restore,
|
||||
},
|
||||
{ sessionIds: [sourceSessionId] },
|
||||
),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
@@ -621,15 +779,18 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: {
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
status: "completed",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
})),
|
||||
restore,
|
||||
},
|
||||
...localRuntimeContext(
|
||||
{
|
||||
get: vi.fn(async () => ({
|
||||
sessionId: sourceSessionId,
|
||||
status: "completed",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
})),
|
||||
restore,
|
||||
},
|
||||
{ sessionIds: [sourceSessionId, siblingSessionId] },
|
||||
),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
@@ -643,81 +804,6 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("allows a workspace restore after a queued turn completes through the event stream", async () => {
|
||||
const sessionId = `queued-turn-session-${Date.now()}`;
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-queued-restore-"));
|
||||
const originalDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
process.env.CLINE_SESSION_DATA_DIR = dataDir;
|
||||
try {
|
||||
const restore = vi.fn(async () => ({
|
||||
sessionId,
|
||||
messages: [{ role: "user", content: "first prompt" }],
|
||||
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
|
||||
}));
|
||||
const ctx = {
|
||||
liveSessions: new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: { cwd: "/workspace/project" },
|
||||
messages: [{ role: "user", content: "first prompt" }],
|
||||
promptsInQueue: [],
|
||||
// A drained queued turn is running: no send() RPC owns
|
||||
// this turn's busy flag, only the event stream does.
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: { restore },
|
||||
} as unknown as SidecarContext;
|
||||
const restoreRequest = {
|
||||
action: "restore_checkpoint" as const,
|
||||
sessionId,
|
||||
checkpointRunCount: 1,
|
||||
config: {
|
||||
cwd: "/workspace/project",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
},
|
||||
};
|
||||
|
||||
// While the queued turn is still running the workspace stays locked.
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, restoreRequest),
|
||||
).rejects.toThrow("Wait for all turns in this workspace to finish");
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
|
||||
// The queued turn settles through the event stream: the runtime
|
||||
// host reports the session back at idle (there is no send() RPC
|
||||
// response to clear the busy flag for event-settled turns).
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "status",
|
||||
payload: { sessionId, status: "idle" },
|
||||
});
|
||||
expect(ctx.liveSessions.get(sessionId)).toMatchObject({
|
||||
busy: false,
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, restoreRequest),
|
||||
).resolves.toMatchObject({ sessionId });
|
||||
expect(restore).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = originalDataDir;
|
||||
}
|
||||
rmSync(dataDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks sends from sibling sessions while their workspace is restored", async () => {
|
||||
const send = vi.fn();
|
||||
const sessionId = "workspace-sibling-session";
|
||||
@@ -736,7 +822,7 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(["/workspace/project"]),
|
||||
sessionManager: { send },
|
||||
...localRuntimeContext({ send }, { sessionIds: [sessionId] }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
@@ -796,17 +882,20 @@ describe("first-send connection updates", () => {
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: {
|
||||
readMessages,
|
||||
readSessionCompactionState,
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
pendingPrompts: {
|
||||
list: vi.fn(async () => []),
|
||||
...localRuntimeContext(
|
||||
{
|
||||
readMessages,
|
||||
readSessionCompactionState,
|
||||
send,
|
||||
start,
|
||||
stop,
|
||||
updateSessionConnection,
|
||||
pendingPrompts: {
|
||||
list: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ sessionIds: [sessionId] },
|
||||
),
|
||||
} as unknown as SidecarContext;
|
||||
return {
|
||||
ctx,
|
||||
@@ -930,7 +1019,7 @@ describe("first-send connection updates", () => {
|
||||
attachmentCount: number;
|
||||
userFiles?: string[];
|
||||
}> = [];
|
||||
const manager = ctx.sessionManager as unknown as {
|
||||
const manager = localSessionManager(ctx) as unknown as {
|
||||
send: typeof send;
|
||||
pendingPrompts: {
|
||||
list: (input: unknown) => Promise<unknown[]>;
|
||||
@@ -1062,15 +1151,13 @@ describe("first-send connection updates", () => {
|
||||
if (!session) throw new Error("missing session");
|
||||
const queuedMap = new Map([["pending_1", [queuedFile]]]);
|
||||
session.queuedAttachmentFiles = queuedMap;
|
||||
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
|
||||
async () => ({
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
);
|
||||
(localSessionManager(ctx) as { get?: unknown }).get = vi.fn(async () => ({
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
}));
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "attach",
|
||||
@@ -1383,7 +1470,97 @@ describe("first-send connection updates", () => {
|
||||
});
|
||||
|
||||
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.liveSessions.get(sessionId)?.attachedViaHub).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system prompt mode resolution", () => {
|
||||
function createStartContext() {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => ({
|
||||
sessionId: "session-mode-test",
|
||||
manifest: {
|
||||
cwd: String(input.config.cwd ?? ""),
|
||||
workspace_root: String(input.config.workspaceRoot ?? ""),
|
||||
},
|
||||
manifestPath: "/tmp/session-mode-test.json",
|
||||
messagesPath: "/tmp/session-mode-test.messages.json",
|
||||
}));
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
...localRuntimeContext({ start }),
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, start };
|
||||
}
|
||||
|
||||
async function startAndCaptureSystemPrompt(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const { ctx, start } = createStartContext();
|
||||
const cwd = String(config.cwd ?? "");
|
||||
// Seed the metadata cache so resolveSystemPrompt does not scan a real
|
||||
// workspace during the test.
|
||||
prewarmWorkspaceMetadata(cwd, async () => "test metadata");
|
||||
await handleChatSessionCommand(ctx, { action: "start", config });
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
const input = start.mock.calls[0][0] as {
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
return String(input.config.systemPrompt ?? "");
|
||||
}
|
||||
|
||||
it("keeps the interactive act persona when autoApproveTools is enabled", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-act-auto-approve",
|
||||
mode: "act",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).not.toContain("submit_and_exit");
|
||||
expect(systemPrompt).not.toContain(
|
||||
"user who you cannot communicate with directly",
|
||||
);
|
||||
expect(systemPrompt).toContain("assist users with various coding tasks");
|
||||
});
|
||||
|
||||
it("defaults to act mode when mode is omitted", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-default",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).not.toContain("submit_and_exit");
|
||||
expect(systemPrompt).toContain("assist users with various coding tasks");
|
||||
});
|
||||
|
||||
it("appends plan-mode instructions when mode is plan", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-plan",
|
||||
mode: "plan",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).not.toContain("submit_and_exit");
|
||||
expect(systemPrompt).toContain("You are in Plan mode");
|
||||
});
|
||||
|
||||
it("only uses the yolo persona when mode is explicitly yolo", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-yolo",
|
||||
mode: "yolo",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).toContain("submit_and_exit");
|
||||
expect(systemPrompt).toContain(
|
||||
"user who you cannot communicate with directly",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1477,15 +1654,6 @@ describe("runtime slash command expansion on send", () => {
|
||||
name: desktop-send-skill
|
||||
---
|
||||
Follow the desktop send skill instructions.`,
|
||||
);
|
||||
const workflowsDir = join(workspace, ".cline", "workflows");
|
||||
mkdirSync(workflowsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(workflowsDir, "desktop-send-workflow.md"),
|
||||
`---
|
||||
name: desktop-send-workflow
|
||||
---
|
||||
Follow the desktop send workflow instructions.`,
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
@@ -1514,23 +1682,25 @@ Follow the desktop send workflow instructions.`,
|
||||
}),
|
||||
);
|
||||
const ctx = {
|
||||
workspaceRoot: workspace,
|
||||
liveSessions: new Map([[sessionId, session]]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
sessionManager: {
|
||||
send,
|
||||
pendingPrompts: {
|
||||
list: vi.fn(async () => []),
|
||||
update: updatePendingPrompt,
|
||||
...localRuntimeContext(
|
||||
{
|
||||
send,
|
||||
pendingPrompts: {
|
||||
list: vi.fn(async () => []),
|
||||
update: updatePendingPrompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ sessionIds: [sessionId], workspaceRoot: workspace },
|
||||
),
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, send, session, sessionId, updatePendingPrompt };
|
||||
}
|
||||
|
||||
it("sends a skill command through as typed for the skills tool", async () => {
|
||||
it("expands a leading skill command into its instructions", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, send, session, sessionId } = createContext(workspace);
|
||||
|
||||
@@ -1540,59 +1710,16 @@ Follow the desktop send workflow instructions.`,
|
||||
prompt: "/desktop-send-skill write the docs",
|
||||
});
|
||||
|
||||
// Skills are not expanded into the user message: the runtime's skills
|
||||
// tool loads the instructions, and the persisted transcript keeps the
|
||||
// typed command.
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "/desktop-send-skill write the docs",
|
||||
}),
|
||||
);
|
||||
expect(session.prompt).toBe("/desktop-send-skill write the docs");
|
||||
});
|
||||
|
||||
it("expands a skill command in yolo mode, where the skills tool is unavailable", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, send, session, sessionId } = createContext(workspace);
|
||||
(session.config as Record<string, unknown>).mode = "yolo";
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "/desktop-send-skill write the docs",
|
||||
});
|
||||
|
||||
// The yolo preset has no skills tool, so textual expansion is the only
|
||||
// way the instructions reach the model.
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Follow the desktop send skill instructions. write the docs",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("expands a leading workflow command into its instructions", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, send, session, sessionId } = createContext(workspace);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "/desktop-send-workflow ship it",
|
||||
});
|
||||
|
||||
// Workflows are not served by the skills tool, so they keep textual
|
||||
// expansion.
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Follow the desktop send workflow instructions. ship it",
|
||||
}),
|
||||
);
|
||||
// The session's display prompt keeps the raw token.
|
||||
expect(session.prompt).toBe("/desktop-send-workflow ship it");
|
||||
expect(session.prompt).toBe("/desktop-send-skill write the docs");
|
||||
});
|
||||
|
||||
it("keeps a skill command as typed when a queued prompt is edited", async () => {
|
||||
it("expands a skill command when a queued prompt is edited", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
|
||||
|
||||
@@ -1606,25 +1733,7 @@ Follow the desktop send workflow instructions.`,
|
||||
expect(updatePendingPrompt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
promptId: "queued-1",
|
||||
prompt: "/desktop-send-skill later please",
|
||||
});
|
||||
});
|
||||
|
||||
it("expands a workflow command when a queued prompt is edited", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "update_pending_prompt",
|
||||
sessionId,
|
||||
promptId: "queued-2",
|
||||
prompt: "/desktop-send-workflow later please",
|
||||
});
|
||||
|
||||
expect(updatePendingPrompt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
promptId: "queued-2",
|
||||
prompt: "Follow the desktop send workflow instructions. later please",
|
||||
prompt: "Follow the desktop send skill instructions. later please",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
@@ -9,14 +15,17 @@ import {
|
||||
createSessionCompactionState,
|
||||
createUserInstructionConfigService,
|
||||
getCoreBuiltinToolCatalog,
|
||||
isSkillsToolAvailable,
|
||||
ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readGlobalSettings,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
RuntimeOAuthTokenManager,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
toProviderConfig,
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
@@ -27,7 +36,15 @@ import {
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { getCloudSessionManager } from "./cloud-sessions";
|
||||
import {
|
||||
emitChunk,
|
||||
findSessionRuntimeBinding,
|
||||
getSessionRuntimeBinding,
|
||||
nowMs,
|
||||
sendEvent,
|
||||
} from "./context";
|
||||
import { isCloudAgentsEnabled } from "./feature-flags";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
import type {
|
||||
@@ -35,6 +52,7 @@ import type {
|
||||
JsonRecord,
|
||||
LiveSession,
|
||||
PromptInQueue,
|
||||
SessionRuntimeBinding,
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
@@ -102,21 +120,15 @@ const BUILTIN_SLASH_COMMAND_NAMES = new Set(["fork", "team"]);
|
||||
|
||||
/**
|
||||
* Expand a leading `/skill` or `/workflow` token into its configured
|
||||
* instructions before dispatching the prompt. Skill commands pass through as
|
||||
* typed whenever the session registers the runtime's `skills` tool (its
|
||||
* description requires the model to invoke it on slash-command references),
|
||||
* so the instructions reach the model as a tool result instead of being
|
||||
* pasted into the user message — where the transcript would render them as
|
||||
* if the user had typed the whole skill body. Workflows, and skills in
|
||||
* configurations without the tool (e.g. yolo mode), keep textual expansion.
|
||||
* Returns the prompt unchanged when it is not a slash command, the token is
|
||||
* a built-in, no command matches, or command discovery fails.
|
||||
* instructions before dispatching the prompt, mirroring the CLI's
|
||||
* `buildUserInputMessage`. Returns the prompt unchanged when it is not a
|
||||
* slash command, the token is a built-in, no command matches, or command
|
||||
* discovery fails.
|
||||
*/
|
||||
async function expandRuntimeSlashCommand(
|
||||
ctx: SidecarContext,
|
||||
workspacePath: string | undefined,
|
||||
prompt: string,
|
||||
mode?: unknown,
|
||||
): Promise<string> {
|
||||
if (!prompt.startsWith("/") || prompt.length < 2) {
|
||||
return prompt;
|
||||
@@ -132,12 +144,7 @@ async function expandRuntimeSlashCommand(
|
||||
});
|
||||
try {
|
||||
await service.start();
|
||||
return service.resolveRuntimeSlashCommand(prompt, {
|
||||
expandSkillCommands: !isSkillsToolAvailable({
|
||||
mode: mode === "plan" || mode === "yolo" ? mode : "act",
|
||||
disabledToolIds: new Set(readGlobalSettings().disabledTools ?? []),
|
||||
}),
|
||||
});
|
||||
return service.resolveRuntimeSlashCommand(prompt);
|
||||
} catch (error) {
|
||||
ctx.logger?.debug("Slash command expansion failed, sending raw prompt", {
|
||||
error,
|
||||
@@ -200,7 +207,7 @@ async function resolveDesktopRuntimePrompt(
|
||||
mode?: unknown,
|
||||
): Promise<string> {
|
||||
return rewriteDesktopTeamPrompt(
|
||||
await expandRuntimeSlashCommand(ctx, workspacePath, prompt, mode),
|
||||
await expandRuntimeSlashCommand(ctx, workspacePath, prompt),
|
||||
{ mode },
|
||||
);
|
||||
}
|
||||
@@ -315,6 +322,22 @@ function readPersistedChatMessages(
|
||||
}
|
||||
}
|
||||
|
||||
export function copySessionGeneratedArtifacts(
|
||||
sourceSessionId: string,
|
||||
targetSessionId: string,
|
||||
): void {
|
||||
if (sourceSessionId === targetSessionId) return;
|
||||
const sourceDir = join(sharedSessionDataDir(), sourceSessionId, "artifacts");
|
||||
if (!existsSync(sourceDir)) return;
|
||||
|
||||
const targetDir = join(sharedSessionDataDir(), targetSessionId, "artifacts");
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
copyFileSync(join(sourceDir, entry.name), join(targetDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionMetadataTitle(sessionId: string): string | undefined {
|
||||
const manifest = readSessionManifest(sessionId);
|
||||
const metadata =
|
||||
@@ -354,6 +377,7 @@ function createLiveSession(
|
||||
overrides?: Partial<LiveSession>,
|
||||
): LiveSession {
|
||||
return {
|
||||
environmentId: overrides?.environmentId,
|
||||
config,
|
||||
messages: overrides?.messages ?? [],
|
||||
promptsInQueue: overrides?.promptsInQueue ?? [],
|
||||
@@ -421,7 +445,7 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
sessionId: config.sessionId ?? config.session_id,
|
||||
providerId: config.provider ?? config.providerId ?? "",
|
||||
modelId: config.model ?? config.modelId ?? "",
|
||||
mode: resolveDesktopSessionMode(config),
|
||||
mode: config.mode ?? "act",
|
||||
apiKey: config.apiKey ?? config.api_key ?? "",
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
@@ -445,17 +469,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
/** Auto-approval is a tool policy, not a request to change the tool preset. */
|
||||
export function resolveDesktopSessionMode(
|
||||
config: JsonRecord,
|
||||
): "act" | "plan" | "yolo" {
|
||||
return config.mode === "plan"
|
||||
? "plan"
|
||||
: config.mode === "yolo"
|
||||
? "yolo"
|
||||
: "act";
|
||||
}
|
||||
|
||||
export function buildSessionConnectionUpdate(
|
||||
config: JsonRecord,
|
||||
): SessionConnectionUpdate {
|
||||
@@ -564,7 +577,12 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
return String(config.systemPrompt ?? config.system_prompt ?? "").trim();
|
||||
}
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
const mode = resolveDesktopSessionMode(config);
|
||||
// Mode comes from config.mode only. `autoApproveTools` is a tool-approval
|
||||
// policy (see resolveToolPolicies) and must NOT switch the session to the
|
||||
// non-interactive "yolo" persona, whose prompt demands a `submit_and_exit`
|
||||
// tool that interactive desktop sessions do not expose.
|
||||
const mode =
|
||||
config.mode === "plan" ? "plan" : config.mode === "yolo" ? "yolo" : "act";
|
||||
const metadata = await consumeWorkspaceMetadata(cwd);
|
||||
const inlineRules =
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
@@ -646,9 +664,86 @@ function applyPendingPrompts(
|
||||
}));
|
||||
}
|
||||
|
||||
function getSessionManager(ctx: SidecarContext): ClineCore {
|
||||
if (!ctx.sessionManager) throw new Error("Session manager not initialized");
|
||||
return ctx.sessionManager;
|
||||
function readEnvironmentId(config: JsonRecord | undefined): string | undefined {
|
||||
const value = config?.environmentId ?? config?.environment_id;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readExplicitSystemPrompt(config: JsonRecord): string {
|
||||
const value = config.systemPrompt ?? config.system_prompt;
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote Hub intentionally has its own HOME and provider settings. Send the
|
||||
* selected desktop provider configuration over the authenticated SSH tunnel so
|
||||
* a normal signed-in desktop session can make model requests on the host. The
|
||||
* refresh token is omitted: v0 uses the current access/API token and never
|
||||
* writes the desktop's reusable OAuth credential to the remote settings file.
|
||||
*/
|
||||
async function withRemoteProviderCredentials(
|
||||
config: JsonRecord,
|
||||
): Promise<JsonRecord> {
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
if (!providerId) return config;
|
||||
|
||||
const manager = new ProviderSettingsManager();
|
||||
const settings = manager.getProviderSettings(providerId);
|
||||
if (!settings) return config;
|
||||
const modelId = String(
|
||||
config.model ?? config.modelId ?? settings.model ?? "",
|
||||
).trim();
|
||||
const storedConfig = {
|
||||
...toProviderConfig(
|
||||
{
|
||||
...settings,
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
},
|
||||
{ includeKnownModels: false },
|
||||
),
|
||||
};
|
||||
delete storedConfig.refreshToken;
|
||||
const explicitProviderConfig =
|
||||
config.providerConfig && typeof config.providerConfig === "object"
|
||||
? (config.providerConfig as JsonRecord)
|
||||
: undefined;
|
||||
const explicitApiKey = String(config.apiKey ?? config.api_key ?? "").trim();
|
||||
const oauth = explicitApiKey
|
||||
? null
|
||||
: await new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager: manager,
|
||||
}).resolveProviderApiKey({ providerId });
|
||||
const apiKey =
|
||||
explicitApiKey ||
|
||||
oauth?.apiKey ||
|
||||
resolveProviderApiKeyFromSettings(manager, providerId) ||
|
||||
String(storedConfig.apiKey ?? "").trim();
|
||||
|
||||
return {
|
||||
...config,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(!config.baseUrl && storedConfig.baseUrl
|
||||
? { baseUrl: storedConfig.baseUrl }
|
||||
: {}),
|
||||
...(!config.headers && storedConfig.headers
|
||||
? { headers: storedConfig.headers }
|
||||
: {}),
|
||||
providerConfig: {
|
||||
...storedConfig,
|
||||
...(explicitProviderConfig ?? {}),
|
||||
providerId,
|
||||
...(modelId ? { modelId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionManager(
|
||||
ctx: SidecarContext,
|
||||
sessionId?: string,
|
||||
config?: JsonRecord,
|
||||
): ClineCore {
|
||||
return getSessionRuntimeBinding(ctx, sessionId, readEnvironmentId(config))
|
||||
.sessionManager;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -660,20 +755,34 @@ async function handleStart(
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
if (!request.config) throw new Error("config is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const systemPrompt = await resolveSystemPrompt(request.config);
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
undefined,
|
||||
readEnvironmentId(request.config),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
const config =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(request.config)
|
||||
: request.config;
|
||||
// Workspace discovery must happen where the files live. Local desktop
|
||||
// sessions keep the eager prompt path; SSH sessions leave a blank prompt for
|
||||
// the remote Hub's LocalRuntimeHost bootstrap to compose from remote metadata.
|
||||
const systemPrompt =
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(config)
|
||||
: await resolveSystemPrompt(config);
|
||||
const requestedSessionId = String(
|
||||
request.config.sessionId ?? request.config.session_id ?? "",
|
||||
config.sessionId ?? config.session_id ?? "",
|
||||
).trim();
|
||||
const initialMessages =
|
||||
Array.isArray(request.config.initialMessages) &&
|
||||
request.config.initialMessages.length > 0
|
||||
? request.config.initialMessages
|
||||
Array.isArray(config.initialMessages) && config.initialMessages.length > 0
|
||||
? config.initialMessages
|
||||
: requestedSessionId
|
||||
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
|
||||
: undefined;
|
||||
const coreConfig: JsonRecord = {
|
||||
...buildCoreSessionConfig(request.config),
|
||||
...buildCoreSessionConfig(config),
|
||||
systemPrompt,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
};
|
||||
@@ -689,7 +798,10 @@ async function handleStart(
|
||||
});
|
||||
const startResult = await manager.start({
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
source:
|
||||
request.source === SessionSource.REALTIME
|
||||
? SessionSource.REALTIME
|
||||
: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
@@ -699,8 +811,14 @@ async function handleStart(
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
const session = createLiveSession(
|
||||
{ ...request.config, cwd, workspaceRoot },
|
||||
{
|
||||
...config,
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
environmentId: binding.environmentId,
|
||||
},
|
||||
{
|
||||
environmentId: binding.environmentId,
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
@@ -712,7 +830,13 @@ async function handleStart(
|
||||
},
|
||||
);
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
return { sessionId, cwd, workspaceRoot };
|
||||
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
environmentId: binding.environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAttach(
|
||||
@@ -724,7 +848,18 @@ async function handleAttach(
|
||||
throw new Error("sessionId is required");
|
||||
}
|
||||
|
||||
const manager = getSessionManager(ctx);
|
||||
const preferredEnvironmentId = readEnvironmentId(request.config);
|
||||
// An explicit environment is a hard routing boundary. Falling through to
|
||||
// another connected host can attach a same-id session from the wrong machine.
|
||||
const binding = preferredEnvironmentId
|
||||
? getSessionRuntimeBinding(ctx, sessionId, preferredEnvironmentId)
|
||||
: await findSessionRuntimeBinding(ctx, sessionId);
|
||||
if (!binding) {
|
||||
throw new Error(
|
||||
`Session ${sessionId} not found in a connected environment`,
|
||||
);
|
||||
}
|
||||
const manager = binding.sessionManager;
|
||||
const session = await manager.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
@@ -735,13 +870,12 @@ async function handleAttach(
|
||||
? (session.metadata as JsonRecord)
|
||||
: undefined;
|
||||
const existing = ctx.liveSessions.get(sessionId);
|
||||
if (ctx.hubClient) {
|
||||
await ctx.hubClient.command("session.attach", { sessionId }, sessionId);
|
||||
}
|
||||
const attachedConfig: JsonRecord = {
|
||||
await binding.hubClient.command("session.attach", { sessionId }, sessionId);
|
||||
const baseAttachedConfig: JsonRecord = {
|
||||
...(existing?.config ?? {}),
|
||||
...(request.config ?? {}),
|
||||
sessionId,
|
||||
environmentId: binding.environmentId,
|
||||
provider: session.provider || existing?.config.provider || "",
|
||||
model: session.model || existing?.config.model || "",
|
||||
cwd:
|
||||
@@ -755,9 +889,14 @@ async function handleAttach(
|
||||
String(request.config?.workspaceRoot ?? "").trim() ||
|
||||
String(existing?.config.workspaceRoot ?? "").trim(),
|
||||
};
|
||||
const attachedConfig =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(baseAttachedConfig)
|
||||
: baseAttachedConfig;
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(attachedConfig, {
|
||||
environmentId: binding.environmentId,
|
||||
messages: existing?.messages ?? [],
|
||||
promptsInQueue: existing?.promptsInQueue ?? [],
|
||||
status: session.status,
|
||||
@@ -777,9 +916,11 @@ async function handleAttach(
|
||||
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
|
||||
}),
|
||||
);
|
||||
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
environmentId: binding.environmentId,
|
||||
status: session.status,
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
@@ -832,11 +973,20 @@ async function startRebuiltSession(
|
||||
|
||||
async function rebuildSessionForProviderChange(
|
||||
ctx: SidecarContext,
|
||||
manager: ClineCore,
|
||||
binding: SessionRuntimeBinding,
|
||||
sessionId: string,
|
||||
previousConfig: JsonRecord,
|
||||
nextConfig: JsonRecord,
|
||||
): Promise<void> {
|
||||
const manager = binding.sessionManager;
|
||||
const effectivePreviousConfig =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(previousConfig)
|
||||
: previousConfig;
|
||||
const effectiveNextConfig =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(nextConfig)
|
||||
: nextConfig;
|
||||
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
|
||||
await Promise.all([
|
||||
manager.readMessages(sessionId),
|
||||
@@ -848,8 +998,12 @@ async function rebuildSessionForProviderChange(
|
||||
});
|
||||
return undefined;
|
||||
}),
|
||||
resolveSystemPrompt(previousConfig),
|
||||
resolveSystemPrompt(nextConfig),
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(effectivePreviousConfig)
|
||||
: resolveSystemPrompt(effectivePreviousConfig),
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(effectiveNextConfig)
|
||||
: resolveSystemPrompt(effectiveNextConfig),
|
||||
]);
|
||||
|
||||
await manager.stop(sessionId);
|
||||
@@ -858,7 +1012,7 @@ async function rebuildSessionForProviderChange(
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
nextConfig,
|
||||
effectiveNextConfig,
|
||||
nextSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
@@ -869,7 +1023,7 @@ async function rebuildSessionForProviderChange(
|
||||
// persistence failure cannot leave runtime and cached state diverged.
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
buildSessionConnectionUpdate(effectiveNextConfig),
|
||||
);
|
||||
} catch (replacementError) {
|
||||
try {
|
||||
@@ -879,14 +1033,14 @@ async function rebuildSessionForProviderChange(
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
effectivePreviousConfig,
|
||||
previousSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
);
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(previousConfig),
|
||||
buildSessionConnectionUpdate(effectivePreviousConfig),
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
@@ -911,8 +1065,13 @@ async function handleSend(
|
||||
if (!prompt && !hasAttachments) {
|
||||
throw new Error("prompt or attachment is required");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sessionId,
|
||||
readEnvironmentId(request.config),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
const lockedWorkspaceKey = workspacePathKey(
|
||||
session?.config ?? request.config,
|
||||
);
|
||||
@@ -929,7 +1088,8 @@ async function handleSend(
|
||||
// `/command` token as the session's display prompt.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
|
||||
readWorkspacePath(session?.config ?? request.config) ??
|
||||
ctx.localWorkspaceRoot,
|
||||
prompt,
|
||||
request.config?.mode ?? session?.config?.mode,
|
||||
);
|
||||
@@ -966,7 +1126,7 @@ async function handleSend(
|
||||
if (providerChanged && session) {
|
||||
await rebuildSessionForProviderChange(
|
||||
ctx,
|
||||
manager,
|
||||
binding,
|
||||
sessionId,
|
||||
session.config,
|
||||
nextConfig,
|
||||
@@ -989,16 +1149,18 @@ async function handleSend(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
binding.kind === "ssh" &&
|
||||
(request.attachments?.userFiles?.length ?? 0) > 0
|
||||
) {
|
||||
throw new Error(
|
||||
"File attachments are not available in the SSH proof of concept yet. Images and text prompts are supported.",
|
||||
);
|
||||
}
|
||||
const userFiles = materializeUserFiles(
|
||||
sessionId,
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (session?.attachedViaHub) {
|
||||
// Once ClineCore sends a turn, its HubRuntimeHost owns the session
|
||||
// subscription. Stop projecting the observer stream as well or every
|
||||
// assistant/tool update (including command chunks) is emitted twice.
|
||||
session.attachedViaHub = false;
|
||||
}
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
@@ -1068,11 +1230,19 @@ async function handleSend(
|
||||
} else {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
}
|
||||
ctx.logger?.log("Desktop chat prompt completed", {
|
||||
const completionMetadata = {
|
||||
sessionId,
|
||||
finishReason: result?.finishReason,
|
||||
textLength: result?.text?.length ?? 0,
|
||||
});
|
||||
};
|
||||
if (result?.finishReason === "error") {
|
||||
ctx.logger?.error?.("Desktop chat prompt returned an error result", {
|
||||
...completionMetadata,
|
||||
failure: result.text,
|
||||
});
|
||||
} else {
|
||||
ctx.logger?.log("Desktop chat prompt completed", completionMetadata);
|
||||
}
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "idle";
|
||||
if (result?.messages) session.messages = result.messages;
|
||||
@@ -1131,7 +1301,7 @@ async function handleStop(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -1146,7 +1316,10 @@ async function handleAbort(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
await getSessionManager(ctx).abort(sessionId, "user_abort");
|
||||
await getSessionManager(ctx, sessionId, request.config).abort(
|
||||
sessionId,
|
||||
"user_abort",
|
||||
);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -1168,7 +1341,7 @@ async function handleFork(
|
||||
) {
|
||||
throw new Error("forkBeforeRunCount must be a positive integer");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const manager = getSessionManager(ctx, sourceSessionId, request.config);
|
||||
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
|
||||
if (
|
||||
forkBeforeRunCount !== undefined &&
|
||||
@@ -1220,10 +1393,18 @@ async function handleForkUnlocked(
|
||||
sourceSession: SessionRecord | undefined,
|
||||
restoreWorkspacePath?: string,
|
||||
): Promise<unknown> {
|
||||
const manager = getSessionManager(ctx);
|
||||
const sourceMessages =
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
readEnvironmentId(request.config),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
let sourceMessages =
|
||||
readPersistedChatMessages(sourceSessionId) ??
|
||||
ctx.liveSessions.get(sourceSessionId)?.messages;
|
||||
if (!sourceMessages?.length && binding.kind === "ssh") {
|
||||
sourceMessages = await manager.readMessages(sourceSessionId);
|
||||
}
|
||||
if (!sourceMessages?.length) {
|
||||
throw new Error(`No messages found for session ${sourceSessionId}`);
|
||||
}
|
||||
@@ -1233,10 +1414,11 @@ async function handleForkUnlocked(
|
||||
? (sourceSession.metadata as JsonRecord)
|
||||
: undefined) ?? readSessionMetadata(sourceSessionId);
|
||||
const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config;
|
||||
const forkConfig: JsonRecord = {
|
||||
const baseForkConfig: JsonRecord = {
|
||||
...(liveConfig ?? {}),
|
||||
...(request.config ?? {}),
|
||||
sessionId: undefined,
|
||||
environmentId: binding.environmentId,
|
||||
provider:
|
||||
sourceSession?.provider ||
|
||||
liveConfig?.provider ||
|
||||
@@ -1266,6 +1448,10 @@ async function handleForkUnlocked(
|
||||
request.config?.cwd ||
|
||||
"",
|
||||
};
|
||||
const forkConfig =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(baseForkConfig)
|
||||
: baseForkConfig;
|
||||
const checkpointMetadata =
|
||||
sourceMetadata?.checkpoint !== undefined
|
||||
? { checkpoints: sourceMetadata.checkpoint }
|
||||
@@ -1286,7 +1472,10 @@ async function handleForkUnlocked(
|
||||
...checkpointMetadata,
|
||||
},
|
||||
};
|
||||
const systemPrompt = await resolveSystemPrompt(forkConfig);
|
||||
const systemPrompt =
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(forkConfig)
|
||||
: await resolveSystemPrompt(forkConfig);
|
||||
const startInput = {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
@@ -1332,6 +1521,7 @@ async function handleForkUnlocked(
|
||||
});
|
||||
newSessionId = started.sessionId;
|
||||
}
|
||||
copySessionGeneratedArtifacts(sourceSessionId, newSessionId);
|
||||
try {
|
||||
const read = await manager.readMessages(newSessionId);
|
||||
if (forkBeforeRunCount !== undefined || read.length > 0) {
|
||||
@@ -1346,12 +1536,14 @@ async function handleForkUnlocked(
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
createLiveSession(forkConfig, {
|
||||
environmentId: binding.environmentId,
|
||||
messages: forkMessages,
|
||||
prompt: derivePromptFromMessages(forkMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
ctx.sessionEnvironmentIds.set(newSessionId, binding.environmentId);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, newSessionId);
|
||||
return {
|
||||
@@ -1373,10 +1565,11 @@ async function handleReset(
|
||||
session?.status === "running" ||
|
||||
session?.status === "stopping"
|
||||
) {
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
ctx.sessionEnvironmentIds.delete(sessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
return { sessionId: request.sessionId, ok: true };
|
||||
@@ -1395,14 +1588,25 @@ async function handleRestoreCheckpoint(
|
||||
runCount < 1
|
||||
)
|
||||
throw new Error("checkpointRunCount must be a positive integer");
|
||||
const config = request.config;
|
||||
if (!config) throw new Error("config is required to restore a checkpoint");
|
||||
const requestedConfig = request.config;
|
||||
if (!requestedConfig)
|
||||
throw new Error("config is required to restore a checkpoint");
|
||||
const cwd =
|
||||
(typeof config.cwd === "string" && config.cwd.trim()) ||
|
||||
(typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) ||
|
||||
(typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) ||
|
||||
(typeof requestedConfig.workspaceRoot === "string" &&
|
||||
requestedConfig.workspaceRoot.trim()) ||
|
||||
"";
|
||||
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
readEnvironmentId(requestedConfig),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
const config =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(requestedConfig)
|
||||
: requestedConfig;
|
||||
return withWorkspaceRestoreLock(ctx, cwd, async () => {
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
@@ -1413,7 +1617,10 @@ async function handleRestoreCheckpoint(
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
systemPrompt:
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(config)
|
||||
: await resolveSystemPrompt(config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
@@ -1426,6 +1633,7 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
copySessionGeneratedArtifacts(sourceSessionId, sessionId);
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
@@ -1434,12 +1642,14 @@ async function handleRestoreCheckpoint(
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(config, {
|
||||
environmentId: binding.environmentId,
|
||||
messages: restoredMessages,
|
||||
prompt: derivePromptFromMessages(restoredMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
|
||||
// A restore that reuses the source session id leaves the persisted
|
||||
// transcript describing the discarded turns, and read_session_messages
|
||||
// prefers that file over the live session. Write the trimmed history so
|
||||
@@ -1460,9 +1670,11 @@ async function handlePendingPrompts(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const prompts = await getSessionManager(ctx).pendingPrompts.list({
|
||||
const prompts = await getSessionManager(
|
||||
ctx,
|
||||
sessionId,
|
||||
});
|
||||
request.config,
|
||||
).pendingPrompts.list({ sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
@@ -1477,7 +1689,7 @@ async function handleSteerPrompt(
|
||||
const promptId = request.promptId?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
const manager = getSessionManager(ctx);
|
||||
const manager = getSessionManager(ctx, sessionId, request.config);
|
||||
const result = await manager.pendingPrompts.update({
|
||||
sessionId,
|
||||
promptId,
|
||||
@@ -1509,7 +1721,7 @@ async function handleUpdatePendingPrompt(
|
||||
// through handleSend, so resolve slash commands here too.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
|
||||
readWorkspacePath(sessionConfig) ?? ctx.localWorkspaceRoot,
|
||||
prompt,
|
||||
sessionConfig?.mode,
|
||||
);
|
||||
@@ -1535,7 +1747,7 @@ async function handleRemovePendingPrompt(
|
||||
if (!sessionId || !promptId) {
|
||||
throw new Error("sessionId and promptId are required");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const manager = getSessionManager(ctx, sessionId, request.config);
|
||||
const result = await manager.pendingPrompts.delete({
|
||||
sessionId,
|
||||
promptId,
|
||||
@@ -1578,6 +1790,125 @@ export async function handleChatSessionCommand(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
const executionTarget = String(request.config?.executionTarget ?? "").trim();
|
||||
const sessionId = request.sessionId?.trim();
|
||||
const existingCloudSession =
|
||||
sessionId &&
|
||||
ctx.liveSessions.get(sessionId)?.config.executionTarget === "cloud";
|
||||
if (executionTarget === "cloud" || existingCloudSession) {
|
||||
const cloud = getCloudSessionManager(ctx);
|
||||
// The approval preference lives only client-side; keep the live session
|
||||
// current so a lazily created inner session inherits the user's choice.
|
||||
const requestedAutoApprove = request.config?.autoApproveTools;
|
||||
if (typeof requestedAutoApprove === "boolean" && sessionId) {
|
||||
const live = ctx.liveSessions.get(sessionId);
|
||||
if (live) {
|
||||
live.config.autoApproveTools = requestedAutoApprove;
|
||||
}
|
||||
}
|
||||
switch (request.action) {
|
||||
case "start": {
|
||||
const requestedSessionId = String(
|
||||
request.config?.sessionId ?? request.config?.session_id ?? "",
|
||||
).trim();
|
||||
// Server ids attach even with a cold registry; client-planned ids create.
|
||||
if (requestedSessionId && cloud.isCloudSession(requestedSessionId)) {
|
||||
return await cloud.attach(requestedSessionId);
|
||||
}
|
||||
// Gate new sessions only; existing cloud sessions must remain usable.
|
||||
if (!isCloudAgentsEnabled()) {
|
||||
throw new Error(
|
||||
"Cloud sessions are not enabled for this account yet.",
|
||||
);
|
||||
}
|
||||
const repoUrl = String(request.config?.repoUrl ?? "").trim();
|
||||
const modelId = String(
|
||||
request.config?.model ?? request.config?.modelId ?? "",
|
||||
).trim();
|
||||
if (!repoUrl || !modelId) {
|
||||
throw new Error("repoUrl and model are required for a cloud session");
|
||||
}
|
||||
const branch = String(request.config?.branch ?? "").trim();
|
||||
const initialPrompt = request.prompt?.trim();
|
||||
const reasoningEffort = readReasoningEffort(
|
||||
request.config?.reasoningEffort,
|
||||
);
|
||||
return await cloud.create({
|
||||
repoUrl,
|
||||
modelId,
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
...(branch ? { branch } : {}),
|
||||
...(typeof request.config?.thinking === "boolean"
|
||||
? { thinking: request.config.thinking }
|
||||
: {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(typeof requestedAutoApprove === "boolean"
|
||||
? { autoApproveTools: requestedAutoApprove }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
case "attach":
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
return await cloud.attach(sessionId);
|
||||
case "send": {
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
if (request.attachments?.userFiles?.length) {
|
||||
throw new Error(
|
||||
"File attachments are not supported in cloud sessions",
|
||||
);
|
||||
}
|
||||
const live = ctx.liveSessions.get(sessionId);
|
||||
const delivery = request.delivery ?? (live?.busy ? "queue" : undefined);
|
||||
const modelId = String(
|
||||
request.config?.model ?? request.config?.modelId ?? "",
|
||||
).trim();
|
||||
return await cloud.send(
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
modelId || undefined,
|
||||
request.attachments?.userImages,
|
||||
);
|
||||
}
|
||||
case "stop":
|
||||
case "abort":
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
return await cloud.abort(sessionId);
|
||||
case "pending_prompts":
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
return await cloud.pendingPrompts(sessionId);
|
||||
case "steer_prompt": {
|
||||
const promptId = request.promptId?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
return await cloud.updatePendingPrompt(sessionId, promptId, {
|
||||
delivery: "steer",
|
||||
});
|
||||
}
|
||||
case "update_pending_prompt": {
|
||||
const promptId = request.promptId?.trim();
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
return await cloud.updatePendingPrompt(sessionId, promptId, {
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
case "remove_pending_prompt": {
|
||||
const promptId = request.promptId?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
return await cloud.removePendingPrompt(sessionId, promptId);
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`${request.action} is not supported for cloud sessions yet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const handler = ACTION_HANDLERS[request.action];
|
||||
if (!handler) throw new Error("unsupported action");
|
||||
return handler(ctx, request);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
captureAuthRefreshSoftFailure,
|
||||
getProviderAuthHandler,
|
||||
type ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "@cline/core";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
// Cline access tokens expire between app launches, so account requests must
|
||||
// resolve through the refresh-aware OAuth manager instead of reading the
|
||||
// persisted token directly. A single shared instance keeps concurrent account
|
||||
// requests single-flight; the refresh token is single-use, so parallel
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
export async function resolveFreshClineAuthToken(
|
||||
manager: ProviderSettingsManager,
|
||||
ctx?: SidecarContext,
|
||||
): Promise<string | undefined> {
|
||||
let refreshError: Error | undefined;
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
providerId: "cline",
|
||||
});
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to the persisted token; when one exists the account request
|
||||
// surfaces the auth failure to the caller.
|
||||
refreshError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
// The canonical handler applies the same formatting the refresh path uses:
|
||||
// OAuth access tokens gain the `workos:` prefix core-platform expects,
|
||||
// while raw API keys pass through untouched.
|
||||
const persisted = getProviderAuthHandler("cline")?.getApiKey(
|
||||
manager.getProviderSettings("cline"),
|
||||
);
|
||||
// Never-signed-in resolves to undefined without a refresh attempt and is
|
||||
// silent. A refresh failure with no persisted fallback means credentials
|
||||
// existed but yielded nothing — that is the signal a real auth regression
|
||||
// would show up as, so report exactly one event for it.
|
||||
if (!persisted && refreshError && ctx) {
|
||||
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
|
||||
error: refreshError,
|
||||
});
|
||||
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
|
||||
errorName: refreshError.name,
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
});
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import type { SidecarContext } from "./types";
|
||||
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -22,7 +21,6 @@ vi.mock("@cline/core", async () => {
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
@@ -52,7 +50,6 @@ beforeEach(() => {
|
||||
clineAccountServiceCtorMock.mockReset();
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -113,8 +110,10 @@ describe("cline_account command auth states", () => {
|
||||
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
// Persisted OAuth tokens gain the `workos:` prefix required by
|
||||
// core-platform (see cline-auth.ts).
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe(
|
||||
"persisted-token",
|
||||
"workos:persisted-token",
|
||||
);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -143,162 +142,3 @@ describe("cline_account command auth states", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Feature-flag identity is otherwise resolved once at sidecar startup, so these
|
||||
* cover the mid-session transitions that would otherwise keep evaluating flags
|
||||
* against a stale account (or the device).
|
||||
*/
|
||||
describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
async function currentFlagsUserId(): Promise<string | undefined> {
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
return getDesktopFeatureFlagsContext().userId ?? undefined;
|
||||
}
|
||||
|
||||
async function runOperation(ctx: SidecarContext, operation: string) {
|
||||
const { handleCommand } = await import("./commands");
|
||||
return handleCommand(ctx, "cline_account", {
|
||||
action: "clineAccount",
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { resetDesktopFeatureFlagsForTesting } = await import(
|
||||
"./feature-flags"
|
||||
);
|
||||
resetDesktopFeatureFlagsForTesting();
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
});
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue(undefined);
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
auth: { accountId: "stale-acct" },
|
||||
});
|
||||
|
||||
await runOperation(ctx, "switchAccount");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("adopts the identity from the refetch that follows a switch", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue(undefined);
|
||||
await runOperation(ctx, "switchAccount");
|
||||
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-2" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-2");
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
// Signed out: no token resolves.
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears the identity when sign-out blanks the cline auth settings", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
|
||||
// What the Sign Out button actually sends: a settings write that blanks
|
||||
// the auth block. No account command is involved.
|
||||
getProviderSettingsMock.mockReturnValue({ auth: { accountId: "" } });
|
||||
saveProviderSettingsMock.mockReturnValue({
|
||||
providerId: "cline",
|
||||
enabled: true,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "cline",
|
||||
api_key: "",
|
||||
settings: { auth: { accessToken: "", refreshToken: "", accountId: "" } },
|
||||
});
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores settings writes for other providers", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
saveProviderSettingsMock.mockReturnValue({
|
||||
providerId: "anthropic",
|
||||
enabled: true,
|
||||
settingsPath: "/tmp/settings.json",
|
||||
});
|
||||
const { handleCommand } = await import("./commands");
|
||||
await handleCommand(ctx, "save_provider_settings", {
|
||||
provider: "anthropic",
|
||||
api_key: "sk-test",
|
||||
});
|
||||
|
||||
// Saving an unrelated provider must not disturb the Cline identity.
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("falls back to the device distinct ID after logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
|
||||
const deviceId = getDesktopFeatureFlagsContext().distinctId;
|
||||
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
await runOperation(ctx, "fetchMe");
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe("acct-1");
|
||||
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
// Not left on the previous account's ID.
|
||||
expect(getDesktopFeatureFlagsContext().distinctId).toBe(deviceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
resolveGitHubInstallUrl,
|
||||
} from "./commands-integrations";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const REQUEST_OPTIONS = {
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
appBaseUrl: "https://app.example.com",
|
||||
authToken: "test-token",
|
||||
} as const;
|
||||
|
||||
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
...REQUEST_OPTIONS,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("listClineIntegrations", () => {
|
||||
it("lists integrations through the envelope with a bearer token", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineIntegrations(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer test-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listClineGitHubRepositories", () => {
|
||||
it("lists GitHub repositories from the repositories endpoint", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ full_name: "cline/cline" }]);
|
||||
expect(String(fetchImpl.mock.calls[0][0])).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/repositories",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the API envelope error message on failures", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: "failed to list integrations" },
|
||||
500,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
listClineIntegrations(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("failed to list integrations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitHubInstallUrl", () => {
|
||||
it("resolves the GitHub install URL from the redirect location", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
});
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/install",
|
||||
);
|
||||
// The post-install browser hop must land on the Cline dashboard.
|
||||
expect(url.searchParams.get("redirect")).toBe(
|
||||
"https://app.example.com/dashboard/integrations",
|
||||
);
|
||||
// The redirect must be read, not followed: the Location URL is the result.
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("resolves a relative redirect location against the request URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "//github.com/apps/cline/installations/new" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
// A bare relative Location would blow up later in the URL opener.
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://evil.example/apps/cline", "evil.example"],
|
||||
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
|
||||
// Subdomains are not part of the install flow, so they are not allowed
|
||||
// either -- the host must be exactly github.com.
|
||||
["https://gist.github.com/apps/cline", "gist.github.com"],
|
||||
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(null, { status: 302, headers: { location } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow(`unexpected host: ${host}`);
|
||||
});
|
||||
|
||||
it("rejects a redirect that does not use https", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://github.com/apps/cline" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("must use https");
|
||||
});
|
||||
|
||||
it("rejects a redirect location that is not a usable URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("not a valid URL");
|
||||
});
|
||||
|
||||
it("throws when the install endpoint does not answer with a redirect", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ error: "authentication required" }, 401),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("authentication required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline_integrations command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the Cline API with the resolved fresh token when signed in", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toContain("/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer fresh-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown operations", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
await expect(
|
||||
handleCommand(ctx, "cline_integrations", {
|
||||
operation: "dropIntegrations",
|
||||
}),
|
||||
).rejects.toThrow("Unsupported Cline integrations operation");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
ClineGitHubRepository,
|
||||
ClineIntegration,
|
||||
} from "../webview/lib/cline-integrations-types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const GITHUB_INSTALL_HOST = "github.com";
|
||||
|
||||
function resolveInstallRedirect(location: string, requestUrl: URL): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
|
||||
}
|
||||
if (resolved.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`GitHub install redirect must use https, got: ${resolved.protocol}`,
|
||||
);
|
||||
}
|
||||
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
|
||||
throw new Error(
|
||||
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
|
||||
);
|
||||
}
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
export interface ClineIntegrationsRequestOptions {
|
||||
apiBaseUrl: string;
|
||||
/** Frontend origin the browser install flow returns to when it finishes. */
|
||||
appBaseUrl: string;
|
||||
authToken: string;
|
||||
requestTimeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function listClineIntegrations(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineIntegration[]> {
|
||||
const data = await requestClineApiJson("/api/v1/integrations", options);
|
||||
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
|
||||
}
|
||||
|
||||
export async function listClineGitHubRepositories(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineGitHubRepository[]> {
|
||||
const data = await requestClineApiJson(
|
||||
"/api/v1/integrations/github/repositories",
|
||||
options,
|
||||
);
|
||||
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
|
||||
}
|
||||
|
||||
export async function resolveGitHubInstallUrl(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<{ url: string }> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const installUrl = new URL(
|
||||
"/api/v1/integrations/github/install",
|
||||
options.apiBaseUrl,
|
||||
);
|
||||
installUrl.searchParams.set(
|
||||
"redirect",
|
||||
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(installUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${options.authToken}` },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (response.status >= 300 && response.status < 400 && location?.trim()) {
|
||||
return { url: resolveInstallRedirect(location.trim(), installUrl) };
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = text.trim() ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvelopeError(parsed: unknown): string | undefined {
|
||||
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const error = (parsed as { error?: unknown }).error;
|
||||
return typeof error === "string" && error.trim() ? error : undefined;
|
||||
}
|
||||
|
||||
function formatRequestFailure(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
parsed: unknown,
|
||||
): string {
|
||||
const envelopeError = getEnvelopeError(parsed);
|
||||
if (envelopeError) {
|
||||
return envelopeError;
|
||||
}
|
||||
const body = bodyText.trim();
|
||||
if (body) {
|
||||
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
|
||||
return `Cline integrations request failed with status ${status}: ${preview}`;
|
||||
}
|
||||
return `Cline integrations request failed with status ${status}`;
|
||||
}
|
||||
|
||||
async function requestClineApiJson(
|
||||
endpoint: string,
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<unknown> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
formatRequestFailure(response.status, text, undefined),
|
||||
);
|
||||
}
|
||||
throw new Error("Cline integrations response was not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
}
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
|
||||
const envelope = parsed as {
|
||||
success?: unknown;
|
||||
error?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
if (typeof envelope.success === "boolean") {
|
||||
if (!envelope.success) {
|
||||
throw new Error(
|
||||
getEnvelopeError(parsed) || "Cline integrations request failed",
|
||||
);
|
||||
}
|
||||
return envelope.data ?? null;
|
||||
}
|
||||
}
|
||||
return parsed ?? null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { handleCommand } from "./commands";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
function createContext(): {
|
||||
ctx: SidecarContext;
|
||||
events: Array<{ name: string; payload: Record<string, unknown> }>;
|
||||
} {
|
||||
const events: Array<{ name: string; payload: Record<string, unknown> }> = [];
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set([
|
||||
{
|
||||
send(message: string) {
|
||||
const parsed = JSON.parse(message) as {
|
||||
event: { name: string; payload: Record<string, unknown> };
|
||||
};
|
||||
events.push(parsed.event);
|
||||
},
|
||||
},
|
||||
]),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot: "/local/workspace",
|
||||
unsubscribeSessionEvents: null,
|
||||
cloudSessionManager: null,
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, events };
|
||||
}
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-commands-settings-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_CODE_CLOUD_AGENTS;
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("desktop settings commands", () => {
|
||||
it("reads default desktop settings and an off feature gate", async () => {
|
||||
const { ctx } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "get_desktop_settings", {}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: false });
|
||||
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
|
||||
cloudAgents: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-boolean cloud sessions toggle value", async () => {
|
||||
const { ctx, events } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: "yes",
|
||||
}),
|
||||
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists the toggle and broadcasts the new gate immediately", async () => {
|
||||
const { ctx, events } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: true,
|
||||
}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: true });
|
||||
// Open webviews re-evaluate without waiting for a restart or account
|
||||
// change.
|
||||
expect(events).toEqual([
|
||||
{ name: "feature_flags_changed", payload: { cloudAgents: true } },
|
||||
]);
|
||||
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
|
||||
cloudAgents: true,
|
||||
});
|
||||
|
||||
await handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: false,
|
||||
});
|
||||
expect(events.at(-1)).toEqual({
|
||||
name: "feature_flags_changed",
|
||||
payload: { cloudAgents: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the env override through the feature gate", async () => {
|
||||
const { ctx } = createContext();
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
|
||||
|
||||
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
|
||||
cloudAgents: true,
|
||||
});
|
||||
// The toggle's stored value is reported as-is; the override only
|
||||
// affects the effective gate.
|
||||
await expect(
|
||||
handleCommand(ctx, "get_desktop_settings", {}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,17 +15,17 @@ const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
const updateCapabilitiesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@ai-sdk/provider-utils", () => ({
|
||||
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ClientSettingsManager: class {
|
||||
initializeModesIfMissing = vi.fn();
|
||||
read = vi.fn(() => ({ modes: {} }));
|
||||
getModeSettings = vi.fn(() => undefined);
|
||||
},
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
@@ -40,7 +40,6 @@ vi.mock("@cline/core", async () => {
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
updateCapabilities = updateCapabilitiesMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
};
|
||||
@@ -70,7 +69,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
updateCapabilitiesMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
@@ -80,7 +78,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
updateCapabilitiesMock.mockResolvedValue(undefined);
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
@@ -88,7 +85,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the desktop capability factory with core", async () => {
|
||||
it("registers Code App capability factory with core", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
@@ -110,7 +107,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -121,7 +118,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
displayName: "Code App observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -194,7 +191,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
expect(hubClient).toBe(ctx.hubClient);
|
||||
expect(hubClient).toBeDefined();
|
||||
|
||||
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
|
||||
strategy: "require-hub",
|
||||
@@ -210,78 +207,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forwards raw hub tool updates to attached desktop sessions", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
ctx.liveSessions.set("session-1", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "tool.updated",
|
||||
sessionId: "session-1",
|
||||
payload: {
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_commands",
|
||||
update: { stream: "stdout", chunk: "live\n" },
|
||||
},
|
||||
});
|
||||
|
||||
const forwarded = readEvents(ctx).find(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_tool_call_update",
|
||||
);
|
||||
expect(forwarded?.event.payload).toMatchObject({
|
||||
sessionId: "session-1",
|
||||
stream: "chat_tool_call_update",
|
||||
});
|
||||
expect(
|
||||
JSON.parse(
|
||||
String((forwarded?.event.payload as { chunk?: string }).chunk),
|
||||
),
|
||||
).toEqual({
|
||||
toolCallId: "call-1",
|
||||
toolName: "run_commands",
|
||||
update: { stream: "stdout", chunk: "live\n" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards proceed-while-running requests to the hub", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { detachedCount: 1 },
|
||||
});
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "proceed_while_running", {
|
||||
sessionId: "session-1",
|
||||
toolCallId: "call-1",
|
||||
}),
|
||||
).resolves.toEqual({ detachedCount: 1 });
|
||||
expect(hubCommandMock).toHaveBeenCalledWith(
|
||||
"run.proceed_while_running",
|
||||
{ sessionId: "session-1", toolCallId: "call-1" },
|
||||
"session-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes queued image data when a queued prompt starts", async () => {
|
||||
const { serializeQueuedPromptStart } = await import("./context");
|
||||
|
||||
@@ -302,6 +227,43 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves attached-session content projection to the Core event stream", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
ctx.liveSessions.set("session-image", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
for (const event of [
|
||||
"assistant.delta",
|
||||
"assistant.image",
|
||||
"reasoning.delta",
|
||||
"tool.started",
|
||||
"tool.finished",
|
||||
]) {
|
||||
handleHubLiveEvent(ctx, {
|
||||
event,
|
||||
sessionId: "session-image",
|
||||
payload: {
|
||||
text: "one canonical copy",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(readEvents(ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it("announces a queued prompt start once when drain emits both queue events", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -467,11 +429,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
@@ -481,7 +439,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
"Which branch?",
|
||||
["Keep current", "Create new"],
|
||||
{
|
||||
sessionId: "session-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 3,
|
||||
@@ -493,7 +450,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
(item) => item.event.name === "ask_question_requested",
|
||||
);
|
||||
expect(event?.event.payload).toMatchObject({
|
||||
sessionId: "session-1",
|
||||
question: "Which branch?",
|
||||
options: ["Keep current", "Create new"],
|
||||
context: {
|
||||
@@ -504,23 +460,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
const requestId = String(event?.event.payload.requestId ?? "");
|
||||
expect(requestId.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_ask_questions", {
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
requestId,
|
||||
sessionId: "session-1",
|
||||
question: "Which branch?",
|
||||
options: ["Keep current", "Create new"],
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_ask_questions", {
|
||||
sessionId: "another-session",
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
await handleCommand(ctx, "respond_ask_question", {
|
||||
requestId,
|
||||
@@ -529,11 +468,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
|
||||
await expect(answer).resolves.toBe("Create new");
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_ask_questions", {
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(readEvents(ctx)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
@@ -544,32 +478,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects askQuestion requests without an owning session", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
const capabilities = createCoreMock.mock.calls[0][0]
|
||||
.capabilities as RuntimeCapabilities;
|
||||
const answer = capabilities.toolExecutors?.askQuestion?.(
|
||||
"Which branch?",
|
||||
["Keep current", "Create new"],
|
||||
{ agentId: "agent-1", iteration: 3 },
|
||||
);
|
||||
|
||||
await expect(answer).rejects.toThrow(
|
||||
"ask_question requires an active session ID",
|
||||
);
|
||||
expect(
|
||||
readEvents(ctx).some(
|
||||
(message) => message.event.name === "ask_question_requested",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves approval through websocket state", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -577,11 +485,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const { handleCommand } = await import("./commands");
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
@@ -594,7 +498,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
hub: expect.objectContaining({
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -613,12 +517,9 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
|
||||
expect(approval).toBeInstanceOf(Promise);
|
||||
const pending = await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
const pending = await handleCommand(ctx, "poll_tool_approvals", {
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
expect(pending).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
@@ -644,268 +545,47 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
|
||||
const [{ requestId }] = pending as Array<{ requestId: string }>;
|
||||
const untrustedClient = { send: vi.fn() };
|
||||
ctx.wsClients.add(untrustedClient);
|
||||
await expect(
|
||||
handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: untrustedClient },
|
||||
),
|
||||
).rejects.toThrow("trusted desktop connection");
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
await handleCommand(ctx, "respond_tool_approval", {
|
||||
sessionId: "sess-1",
|
||||
requestId,
|
||||
approved: true,
|
||||
});
|
||||
|
||||
await expect(approval).resolves.toEqual({ approved: true });
|
||||
expect(
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects and removes an approval when initial delivery fails", async () => {
|
||||
const { createSidecarContext, createSidecarRuntimeCapabilities } =
|
||||
await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const failedClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(failedClient);
|
||||
|
||||
const approval = createSidecarRuntimeCapabilities(
|
||||
ctx,
|
||||
).requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
|
||||
await expect(approval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(failedClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an owned approval when a later broadcast fails", async () => {
|
||||
const {
|
||||
broadcastEvent,
|
||||
createSidecarContext,
|
||||
createSidecarRuntimeCapabilities,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
|
||||
const approval = createSidecarRuntimeCapabilities(
|
||||
ctx,
|
||||
).requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(1);
|
||||
|
||||
broadcastEvent(ctx, "task.updated", { taskId: "task-1" });
|
||||
|
||||
await expect(approval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(approvalClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects sibling approvals when a targeted state update fails", async () => {
|
||||
const { createSidecarContext, createSidecarRuntimeCapabilities } =
|
||||
await import("./context");
|
||||
it("keeps an approval visible when its remote acknowledgement fails", async () => {
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("socket closed");
|
||||
}),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
const capabilities = createSidecarRuntimeCapabilities(ctx);
|
||||
const request = (toolCallId: string) =>
|
||||
capabilities.requestToolApproval?.({
|
||||
sessionId: "sess-1",
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
iteration: 1,
|
||||
toolCallId,
|
||||
ctx.pendingApprovals.set("cloud-approval", {
|
||||
item: {
|
||||
requestId: "cloud-approval",
|
||||
sessionId: "ses-cloud",
|
||||
createdAt: new Date().toISOString(),
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
input: { commands: ["echo hi"] },
|
||||
policy: { autoApprove: false },
|
||||
});
|
||||
const firstApproval = request("tool-call-1");
|
||||
const siblingApproval = request("tool-call-2");
|
||||
const [{ requestId }] = (await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "sess-1" },
|
||||
{ connection: approvalClient },
|
||||
)) as Array<{ requestId: string }>;
|
||||
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{ sessionId: "sess-1", requestId, approved: true },
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await expect(firstApproval).resolves.toEqual({ approved: true });
|
||||
await expect(siblingApproval).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
expect(ctx.pendingApprovals.size).toBe(0);
|
||||
expect(ctx.wsClients.has(approvalClient)).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes approval readiness updates and publishes the latest state", async () => {
|
||||
const {
|
||||
createSidecarContext,
|
||||
initializeSessionManager,
|
||||
syncSidecarApprovalReadiness,
|
||||
} = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
updateCapabilitiesMock.mockReset();
|
||||
|
||||
let finishDisconnectedUpdate: (() => void) | undefined;
|
||||
updateCapabilitiesMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDisconnectedUpdate = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const disconnected = syncSidecarApprovalReadiness(ctx);
|
||||
await vi.waitFor(() =>
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledWith([]),
|
||||
);
|
||||
ctx.wsClients.add({
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
});
|
||||
const connected = syncSidecarApprovalReadiness(ctx);
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishDisconnectedUpdate?.();
|
||||
await Promise.all([disconnected, connected]);
|
||||
expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ name: "approval.respond" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards Hub-owned task session approvals to the live desktop", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
let onHubEvent: ((event: Record<string, unknown>) => void) | undefined;
|
||||
subscribeMock.mockImplementation((handler) => {
|
||||
onHubEvent = handler;
|
||||
return () => {};
|
||||
});
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
ctx.wsClients.add(approvalClient);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: "approval.respond" }),
|
||||
]);
|
||||
onHubEvent?.({
|
||||
event: "approval.requested",
|
||||
sessionId: "task-session-1",
|
||||
payload: {
|
||||
approvalId: "hub-approval-1",
|
||||
agendaTaskId: "task-1",
|
||||
agentId: "task-agent-1",
|
||||
conversationId: "task-conversation-1",
|
||||
iteration: 2,
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "write_to_file",
|
||||
inputJson: JSON.stringify({ path: "src/a.ts" }),
|
||||
policy: { autoApprove: false },
|
||||
},
|
||||
resolve: async () => {
|
||||
throw new Error("hub disconnected");
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1));
|
||||
const pendingItems = (await handleCommand(
|
||||
ctx,
|
||||
"poll_tool_approvals",
|
||||
{ sessionId: "task-session-1" },
|
||||
{ connection: approvalClient },
|
||||
)) as Array<{ requestId: string }>;
|
||||
const pending = pendingItems[0];
|
||||
if (!pending) throw new Error("expected a pending task approval");
|
||||
await handleCommand(
|
||||
ctx,
|
||||
"respond_tool_approval",
|
||||
{
|
||||
sessionId: "task-session-1",
|
||||
requestId: pending.requestId,
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "respond_tool_approval", {
|
||||
sessionId: "ses-cloud",
|
||||
requestId: "cloud-approval",
|
||||
approved: true,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hubCommandMock).toHaveBeenCalledWith(
|
||||
"approval.respond",
|
||||
{
|
||||
approvalId: "hub-approval-1",
|
||||
approved: true,
|
||||
reason: undefined,
|
||||
},
|
||||
"task-session-1",
|
||||
),
|
||||
);
|
||||
}),
|
||||
).rejects.toThrow("hub disconnected");
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_tool_approvals", {
|
||||
sessionId: "ses-cloud",
|
||||
}),
|
||||
).toEqual([expect.objectContaining({ requestId: "cloud-approval" })]);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
@@ -932,115 +612,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proxies Agenda task commands through the connected shared Hub", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const task = {
|
||||
taskId: "task-1",
|
||||
title: "Review the PR",
|
||||
status: "pending_approval",
|
||||
};
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { tasks: [task] },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
const approvalClient = {
|
||||
data: { canApproveTools: true },
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "task.list", {
|
||||
workspaceRoot: "/workspace/project",
|
||||
statuses: ["pending_approval"],
|
||||
}),
|
||||
).resolves.toEqual({ tasks: [task] });
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("task.list", {
|
||||
workspaceRoot: "/workspace/project",
|
||||
statuses: ["pending_approval"],
|
||||
});
|
||||
|
||||
hubCommandMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { task: { ...task, status: "in_progress", revision: 4 } },
|
||||
});
|
||||
await expect(
|
||||
handleCommand(
|
||||
ctx,
|
||||
"task.run",
|
||||
{
|
||||
taskId: "task-1",
|
||||
expectedRevision: 4,
|
||||
},
|
||||
{ connection: approvalClient },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
task: { ...task, status: "in_progress", revision: 4 },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("task.run", {
|
||||
taskId: "task-1",
|
||||
expectedRevision: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"task.create",
|
||||
"task.approve",
|
||||
"task.cancel",
|
||||
"task.run",
|
||||
"task.automation.set",
|
||||
])("rejects untrusted %s commands before they reach the shared Hub", async (command) => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
const untrustedClient = {
|
||||
data: { canApproveTools: false },
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, command, {}, { connection: untrustedClient }),
|
||||
).rejects.toThrow("task execution requires a trusted desktop connection");
|
||||
expect(hubCommandMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards Hub task events that do not have a session", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() } as never);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "task.created",
|
||||
payload: {
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readEvents(ctx)).toEqual([
|
||||
{
|
||||
type: "event",
|
||||
event: {
|
||||
name: "task.created",
|
||||
payload: {
|
||||
taskId: "task-1",
|
||||
status: "pending_approval",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
@@ -1091,4 +662,37 @@ describe("disposeSidecarContext attachment cleanup", () => {
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("waits for pending approval callbacks before shutdown completes", async () => {
|
||||
const { createSidecarContext, disposeSidecarContext } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
let release: (() => void) | undefined;
|
||||
ctx.pendingApprovals.set("approval-1", {
|
||||
item: {
|
||||
requestId: "approval-1",
|
||||
sessionId: "session-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
input: {},
|
||||
},
|
||||
resolve: async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
let disposed = false;
|
||||
const disposing = disposeSidecarContext(ctx, "test_shutdown").then(() => {
|
||||
disposed = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
|
||||
expect(disposed).toBe(false);
|
||||
|
||||
release?.();
|
||||
await disposing;
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,37 +15,29 @@ import {
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type AgentEvent,
|
||||
HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
isGeneratedMedia,
|
||||
} from "@cline/shared";
|
||||
import { type AgentEvent, isGeneratedMedia } from "@cline/shared";
|
||||
import {
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
reconcileQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import {
|
||||
disposeDesktopFeatureFlagsService,
|
||||
getDesktopFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
import { sessionLogPath } from "./paths";
|
||||
import type {
|
||||
LiveSession,
|
||||
PendingAskQuestion,
|
||||
PendingToolApproval,
|
||||
PromptInQueue,
|
||||
SessionRuntimeBinding,
|
||||
SidecarContext,
|
||||
SidecarWebSocketClient,
|
||||
} from "./types";
|
||||
import { LOCAL_ENVIRONMENT_ID } from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
const approvalReadinessUpdates = new WeakMap<SidecarContext, Promise<void>>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -69,76 +61,10 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
client.send(encoded);
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, client);
|
||||
void syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sendEventToClient(
|
||||
ctx: SidecarContext,
|
||||
client: SidecarWebSocketClient,
|
||||
name: string,
|
||||
payload: unknown,
|
||||
): boolean {
|
||||
try {
|
||||
client.send(encodeSidecarEvent(name, payload));
|
||||
return true;
|
||||
} catch {
|
||||
ctx.wsClients.delete(client);
|
||||
cancelSidecarToolApprovalsForOwner(ctx, client);
|
||||
void syncSidecarApprovalReadiness(ctx).catch((error) =>
|
||||
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelSidecarToolApprovalsForOwner(
|
||||
ctx: SidecarContext,
|
||||
owner: SidecarWebSocketClient,
|
||||
): void {
|
||||
for (const [requestId, pending] of ctx.pendingApprovals) {
|
||||
if (pending.owner !== owner) continue;
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
pending.resolve({
|
||||
approved: false,
|
||||
reason: "Desktop approval surface disconnected",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncSidecarApprovalReadiness(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
|
||||
const update = previous.catch(() => undefined).then(async () => {
|
||||
const hubClient = ctx.hubClient;
|
||||
if (!hubClient) return;
|
||||
await hubClient.updateCapabilities(
|
||||
[...ctx.wsClients].some(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
)
|
||||
? [
|
||||
{
|
||||
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
|
||||
description:
|
||||
"Cline Code has a live user surface for tool review.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
approvalReadinessUpdates.set(ctx, update);
|
||||
return update.finally(() => {
|
||||
if (approvalReadinessUpdates.get(ctx) === update) {
|
||||
approvalReadinessUpdates.delete(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Session log appends are chained per session so writes stay ordered, but
|
||||
// they run asynchronously: a synchronous write per streamed token would stall
|
||||
// the sidecar event loop (and therefore every pending UI command) under load.
|
||||
@@ -241,7 +167,7 @@ export function serializeQueuedPromptStart(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(
|
||||
export function sendPromptsInQueueSnapshot(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
): void {
|
||||
@@ -438,7 +364,7 @@ function emitQueuedPromptStart(
|
||||
);
|
||||
}
|
||||
|
||||
export function handleCoreSessionEvent(
|
||||
function handleCoreSessionEvent(
|
||||
ctx: SidecarContext,
|
||||
event: CoreSessionEvent,
|
||||
): void {
|
||||
@@ -577,12 +503,15 @@ export function createSidecarContext(
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
runtimeBindings: new Map(),
|
||||
sessionEnvironmentIds: new Map(),
|
||||
activeEnvironmentId: LOCAL_ENVIRONMENT_ID,
|
||||
remoteEnvironments: null,
|
||||
localWorkspaceRoot: workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
unsubscribeSessionEvents: null,
|
||||
cloudSessionManager: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
}
|
||||
@@ -592,9 +521,7 @@ export async function disposeSidecarContext(
|
||||
reason = "code_sidecar_shutdown",
|
||||
): Promise<void> {
|
||||
const cleanup: Array<Promise<unknown>> = [];
|
||||
|
||||
ctx.unsubscribeSessionEvents?.();
|
||||
ctx.unsubscribeSessionEvents = null;
|
||||
const approvalCleanup: Array<Promise<unknown>> = [];
|
||||
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
@@ -610,7 +537,21 @@ export async function disposeSidecarContext(
|
||||
}
|
||||
ctx.wsClients.clear();
|
||||
for (const pending of ctx.pendingApprovals.values()) {
|
||||
pending.resolve({ approved: false, reason });
|
||||
// Cloud sessions outlive this app: denying their approvals on local
|
||||
// shutdown would fail a tool call on a pod that keeps running and
|
||||
// could otherwise be answered later (from here or another surface).
|
||||
// Drop those entries locally and leave the remote approval pending.
|
||||
if (ctx.cloudSessionManager?.isCloudSession(pending.item.sessionId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
approvalCleanup.push(
|
||||
Promise.resolve(pending.resolve({ approved: false, reason })),
|
||||
);
|
||||
} catch (error) {
|
||||
// Keep disposing the remaining resources, then preserve the failure.
|
||||
approvalCleanup.push(Promise.reject(error));
|
||||
}
|
||||
}
|
||||
ctx.pendingApprovals.clear();
|
||||
for (const pending of ctx.pendingQuestions.values()) {
|
||||
@@ -618,24 +559,28 @@ export async function disposeSidecarContext(
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
ctx.pendingQuestions.clear();
|
||||
// Approval callbacks may need the Hub/cloud clients that are disposed below.
|
||||
const approvalResults = await Promise.allSettled(approvalCleanup);
|
||||
|
||||
const hubClient = ctx.hubClient;
|
||||
ctx.hubClient = null;
|
||||
if (hubClient) {
|
||||
cleanup.push(hubClient.dispose());
|
||||
const cloudSessionManager = ctx.cloudSessionManager;
|
||||
ctx.cloudSessionManager = null;
|
||||
if (cloudSessionManager) {
|
||||
cleanup.push(cloudSessionManager.dispose());
|
||||
}
|
||||
|
||||
const sessionManager = ctx.sessionManager;
|
||||
ctx.sessionManager = null;
|
||||
if (sessionManager) {
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
for (const binding of ctx.runtimeBindings?.values() ?? []) {
|
||||
binding.unsubscribeSessionEvents();
|
||||
cleanup.push(binding.hubClient.dispose());
|
||||
cleanup.push(binding.sessionManager.dispose(reason));
|
||||
}
|
||||
ctx.runtimeBindings?.clear();
|
||||
ctx.sessionEnvironmentIds?.clear();
|
||||
if (ctx.remoteEnvironments) {
|
||||
cleanup.push(ctx.remoteEnvironments.dispose());
|
||||
ctx.remoteEnvironments = null;
|
||||
}
|
||||
|
||||
// Shuts down the PostHog client the feature flags service owns, flushing
|
||||
// any pending $feature_flag_called events.
|
||||
cleanup.push(disposeDesktopFeatureFlagsService());
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const results = [...approvalResults, ...(await Promise.allSettled(cleanup))];
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
);
|
||||
@@ -661,12 +606,6 @@ export function requestSidecarAskQuestion(
|
||||
options: string[],
|
||||
context: AgentToolContext,
|
||||
): Promise<string> {
|
||||
const sessionId = context.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
return Promise.reject(
|
||||
new Error("ask_question requires an active session ID"),
|
||||
);
|
||||
}
|
||||
const choices = options
|
||||
.map((option) => option.trim())
|
||||
.filter((option) => option.length > 0)
|
||||
@@ -692,7 +631,6 @@ export function requestSidecarAskQuestion(
|
||||
const pending: PendingAskQuestion = {
|
||||
item: {
|
||||
requestId,
|
||||
sessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
question,
|
||||
options: choices,
|
||||
@@ -738,15 +676,6 @@ function requestSidecarToolApproval(
|
||||
ctx: SidecarContext,
|
||||
request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> {
|
||||
const owner = [...ctx.wsClients].find(
|
||||
(client) => client.data?.canApproveTools === true,
|
||||
);
|
||||
if (!owner) {
|
||||
return Promise.resolve({
|
||||
approved: false,
|
||||
reason: "No trusted desktop approval surface is connected",
|
||||
});
|
||||
}
|
||||
return new Promise<ToolApprovalResult>((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const pending: PendingToolApproval = {
|
||||
@@ -761,25 +690,16 @@ function requestSidecarToolApproval(
|
||||
agentId: request.agentId,
|
||||
conversationId: request.conversationId,
|
||||
},
|
||||
owner,
|
||||
resolve,
|
||||
};
|
||||
ctx.pendingApprovals.set(requestId, pending);
|
||||
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
|
||||
.filter(
|
||||
(approval) =>
|
||||
approval.owner === owner &&
|
||||
approval.item.sessionId === request.sessionId,
|
||||
)
|
||||
.filter((approval) => approval.item.sessionId === request.sessionId)
|
||||
.map((approval) => approval.item);
|
||||
if (
|
||||
!sendEventToClient(ctx, owner, "tool_approval_state", {
|
||||
sessionId: request.sessionId,
|
||||
items: sessionApprovals,
|
||||
})
|
||||
) {
|
||||
cancelSidecarToolApprovalsForOwner(ctx, owner);
|
||||
}
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId: request.sessionId,
|
||||
items: sessionApprovals,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -790,26 +710,8 @@ export function handleHubLiveEvent(
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
options: { relayRawAssistantText?: boolean } = {},
|
||||
): void {
|
||||
if (event.event === "approval.requested") {
|
||||
if (typeof event.payload?.agendaTaskId !== "string") return;
|
||||
void handleHubApprovalRequest(ctx, event).catch((error) => {
|
||||
ctx.logger?.error?.("Hub task approval forwarding failed", { error });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Task lifecycle events are Hub-wide invalidations and usually do not have a
|
||||
// session yet (pending and approved tasks explicitly predate their session).
|
||||
// Forward them before the session-only live-chat projection below so Agenda
|
||||
// surfaces stay current without polling.
|
||||
if (event.event.startsWith("task.")) {
|
||||
sendEvent(ctx, event.event, {
|
||||
...(event.payload ?? {}),
|
||||
...(event.sessionId ? { sessionId: event.sessionId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
return;
|
||||
@@ -821,13 +723,23 @@ export function handleHubLiveEvent(
|
||||
|
||||
switch (event.event) {
|
||||
case "assistant.delta": {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
if (text) {
|
||||
emitChunk(ctx, sessionId, "chat_text", text);
|
||||
if (options.relayRawAssistantText) {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
if (text) emitChunk(ctx, sessionId, "chat_text", text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "assistant.image":
|
||||
case "assistant.video":
|
||||
case "assistant.audio":
|
||||
case "reasoning.delta":
|
||||
case "tool.started":
|
||||
case "tool.finished":
|
||||
// HubRuntimeHost already projects these into the canonical Core event
|
||||
// stream consumed by handleCoreSessionEvent. Relaying the raw Hub copy
|
||||
// too duplicates assistant output and tool activity.
|
||||
return;
|
||||
case "assistant.media": {
|
||||
const media = event.payload?.media;
|
||||
if (isGeneratedMedia(media)) {
|
||||
@@ -835,80 +747,84 @@ export function handleHubLiveEvent(
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "reasoning.delta": {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
const redacted = event.payload?.redacted === true;
|
||||
if (!text && !redacted) {
|
||||
case "usage.updated": {
|
||||
const delta =
|
||||
event.payload?.delta &&
|
||||
typeof event.payload.delta === "object" &&
|
||||
!Array.isArray(event.payload.delta)
|
||||
? (event.payload.delta as Record<string, unknown>)
|
||||
: {};
|
||||
const totals =
|
||||
event.payload?.totals &&
|
||||
typeof event.payload.totals === "object" &&
|
||||
!Array.isArray(event.payload.totals)
|
||||
? (event.payload.totals as Record<string, unknown>)
|
||||
: {};
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_usage",
|
||||
JSON.stringify({
|
||||
inputTokens: delta.inputTokens,
|
||||
outputTokens: delta.outputTokens,
|
||||
cacheReadTokens: delta.cacheReadTokens,
|
||||
cacheWriteTokens: delta.cacheWriteTokens,
|
||||
cost: delta.totalCost,
|
||||
totalInputTokens: totals.inputTokens,
|
||||
totalOutputTokens: totals.outputTokens,
|
||||
totalCost: totals.totalCost,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "session.pending_prompts": {
|
||||
const items = Array.isArray(event.payload?.prompts)
|
||||
? (event.payload.prompts as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const mapped: PromptInQueue[] = items
|
||||
.map((item) => ({
|
||||
id: typeof item.id === "string" ? item.id : "",
|
||||
prompt: typeof item.prompt === "string" ? item.prompt : "",
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount:
|
||||
typeof item.attachmentCount === "number" ? item.attachmentCount : 0,
|
||||
userImages: Array.isArray(item.userImages)
|
||||
? (item.userImages as string[])
|
||||
: undefined,
|
||||
}))
|
||||
.filter((item) => item.id && (item.prompt || item.attachmentCount > 0));
|
||||
reconcileQueuedAttachments(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
// No "head submitted" inference here, unlike the local queue-drain
|
||||
// handler: the hub emits an explicit session.pending_prompt_submitted
|
||||
// for real submissions, and a snapshot can also shrink because a
|
||||
// prompt was REMOVED — inferring a start would render the deleted
|
||||
// prompt in the transcript as if it had been sent.
|
||||
session.promptsInQueue = mapped;
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return;
|
||||
}
|
||||
case "session.pending_prompt_submitted": {
|
||||
const item =
|
||||
event.payload?.prompt && typeof event.payload.prompt === "object"
|
||||
? (event.payload.prompt as Record<string, unknown>)
|
||||
: undefined;
|
||||
const promptId = typeof item?.id === "string" ? item.id : "";
|
||||
if (!promptId) {
|
||||
return;
|
||||
}
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_reasoning",
|
||||
JSON.stringify({ text, redacted }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.started": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_start",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
input: event.payload?.input,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.updated": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_update",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
update: event.payload?.update,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.finished": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_end",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
output: event.payload?.output,
|
||||
error:
|
||||
typeof event.payload?.error === "string"
|
||||
? event.payload.error
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
markQueuedAttachmentsSubmitted(session, promptId);
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId,
|
||||
prompt: typeof item?.prompt === "string" ? item.prompt : "",
|
||||
attachmentCount:
|
||||
typeof item?.attachmentCount === "number" ? item.attachmentCount : 0,
|
||||
userImages: Array.isArray(item?.userImages)
|
||||
? (item.userImages as string[])
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "run.started":
|
||||
@@ -920,20 +836,43 @@ export function handleHubLiveEvent(
|
||||
!Array.isArray(event.payload.session)
|
||||
? (event.payload.session as Record<string, unknown>)
|
||||
: undefined;
|
||||
const status =
|
||||
const runtimeStatus =
|
||||
typeof payloadSession?.status === "string"
|
||||
? payloadSession.status
|
||||
: event.event === "run.started"
|
||||
? "running"
|
||||
: session.status;
|
||||
// Hub "pending" means the run is blocked on approval or otherwise
|
||||
// still active. Desktop has no pending status, so expose it as running
|
||||
// and keep later prompts on the queue path.
|
||||
const status = runtimeStatus === "pending" ? "running" : runtimeStatus;
|
||||
// Pods emit periodic session.updated snapshots; re-broadcasting an
|
||||
// unchanged status marks the session unread in the sidebar every time.
|
||||
const statusChanged = session.status !== status;
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
if (statusChanged) {
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "run.completed":
|
||||
case "run.failed":
|
||||
case "run.aborted": {
|
||||
// A failed run carries its reason in payload.error — surface it, or
|
||||
// the user sees a silent no-op (e.g. "Insufficient balance").
|
||||
const errorMessage =
|
||||
event.event === "run.failed" && typeof event.payload?.error === "string"
|
||||
? event.payload.error.trim()
|
||||
: "";
|
||||
if (errorMessage) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({ level: "error", message: errorMessage }),
|
||||
);
|
||||
}
|
||||
const reason =
|
||||
typeof event.payload?.reason === "string"
|
||||
? event.payload.reason
|
||||
@@ -953,72 +892,6 @@ export function handleHubLiveEvent(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHubApprovalRequest(
|
||||
ctx: SidecarContext,
|
||||
event: {
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const sessionId = event.sessionId?.trim() || "";
|
||||
const approvalId =
|
||||
typeof event.payload?.approvalId === "string"
|
||||
? event.payload.approvalId.trim()
|
||||
: "";
|
||||
const toolCallId =
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId.trim()
|
||||
: "";
|
||||
const toolName =
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName.trim()
|
||||
: "";
|
||||
if (!sessionId || !approvalId || !toolCallId || !toolName) return;
|
||||
let input: unknown;
|
||||
try {
|
||||
input =
|
||||
typeof event.payload?.inputJson === "string"
|
||||
? JSON.parse(event.payload.inputJson)
|
||||
: undefined;
|
||||
} catch {
|
||||
input = undefined;
|
||||
}
|
||||
const result = await requestSidecarToolApproval(ctx, {
|
||||
sessionId,
|
||||
agentId:
|
||||
typeof event.payload?.agentId === "string" ? event.payload.agentId : "",
|
||||
conversationId:
|
||||
typeof event.payload?.conversationId === "string"
|
||||
? event.payload.conversationId
|
||||
: sessionId,
|
||||
iteration:
|
||||
typeof event.payload?.iteration === "number"
|
||||
? event.payload.iteration
|
||||
: 0,
|
||||
toolCallId,
|
||||
toolName,
|
||||
input,
|
||||
policy:
|
||||
event.payload?.policy &&
|
||||
typeof event.payload.policy === "object" &&
|
||||
!Array.isArray(event.payload.policy)
|
||||
? (event.payload.policy as ToolApprovalRequest["policy"])
|
||||
: { autoApprove: false },
|
||||
});
|
||||
const client = ctx.hubClient;
|
||||
if (!client)
|
||||
throw new Error("Hub client disconnected before approval response");
|
||||
await client.command(
|
||||
"approval.respond",
|
||||
{
|
||||
approvalId,
|
||||
approved: result.approved,
|
||||
reason: result.reason,
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
@@ -1029,16 +902,12 @@ export async function initializeSessionManager(
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
featureFlags: getDesktopFeatureFlagsService({
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
}),
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Cline Desktop sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1047,24 +916,186 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
|
||||
let hubClient: NodeHubClient;
|
||||
try {
|
||||
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
hubClient = await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
|
||||
environmentId: LOCAL_ENVIRONMENT_ID,
|
||||
kind: "local",
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
sessionManager,
|
||||
hubClient,
|
||||
unsubscribeSessionEvents: unsubscribe,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRuntimeBinding(
|
||||
ctx: SidecarContext,
|
||||
environmentId = ctx.activeEnvironmentId,
|
||||
): SessionRuntimeBinding {
|
||||
const binding = ctx.runtimeBindings.get(environmentId);
|
||||
if (!binding) {
|
||||
throw new Error(`Environment ${environmentId} is not connected.`);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function getSessionRuntimeBinding(
|
||||
ctx: SidecarContext,
|
||||
sessionId?: string,
|
||||
requestedEnvironmentId?: string,
|
||||
): SessionRuntimeBinding {
|
||||
const environmentId =
|
||||
requestedEnvironmentId?.trim() ||
|
||||
(sessionId ? ctx.liveSessions.get(sessionId)?.environmentId : undefined) ||
|
||||
(sessionId ? ctx.sessionEnvironmentIds.get(sessionId) : undefined) ||
|
||||
ctx.activeEnvironmentId;
|
||||
return getRuntimeBinding(ctx, environmentId);
|
||||
}
|
||||
|
||||
export async function findSessionRuntimeBinding(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
preferredEnvironmentId?: string,
|
||||
): Promise<SessionRuntimeBinding | undefined> {
|
||||
const knownEnvironmentId =
|
||||
preferredEnvironmentId?.trim() ||
|
||||
ctx.liveSessions.get(sessionId)?.environmentId ||
|
||||
ctx.sessionEnvironmentIds.get(sessionId);
|
||||
const candidates = [
|
||||
...(knownEnvironmentId
|
||||
? [ctx.runtimeBindings.get(knownEnvironmentId)]
|
||||
: []),
|
||||
...ctx.runtimeBindings.values(),
|
||||
].filter(
|
||||
(binding, index, all): binding is SessionRuntimeBinding =>
|
||||
Boolean(binding) && all.indexOf(binding) === index,
|
||||
);
|
||||
for (const binding of candidates) {
|
||||
try {
|
||||
if (await binding.sessionManager.get(sessionId)) {
|
||||
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
|
||||
return binding;
|
||||
}
|
||||
} catch {
|
||||
// A disconnected environment must not prevent another runtime from
|
||||
// resolving the session.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function disposeRuntimeBinding(
|
||||
binding: SessionRuntimeBinding,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
binding.unsubscribeSessionEvents();
|
||||
} catch {
|
||||
// Continue disposing the Hub clients even if an event source has already
|
||||
// torn down its subscription.
|
||||
}
|
||||
await Promise.allSettled([
|
||||
binding.hubClient.dispose(),
|
||||
binding.sessionManager.dispose(reason),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function connectRemoteSessionRuntime(
|
||||
ctx: SidecarContext,
|
||||
connection: NonNullable<SessionRuntimeBinding["remote"]>,
|
||||
): Promise<SessionRuntimeBinding> {
|
||||
const environmentId = connection.profile.id;
|
||||
const existing = ctx.runtimeBindings.get(environmentId);
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "remote",
|
||||
capabilities: createSidecarRuntimeCapabilities(ctx),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
remote: {
|
||||
endpoint: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
clientType: "code-sidecar-ssh",
|
||||
displayName: `Code App (${connection.profile.name})`,
|
||||
},
|
||||
});
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let hubClient: NodeHubClient | undefined;
|
||||
try {
|
||||
unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
hubClient = new NodeHubClient({
|
||||
url: connection.endpoint,
|
||||
authToken: connection.authToken,
|
||||
clientType: "code-sidecar-ssh-observer",
|
||||
displayName: `Code App observer (${connection.profile.name})`,
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
cwd: connection.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) => handleHubLiveEvent(ctx, event));
|
||||
} catch (error) {
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch {
|
||||
// Best effort; the failed runtime still needs to be disposed below.
|
||||
}
|
||||
const disposals: Promise<unknown>[] = [
|
||||
sessionManager.dispose("code_sidecar_remote_initialization_failed"),
|
||||
];
|
||||
if (hubClient) disposals.push(hubClient.dispose());
|
||||
await Promise.allSettled(disposals);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const binding: SessionRuntimeBinding = {
|
||||
environmentId,
|
||||
kind: "ssh",
|
||||
workspaceRoot: connection.workspaceRoot,
|
||||
sessionManager,
|
||||
hubClient,
|
||||
unsubscribeSessionEvents: unsubscribe,
|
||||
remote: connection,
|
||||
};
|
||||
ctx.runtimeBindings.set(environmentId, binding);
|
||||
ctx.activeEnvironmentId = environmentId;
|
||||
if (existing) {
|
||||
await disposeRuntimeBinding(existing, "code_sidecar_remote_reconnect");
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export async function disconnectRemoteSessionRuntime(
|
||||
ctx: SidecarContext,
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
const binding = ctx.runtimeBindings.get(environmentId);
|
||||
if (binding?.kind === "ssh") {
|
||||
ctx.runtimeBindings.delete(environmentId);
|
||||
await disposeRuntimeBinding(binding, "code_sidecar_remote_disconnect");
|
||||
}
|
||||
if (ctx.activeEnvironmentId === environmentId) {
|
||||
ctx.activeEnvironmentId = LOCAL_ENVIRONMENT_ID;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSharedHubClient(
|
||||
ctx: SidecarContext,
|
||||
preferredUrl?: string,
|
||||
): Promise<NodeHubClient> {
|
||||
if (ctx.hubClient) {
|
||||
return ctx.hubClient;
|
||||
const existing = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const pending = hubClientInitialization.get(ctx);
|
||||
if (pending) {
|
||||
@@ -1076,8 +1107,8 @@ export async function ensureSharedHubClient(
|
||||
preferredUrl?.trim() ||
|
||||
(await ensureCompatibleLocalHubUrl({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
}));
|
||||
if (!url) {
|
||||
throw new Error("Unable to start or connect to the shared Cline Hub.");
|
||||
@@ -1086,17 +1117,15 @@ export async function ensureSharedHubClient(
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Cline Desktop observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
await syncSidecarApprovalReadiness(ctx);
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
readDesktopSettings,
|
||||
resolveDesktopSettingsPath,
|
||||
setCloudSessionsEnabled,
|
||||
} from "./desktop-settings";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("desktop settings", () => {
|
||||
it("defaults cloud sessions to off when no settings file exists", () => {
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("persists the cloud sessions opt-in and reads it back", () => {
|
||||
expect(setCloudSessionsEnabled(true)).toEqual({
|
||||
cloudSessionsEnabled: true,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
|
||||
expect(setCloudSessionsEnabled(false)).toEqual({
|
||||
cloudSessionsEnabled: false,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("writes into the desktop-owned settings file, not global-settings", () => {
|
||||
setCloudSessionsEnabled(true);
|
||||
const raw = JSON.parse(
|
||||
readFileSync(resolveDesktopSettingsPath(), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(raw.cloudSessionsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("treats malformed files and non-boolean values as off", () => {
|
||||
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
|
||||
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
writeFileSync(
|
||||
resolveDesktopSettingsPath(),
|
||||
JSON.stringify({ cloudSessionsEnabled: "yes" }),
|
||||
"utf8",
|
||||
);
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user