mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd218aa7bb | ||
|
|
80b3b0348e | ||
|
|
401cf5e12a | ||
|
|
48feb02ae9 | ||
|
|
eef7958cad | ||
|
|
04e08187f3 | ||
|
|
fed502e3cf | ||
|
|
e685ebfd04 | ||
|
|
9b9a067fb8 | ||
|
|
8fe5a196c4 | ||
|
|
e70b3ffc4b | ||
|
|
e6f4d5fef7 | ||
|
|
16875140fb | ||
|
|
7903c76812 | ||
|
|
b060cecc05 | ||
|
|
4d1bafc443 | ||
|
|
74a8e06e41 | ||
|
|
94beb6c5f1 | ||
|
|
8720363ba3 | ||
|
|
f291a269a6 | ||
|
|
06013b9c08 | ||
|
|
c14cc2c696 | ||
|
|
0a9f45a9c6 | ||
|
|
4a63821d57 | ||
|
|
ff14ab601f | ||
|
|
a8841bf96c | ||
|
|
878faf0a95 | ||
|
|
36397f47eb | ||
|
|
3f0c5cdc92 | ||
|
|
d9bb22883d | ||
|
|
398c1a1b8f | ||
|
|
8a64372b54 | ||
|
|
dfa34ecea8 | ||
|
|
98d3e52a02 | ||
|
|
f80e6a5df8 | ||
|
|
9cf60cd43a | ||
|
|
2fd8d0383a | ||
|
|
411282296c | ||
|
|
3705aec28f | ||
|
|
67e5115b85 | ||
|
|
61b95a62ee | ||
|
|
6cc0328424 | ||
|
|
90b32cb41d | ||
|
|
3c433d5a90 | ||
|
|
76ac1c7f55 | ||
|
|
be56c505e4 | ||
|
|
3f9c9c3f33 | ||
|
|
cf07572a07 | ||
|
|
a5ac26f279 | ||
|
|
508a5322af | ||
|
|
38f8260bc3 | ||
|
|
eeaed357ef | ||
|
|
bca9b64206 | ||
|
|
8a038022a4 | ||
|
|
c2e293e293 | ||
|
|
b9efa96826 | ||
|
|
d4b415f8ab | ||
|
|
6b3f034bce | ||
|
|
05a6974ef8 | ||
|
|
5ad2dd5fc8 | ||
|
|
87be867599 | ||
|
|
26cb0ec9eb | ||
|
|
eed78103a4 | ||
|
|
456f86bb3a | ||
|
|
e86d988234 | ||
|
|
041afb718b | ||
|
|
8bbdde2a5c | ||
|
|
da05eeb02d | ||
|
|
2be49cf91b | ||
|
|
b851cd86d1 | ||
|
|
3e0aac53a2 | ||
|
|
ad442cbb6a | ||
|
|
225f65cc0e | ||
|
|
8a619a9ea6 | ||
|
|
2e46676952 | ||
|
|
102e08f5f4 | ||
|
|
942f76e8e7 | ||
|
|
57eb181bab | ||
|
|
63e9c99031 | ||
|
|
afab68f3a3 | ||
|
|
d3d3bd8749 | ||
|
|
afab86dcbd | ||
|
|
6d7e745fb7 | ||
|
|
fcd5a9e0fb | ||
|
|
c8afb44368 | ||
|
|
d30cce4cf1 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"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,28 +1,34 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
|
||||
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.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
|
||||
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.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
|
||||
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
|
||||
## 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`.
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (universal DMG + updater artifact + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- 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.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
|
||||
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
## Workflow
|
||||
|
||||
0. Ask which channel this release is for — **stable or beta** — if the user has not said. Everything below branches on it; never guess.
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
@@ -35,10 +41,15 @@ node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').versio
|
||||
|
||||
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
|
||||
|
||||
For a **beta** release, work on `desktop-experimental` (check out `origin/desktop-experimental`; merge `origin/main` into it first if it is behind — see EXPERIMENTAL.md for the conflict policy) and read the version files from that branch. The last-tag baseline is the newest `desktop-v*` tag of either channel that is an ancestor of the branch.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
# stable (on main):
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
# beta (on desktop-experimental):
|
||||
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
```
|
||||
|
||||
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
@@ -49,13 +60,15 @@ Flat bullet list, user-facing language. Present the draft and wait for approval
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
Stable: ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
Beta: apply the versioning rule — base = next stable version, increment `N` (`0.0.14-beta.1` → `0.0.14-beta.2`; after stable `0.0.14` ships, next is `0.0.15-beta.1`). Confirm the computed version with the user.
|
||||
|
||||
5. Update release files (on `main` for stable, on `desktop-experimental` for beta).
|
||||
|
||||
- `apps/examples/desktop-app/package.json` → new version
|
||||
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
|
||||
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
|
||||
- Prepend `## X.Y.Z` (no date; `## X.Y.Z-beta.N` for beta) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
@@ -77,16 +90,20 @@ Ask before pushing the release commit, then before creating and pushing the tag:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
|
||||
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z" # beta: desktop-vX.Y.Z-beta.N / "Desktop vX.Y.Z-beta.N"
|
||||
git push origin refs/tags/desktop-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
The release commit must be on `main` and the tag pushed first.
|
||||
The release commit must be on the channel's branch (`main` for stable, `desktop-experimental` for beta) and the tag pushed first. Dispatch from `main` for **both** channels (see the release contract for why).
|
||||
|
||||
```sh
|
||||
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
|
||||
# stable:
|
||||
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z -f channel=stable -f confirm_publish=publish
|
||||
# beta:
|
||||
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z-beta.N -f channel=beta -f confirm_publish=publish
|
||||
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
@@ -103,21 +120,24 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`), verifies every Mach-O in the bundle carries both slices, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
```sh
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30 # stable
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new `desktop-vX.Y.Z` universal `.app.tar.gz` asset (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
Report: channel, version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
|
||||
## Publish secrets (one-time setup)
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@ 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 legacy-ref=legacy-extension -f publish=true
|
||||
-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)
|
||||
# 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.
|
||||
@@ -156,7 +158,9 @@ 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 -f branch=legacy-extension
|
||||
-f release-type=release
|
||||
# (the branch is hardcoded to legacy-extension in the workflow; it is
|
||||
# deliberately not an input)
|
||||
```
|
||||
|
||||
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,6 +206,8 @@ 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)
|
||||
@@ -213,6 +215,32 @@ 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:
|
||||
@@ -248,7 +276,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -11,6 +11,14 @@ on:
|
||||
description: 'Type "publish" to confirm the desktop release.'
|
||||
required: true
|
||||
type: string
|
||||
channel:
|
||||
description: "Release channel"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- beta
|
||||
default: stable
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,6 +38,9 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
feed: ${{ steps.version.outputs.feed }}
|
||||
product: ${{ steps.version.outputs.product }}
|
||||
steps:
|
||||
# Companion to the presence check in `build`, and the half that actually
|
||||
# establishes scope. This job declares no environment, so a signing secret
|
||||
@@ -81,11 +92,41 @@ jobs:
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
# inputs.* (not github.event.inputs.*) so the declared default
|
||||
# applies when an API dispatch omits the channel input entirely.
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
# Fail-closed channel mapping: every channel defines its tag shape,
|
||||
# its ancestry source, its feed, and its product name, and an unknown
|
||||
# channel dies here. The feed assignment is the load-bearing one —
|
||||
# the updater comparator is a plain semver "newer than", so a beta
|
||||
# manifest landing on desktop-latest would auto-update every stable
|
||||
# install onto the beta. The stable regex rejects prerelease
|
||||
# suffixes for the same reason.
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "stable git_tag must look like desktop-vX.Y.Z with no suffix, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
ANCESTOR_REF=main
|
||||
FEED=desktop-latest
|
||||
PRODUCT="Cline"
|
||||
;;
|
||||
beta)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
|
||||
echo "beta git_tag must look like desktop-vX.Y.Z-beta.N, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
ANCESTOR_REF=desktop-experimental
|
||||
FEED=desktop-beta
|
||||
PRODUCT="Cline Beta"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
VERSION="${TAG#desktop-v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
|
||||
@@ -108,14 +149,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin +main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
|
||||
echo "${TAG} is not reachable from origin/main"
|
||||
git fetch origin "+${ANCESTOR_REF}:refs/remotes/origin/${ANCESTOR_REF}"
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" "origin/${ANCESTOR_REF}"; then
|
||||
echo "${TAG} is not reachable from origin/${ANCESTOR_REF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
echo "feed=${FEED}" >> "$GITHUB_OUTPUT"
|
||||
echo "product=${PRODUCT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (universal)
|
||||
@@ -126,6 +170,25 @@ jobs:
|
||||
# run. Defense in depth: this `if` is advisory because a dispatched branch
|
||||
# runs its own copy of this file; the enforced gate is the PublishDesktop
|
||||
# environment's deployment-branch policy, which must also allow only main.
|
||||
#
|
||||
# Beta releases do not weaken this: a beta publish is ALSO dispatched from
|
||||
# main (so this gate, the branch policy, and the workflow file executed all
|
||||
# stay main's) — only the checked-out tag points into desktop-experimental,
|
||||
# which validate pins via the ancestry check. A workflow copy edited on
|
||||
# desktop-experimental can therefore never reach the signing secrets.
|
||||
#
|
||||
# What dispatch-from-main does NOT protect: the checked-out tag's own
|
||||
# build scripts (bun install hooks, build:sdk, Tauri's beforeBuildCommand,
|
||||
# build.rs) run inside this job with the signing secrets in scope, for
|
||||
# stable and beta alike. The control for that is this environment's
|
||||
# required-reviewer approval — the approver is vouching for the code the
|
||||
# tag points at, not just for "a release happening". Two consequences:
|
||||
# desktop-experimental must keep main-grade merge controls (branch
|
||||
# protection, maintainer-only pushes), and an approval should only follow
|
||||
# a look at what the tag actually contains. Building betas without these
|
||||
# secrets is not an option: unsigned bundles fail Gatekeeper and updater
|
||||
# artifacts must be signed with the same key or beta installs cannot
|
||||
# verify their updates.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: macos-latest
|
||||
@@ -223,8 +286,13 @@ jobs:
|
||||
|
||||
- name: Build, sign, and notarize desktop bundle
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json
|
||||
# Tauri merges repeated --config flags in order, so the beta overlay
|
||||
# (product name, bundle identifier, beta update feed) layers on top of
|
||||
# the release overlay without duplicating it. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
|
||||
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
|
||||
# this step and inlines these values into the binary via `--define`
|
||||
@@ -258,8 +326,10 @@ jobs:
|
||||
# sidecar would otherwise ship fine and only crash on the other arch.
|
||||
- name: Verify bundle is a universal binary
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Cline Code.app"
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
|
||||
if [ ! -d "$APP" ]; then
|
||||
echo "app bundle not found at $APP"
|
||||
exit 1
|
||||
@@ -276,6 +346,57 @@ jobs:
|
||||
esac
|
||||
done
|
||||
|
||||
# Guardrail: the updater endpoint is compiled into the main binary as a
|
||||
# string literal (tauri-build embeds the merged config via codegen), so
|
||||
# assert the bundle carries this channel's feed URL and not the other
|
||||
# channel's, before anything gets signed into a release. This catches a
|
||||
# --config overlay that silently failed to apply: a beta bundle polling
|
||||
# desktop-latest would pull its users onto stable builds, and a stable
|
||||
# bundle polling desktop-beta would push betas to every stable install.
|
||||
- name: Verify updater feed endpoint
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Plain grep >/dev/null rather than grep -q: -q exits at the first
|
||||
# match, SIGPIPEs strings, and would read as a failed pipeline under
|
||||
# pipefail.
|
||||
found=0
|
||||
for bin in "$APP/Contents/MacOS/"*; do
|
||||
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if strings -a "$bin" | grep "$WANT" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "No binary in ${APP}/Contents/MacOS embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
|
||||
# Guardrail: assert the telemetry config actually made it into the
|
||||
# compiled sidecar. Missing env on the build step (or a regression in
|
||||
# the --define inlining) would otherwise ship a release with telemetry
|
||||
@@ -308,25 +429,29 @@ jobs:
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/Cline-Code_${VERSION}_universal.dmg"
|
||||
cp "$DMG" "$OUT/${PREFIX}_${VERSION}_universal.dmg"
|
||||
|
||||
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
|
||||
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz.sig"
|
||||
cp "$TARBALL" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
@@ -364,14 +489,50 @@ jobs:
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
# Grab content between this release's "## <version>" header and the
|
||||
# next one. Exact match, not "first section": once main and
|
||||
# desktop-experimental cross-merge, stable and beta sections
|
||||
# interleave and the top section may belong to the other channel.
|
||||
CONTENT=$(awk -v ver="$VERSION" '$0 == "## " ver {found=1; next} /^## [0-9]/ {if (found) exit} found {print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
if [ -z "$CONTENT" ]; then
|
||||
echo "No '## ${VERSION}' section found in apps/examples/desktop-app/CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
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 }}
|
||||
@@ -390,8 +551,15 @@ jobs:
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
# Stable compare links skip beta tags so they read stable -> stable;
|
||||
# beta compares against whatever shipped last on either channel.
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-beta*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
else
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
@@ -402,6 +570,7 @@ jobs:
|
||||
# The repo-wide "latest" release stays owned by CLI releases; the
|
||||
# desktop auto-update feed is the rolling desktop-latest release.
|
||||
make_latest: "false"
|
||||
prerelease: ${{ needs.validate.outputs.channel == 'beta' }}
|
||||
files: dist/desktop/*
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
@@ -410,27 +579,58 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update auto-update feed (desktop-latest)
|
||||
- name: Update auto-update feed
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
if ! gh release view desktop-latest >/dev/null 2>&1; then
|
||||
gh release create desktop-latest \
|
||||
--title "Cline Code desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
# Belt and braces: recompute the feed from the channel and require it
|
||||
# to agree with validate's output, so no single threading bug can
|
||||
# point a publish at the other channel's feed. Stable installs poll
|
||||
# desktop-latest and beta installs poll desktop-beta; crossing the
|
||||
# streams either pushes betas to every stable user or strands beta
|
||||
# users on stale builds.
|
||||
case "$CHANNEL" in
|
||||
stable) EXPECTED_FEED=desktop-latest ;;
|
||||
beta) EXPECTED_FEED=desktop-beta ;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$FEED" != "$EXPECTED_FEED" ]; then
|
||||
echo "feed mismatch: validate says '${FEED}' but channel '${CHANNEL}' expects '${EXPECTED_FEED}'"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload desktop-latest dist/desktop/latest.json --clobber
|
||||
|
||||
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)" \
|
||||
--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)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
fi
|
||||
fi
|
||||
gh release upload "$FEED" dist/desktop/latest.json --clobber
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
echo "Published Cline Code desktop v${VERSION}"
|
||||
echo "Published Cline desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
@@ -439,17 +639,17 @@ jobs:
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — ${{ needs.validate.outputs.channel == 'beta' && 'beta channel: installs side by side with the stable app and only beta installs auto-update; stable users are unaffected' || 'installed apps auto-update on next launch' }}${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
|
||||
|
||||
@@ -23,11 +23,6 @@ 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
|
||||
@@ -130,31 +125,37 @@ 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-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.
|
||||
# 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.
|
||||
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: ${{ github.event.inputs.legacy-ref }}
|
||||
ref: legacy-extension
|
||||
|
||||
- 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 }}
|
||||
@@ -489,6 +490,35 @@ 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' }}
|
||||
@@ -544,7 +574,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -21,20 +21,17 @@ 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: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
group: ext-vscode-publish-legacy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -50,18 +47,23 @@ 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: ${{ github.event.inputs.branch }}
|
||||
ref: legacy-extension
|
||||
|
||||
# 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 }}
|
||||
@@ -99,16 +101,20 @@ 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). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
# 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.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
ref: legacy-extension
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
@@ -117,7 +123,7 @@ jobs:
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
BRANCH: legacy-extension
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
@@ -258,6 +264,33 @@ 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:
|
||||
@@ -287,7 +320,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -234,6 +234,33 @@ 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 }}
|
||||
@@ -303,7 +330,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -80,8 +80,9 @@ 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:
|
||||
@@ -93,6 +94,9 @@ 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
|
||||
@@ -100,8 +104,6 @@ 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
|
||||
@@ -110,8 +112,6 @@ 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,8 +123,6 @@ 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).
|
||||
@@ -171,9 +169,12 @@ 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: |
|
||||
test-results/playwright/
|
||||
apps/vscode/test-results/
|
||||
|
||||
@@ -282,6 +282,33 @@ 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
|
||||
@@ -333,7 +360,7 @@ jobs:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
|
||||
@@ -46,6 +46,12 @@ 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
|
||||
|
||||
@@ -58,11 +64,6 @@ 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,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [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.
|
||||
|
||||
### Added
|
||||
|
||||
- Let models that support it search the web during a task, with a toggle in Feature Settings to turn it on. Search calls and their results appear in the conversation and persist across reloads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop two Cline installations on different builds from shutting each other's Hub daemon down in a loop, which killed live sessions with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can decide to retire the other.
|
||||
- Leave a Hub that is still serving sessions in place instead of replacing it mid-handshake; the swap happens once it goes idle.
|
||||
- Reclaim idle plugin sandbox processes instead of leaving them running for the life of the session.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refresh the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board.
|
||||
|
||||
## [4.1.9]
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the editor's foreground color for diff block text, so diffs stay legible in themes where the previous hardcoded color washed them out.
|
||||
- Switch the interface to Inter and Geist Mono.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't discard a successfully refreshed Cline token when the old one was already past expiry, which made the first request after a long idle period fail despite valid credentials.
|
||||
- Stop the legacy-task migration backlog from spamming telemetry, and record a migration outcome only once the seeded session actually persists, so a failed migration is no longer reported as a success.
|
||||
- Report involuntary Cline logouts (a rejected refresh token) instead of clearing credentials silently.
|
||||
|
||||
### Fixed (SDK bundle only)
|
||||
|
||||
These land through SDK v0.0.74 and therefore apply to windows running the SDK bundle, not the legacy one.
|
||||
|
||||
- Fix the Claude Code provider being unusable for agentic work: it now runs its own native tools instead of receiving tool definitions it cannot bridge, anchors the session on your workspace directory, and loads `~/.claude` plus project settings so your permission rules apply.
|
||||
- Reject truncated tool-call JSON instead of silently "repairing" it into wrong arguments.
|
||||
- Fix strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts.
|
||||
- Fix a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough.
|
||||
- Report disjoint per-request token buckets instead of re-counting the whole cached conversation on every request, which inflated per-task totals roughly 5x on cache-heavy sessions.
|
||||
|
||||
## [4.1.8]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 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
|
||||
- Added protections for an update landing under CLI 3.0.54 and earlier, whose updater restarts the Hub mid-session and then rejects every replacement, bricking a running session. The newly installed package defuses that path during install instead of leaving it to fire
|
||||
- Fixed two Cline installations on different builds shutting each other's Hub daemon down in a loop, which killed every live session with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can ever decide to retire the other (from SDK v0.0.75)
|
||||
- A newer build no longer replaces a Hub that is still serving sessions — it attaches to it and the swap happens on a later launch, instead of the sessions dying mid-handshake (from SDK v0.0.75)
|
||||
- Removed the "outdated Hub" notice. It reported a state you cannot act on, and the toast was capped narrower than the message, so it rendered cut off before the reassuring half of the sentence at every terminal width. The prompt for a genuine build mismatch, where there is something to do, is unchanged
|
||||
- Streaming assistant markdown no longer flashes back to raw text. Settled headings, links, and code stay rendered as new chunks arrive instead of the whole message being rebuilt and re-highlighted on every chunk, which also stops the transcript from jumping vertically mid-stream
|
||||
- Web search calls and their results from models that run search natively now render in the transcript (from SDK v0.0.75)
|
||||
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session (from SDK v0.0.75)
|
||||
- `cline doctor fix` now reports honestly: processes that survived a kill are separated from ones that appeared while the fix ran, a live parent respawning a daemon is named, and a startup lock held by a running process is reported as held rather than leaked (from SDK v0.0.75)
|
||||
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board (from SDK v0.0.75)
|
||||
|
||||
## 3.0.54
|
||||
|
||||
- Fixed the Claude Code provider being unusable for agentic work: the provider now runs its own native tools instead of receiving tool definitions it cannot bridge, the session is anchored on your workspace directory instead of inheriting the host's cwd, and `~/.claude` plus project settings are loaded so your permission rules apply. File edits under the workspace are auto-approved; command execution stays gated by your own Claude settings (from SDK v0.0.74)
|
||||
- Fixed truncated tool-call JSON being silently "repaired" into wrong arguments — a payload with an unterminated string is now rejected rather than getting an invented terminator (from SDK v0.0.74)
|
||||
- Fixed strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts (from SDK v0.0.74)
|
||||
- Fixed a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough (from SDK v0.0.74)
|
||||
- Managed Hub daemons now upgrade directionally: when another Cline install ships a newer Hub build, the CLI attaches to the newer daemon and prompts you to update and restart instead of the two installs repeatedly retiring each other's daemons. Yolo and sandbox sessions, which never attach to the shared Hub, are not interrupted by that prompt (from SDK v0.0.74)
|
||||
- Fixed the Hub daemon logging an unhandled `hub server close failed` error and exiting non-zero whenever a client was still connected at shutdown (from SDK v0.0.74)
|
||||
- Fixed per-task token totals being inflated roughly 5x on cache-heavy sessions — token telemetry now reports disjoint uncached-input, cache-read, and cache-write buckets instead of re-counting the whole cached conversation on every request (from SDK v0.0.74)
|
||||
- Upgrading the CLI now retires an already-running Hub daemon and respawns it on the new code, instead of the upgraded CLI continuing to talk to a daemon executing the previous release
|
||||
|
||||
## 3.0.53
|
||||
|
||||
- Fixed the CLI reconnecting to a stale Hub daemon after an upgrade. Hub daemons now carry a runtime build fingerprint, so an upgraded CLI retires and respawns a daemon still running older code instead of attaching to it (from SDK v0.0.73)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.53",
|
||||
"version": "3.0.55",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -17,6 +17,35 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// CLI versions <= 3.0.54 restart the hub daemon after a background
|
||||
// auto-update even while it is serving live sessions, killing those sessions
|
||||
// mid-turn — and their build-fingerprint check then rejects every replacement
|
||||
// hub, bricking the running TUI. That restart code is the *old* version's, so
|
||||
// it cannot be patched here; but it bails out harmlessly when no hub
|
||||
// discovery record exists, and it runs only after this install (and this
|
||||
// script) completes. Setting the record aside protects any attached clients:
|
||||
// a running hub keeps serving its established connections, clients that share
|
||||
// its build fingerprint rebuild the record from a port probe, and the next
|
||||
// fresh launch retires stale hubs regardless of the record.
|
||||
function shieldRunningHubDiscovery() {
|
||||
const explicitPath = process.env.CLINE_HUB_DISCOVERY_PATH?.trim();
|
||||
const dataDir =
|
||||
process.env.CLINE_DATA_DIR?.trim() ||
|
||||
path.join(
|
||||
process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline"),
|
||||
"data",
|
||||
);
|
||||
const recordPath =
|
||||
explicitPath || path.join(dataDir, "locks", "hub", "production.json");
|
||||
if (!fs.existsSync(recordPath)) {
|
||||
return;
|
||||
}
|
||||
const asidePath = `${recordPath}.superseded`;
|
||||
fs.rmSync(asidePath, { force: true });
|
||||
fs.renameSync(recordPath, asidePath);
|
||||
console.log("Set aside hub discovery record for the updated CLI");
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, npm creates .cmd shims from the bin field.
|
||||
@@ -79,6 +108,14 @@ function main() {
|
||||
console.log(`Cached cline binary at ${target}`);
|
||||
}
|
||||
|
||||
try {
|
||||
shieldRunningHubDiscovery();
|
||||
} catch (error) {
|
||||
// Best-effort: without the shield the worst case is the pre-3.0.55
|
||||
// restart-while-busy behavior, never a broken install.
|
||||
console.error(`postinstall: hub discovery shield skipped: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import { isLikelyAuthError, type Message } from "@cline/shared";
|
||||
import { isLikelyAuthError, type MessageWithMetadata } from "@cline/shared";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { subscribeToAgentEvents } from "../runtime/session-events";
|
||||
@@ -70,6 +70,10 @@ import {
|
||||
sendSessionInfoUpdate,
|
||||
} from "./session-updates";
|
||||
|
||||
const CHAT_MODEL_QUERY_OPTIONS = {
|
||||
filter: "chat",
|
||||
} satisfies Llms.GetModelsForProviderOptions;
|
||||
|
||||
interface SessionState {
|
||||
id: string;
|
||||
cwd: string;
|
||||
@@ -100,7 +104,7 @@ interface SessionState {
|
||||
*/
|
||||
fatalError?: Error;
|
||||
/** Messages to inject into the next session manager for conversation continuity. */
|
||||
pendingInitialMessages?: Message[];
|
||||
pendingInitialMessages?: MessageWithMetadata[];
|
||||
}
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
@@ -185,7 +189,10 @@ export class AcpAgent implements Agent {
|
||||
const providerId =
|
||||
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(providerId);
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
providerId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
// 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.
|
||||
@@ -240,7 +247,7 @@ export class AcpAgent implements Agent {
|
||||
this.isSessionReady();
|
||||
|
||||
let session = this.sessions.get(params.sessionId);
|
||||
let messages: Message[];
|
||||
let messages: MessageWithMetadata[];
|
||||
|
||||
if (session?.sessionManager && session.activeSessionId) {
|
||||
// The session is still live in this connection — replay its current
|
||||
@@ -256,7 +263,10 @@ 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);
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
providerId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
session = {
|
||||
id: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
@@ -289,6 +299,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
const availableModels = Object.entries(providerModels).map(
|
||||
([availableModelId, info]) => ({
|
||||
@@ -473,7 +484,10 @@ 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);
|
||||
const providerModels = await Llms.getModelsForProvider(
|
||||
value,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
);
|
||||
session.currentModelId = await resolveDefaultModelId(
|
||||
value,
|
||||
session.currentModelId,
|
||||
@@ -676,7 +690,7 @@ export class AcpAgent implements Agent {
|
||||
session: SessionState,
|
||||
acpSessionId: string,
|
||||
options?: { resume?: boolean },
|
||||
): Promise<Message[] | undefined> {
|
||||
): Promise<MessageWithMetadata[] | undefined> {
|
||||
if (session.sessionManager) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -695,7 +709,7 @@ export class AcpAgent implements Agent {
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
});
|
||||
|
||||
let initialMessages: Message[] | undefined;
|
||||
let initialMessages: MessageWithMetadata[] | undefined;
|
||||
if (options?.resume) {
|
||||
initialMessages = await sessionManager
|
||||
.readMessages(acpSessionId)
|
||||
@@ -907,7 +921,10 @@ async function buildAllConfigOptions(
|
||||
): Promise<SessionConfigOption[]> {
|
||||
const [providerOption, providerModels] = await Promise.all([
|
||||
buildProviderConfigOption(session.currentProviderId),
|
||||
Llms.getModelsForProvider(session.currentProviderId),
|
||||
Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
),
|
||||
]);
|
||||
return [
|
||||
providerOption,
|
||||
|
||||
@@ -225,6 +225,76 @@ describe("translateHistoricalMessage", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays provider model tools with the ordinary ACP tool updates", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Parameters<typeof translateHistoricalMessage>[0]),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "search-1",
|
||||
title: expect.any(String),
|
||||
kind: "search",
|
||||
status: "pending",
|
||||
rawInput: { query: "latest Bun release" },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "search-1",
|
||||
status: "completed",
|
||||
rawOutput: "Bun 1.3.14",
|
||||
},
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Found it" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves structured native web-search results", () => {
|
||||
const nativeResult = {
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
};
|
||||
const updates = translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-native",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: [nativeResult],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Parameters<typeof translateHistoricalMessage>[0]);
|
||||
|
||||
expect(updates[1]).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "search-native",
|
||||
rawOutput: JSON.stringify(nativeResult),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaySessionHistory", () => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import type {
|
||||
AgentSideConnection,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type MessageWithMetadata,
|
||||
type ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
@@ -29,7 +30,7 @@ function isSyntheticUserText(text: string): boolean {
|
||||
export async function replaySessionHistory(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
for (const update of translateHistoricalMessage(message)) {
|
||||
@@ -38,7 +39,17 @@ export async function replaySessionHistory(
|
||||
}
|
||||
}
|
||||
|
||||
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
|
||||
export function translateHistoricalMessage(
|
||||
message: MessageWithMetadata,
|
||||
): SessionUpdate[] {
|
||||
return projectSessionMessagesForDisplay([message]).flatMap(({ message }) =>
|
||||
translateProjectedHistoricalMessage(message),
|
||||
);
|
||||
}
|
||||
|
||||
function translateProjectedHistoricalMessage(
|
||||
message: MessageWithMetadata,
|
||||
): SessionUpdate[] {
|
||||
const blocks: ContentBlock[] =
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text", text: message.content }]
|
||||
@@ -92,6 +103,31 @@ export function translateHistoricalMessage(message: Message): SessionUpdate[] {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "media": {
|
||||
const media = block.media;
|
||||
if (media.modality === "image" && media.source.type === "base64") {
|
||||
updates.push({
|
||||
sessionUpdate:
|
||||
message.role === "user"
|
||||
? "user_message_chunk"
|
||||
: "agent_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: media.source.data,
|
||||
mimeType: media.mediaType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updates.push({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `[Generated ${media.modality}: ${media.mediaType}]`,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_use": {
|
||||
updates.push({
|
||||
sessionUpdate: "tool_call",
|
||||
@@ -133,8 +169,14 @@ function flattenToolResultContent(
|
||||
return part.text;
|
||||
case "file":
|
||||
return part.content;
|
||||
default:
|
||||
case "image":
|
||||
return "[image]";
|
||||
default:
|
||||
try {
|
||||
return JSON.stringify(part);
|
||||
} catch {
|
||||
return String(part);
|
||||
}
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { forwardAgentEvent } from "./session-updates";
|
||||
|
||||
describe("forwardAgentEvent", () => {
|
||||
it("forwards generated images as ACP agent message chunks", () => {
|
||||
const sessionUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const connection = { sessionUpdate } as unknown as AgentSideConnection;
|
||||
|
||||
forwardAgentEvent(connection, "session-1", {
|
||||
type: "content_end",
|
||||
contentType: "media",
|
||||
media: {
|
||||
id: "generated-1",
|
||||
modality: "image",
|
||||
mediaType: "image/png",
|
||||
source: { type: "base64", data: "aGVsbG8=" },
|
||||
},
|
||||
} as AgentEvent);
|
||||
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: "aGVsbG8=",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import type { GeneratedMedia } from "@cline/shared";
|
||||
import { getErrorMessage } from "@cline/shared";
|
||||
import { buildToolTitle, mapToolKind } from "./tool-utils";
|
||||
|
||||
@@ -100,6 +101,7 @@ function translateContentEnd(
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
media?: GeneratedMedia;
|
||||
};
|
||||
|
||||
switch (e.contentType) {
|
||||
@@ -109,6 +111,29 @@ function translateContentEnd(
|
||||
case "reasoning":
|
||||
// Reasoning was already streamed via content_start chunks; don't re-send.
|
||||
return [];
|
||||
case "media":
|
||||
if (!e.media) return [];
|
||||
if (e.media.modality !== "image" || e.media.source.type !== "base64") {
|
||||
return [
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `[Generated ${e.media.modality}: ${e.media.mediaType}]`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: e.media.source.data,
|
||||
mimeType: e.media.mediaType,
|
||||
},
|
||||
},
|
||||
];
|
||||
case "tool": {
|
||||
const toolCallId = e.toolCallId ?? "unknown";
|
||||
const failed = !!e.error;
|
||||
|
||||
@@ -17,6 +17,7 @@ const TOOL_KIND_MAP: Record<string, ToolKind> = {
|
||||
WebFetch: "fetch",
|
||||
fetch_web_content: "fetch",
|
||||
WebSearch: "search",
|
||||
web_search: "search",
|
||||
Agent: "think",
|
||||
spawn_agent: "think",
|
||||
NotebookEdit: "edit",
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 }> => []),
|
||||
@@ -29,6 +30,7 @@ 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,
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockReadSupersededHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockStopLocalHubServerGracefully,
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
),
|
||||
})),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockReadSupersededHubDiscovery: vi.fn(() => undefined as unknown),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
@@ -73,6 +75,7 @@ vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
readSupersededHubDiscovery: mockReadSupersededHubDiscovery,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
listActiveConnectors: mockListActiveConnectors,
|
||||
@@ -186,6 +189,65 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sees the hub through the set-aside record during the shielded update window", async () => {
|
||||
const cwd = "/workspace";
|
||||
// The npm postinstall shield renamed the discovery record aside; the
|
||||
// hub is alive and serving an old client's sessions.
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
mockReadSupersededHubDiscovery.mockReturnValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "shielded-token",
|
||||
pid: 50174,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return { status: 0, stdout: "50174\n" };
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[2] === "--cline-hub-daemon"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "50174 /usr/local/bin/cline --cline-hub-daemon\n",
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockProbeHubServer).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
{
|
||||
authToken: "shielded-token",
|
||||
},
|
||||
);
|
||||
// Without the fallback the live daemon reads as stale and doctor's
|
||||
// advice (\"run doctor fix\") would kill the sessions the shield exists
|
||||
// to protect.
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
hubHealthy: true,
|
||||
staleHubPids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
@@ -624,3 +686,42 @@ describe("doctor supervision reporting", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeProcessesStartedDuringFix", () => {
|
||||
const { describeProcessesStartedDuringFix } = __test__;
|
||||
const liveParents = new Map([
|
||||
[100, 10],
|
||||
[200, 20],
|
||||
]);
|
||||
const resolveLiveParent = (pid: number) => liveParents.get(pid);
|
||||
|
||||
it("says nothing when no process started during the fix", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([], resolveLiveParent),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blames the parent only when every process has a live one", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([100, 200], resolveLiveParent),
|
||||
).toBe(
|
||||
"\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.",
|
||||
);
|
||||
});
|
||||
|
||||
// A process can start on its own mid-repair - a user opening a new session,
|
||||
// say - and telling them to go kill an unrelated parent would be wrong.
|
||||
it("states the facts when no process has a live parent", () => {
|
||||
expect(describeProcessesStartedDuringFix([777], resolveLiveParent)).toBe(
|
||||
"\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.",
|
||||
);
|
||||
});
|
||||
|
||||
it("separates respawns from independent starts in a mixed batch", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([100, 777], resolveLiveParent),
|
||||
).toBe(
|
||||
"\nSome of these were respawned by a live parent (100); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+149
-14
@@ -7,6 +7,7 @@ import {
|
||||
listActiveConnectors,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
readSupersededHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
@@ -384,6 +385,12 @@ async function clearHubStartupArtifacts(
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
clearedDiscovery = 1;
|
||||
}
|
||||
if (options?.clearDiscovery) {
|
||||
// The set-aside copy the npm postinstall shield leaves behind. Once
|
||||
// doctor has deliberately stopped everything, keeping it risks a much
|
||||
// later launch SIGTERMing whatever process has recycled its pid.
|
||||
clearPathIfExists(`${owner.discoveryPath}.superseded`);
|
||||
}
|
||||
return {
|
||||
startupLocks: clearedStartupLocks,
|
||||
discovery: clearedDiscovery,
|
||||
@@ -411,7 +418,25 @@ function resolveCliHubOwnerContext() {
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
// The npm postinstall shield sets the discovery record aside (see
|
||||
// readSupersededHubDiscovery) while an older hub finishes serving its
|
||||
// sessions. Without the fallback, doctor cannot see that hub, classifies
|
||||
// the live daemon as stale, and its "run doctor fix" advice kills the
|
||||
// sessions the shield exists to protect.
|
||||
const recorded = await readHubDiscovery(owner.discoveryPath);
|
||||
// The set-aside record carries only url/token/pid; widen so the two
|
||||
// sources read uniformly below.
|
||||
const discovery:
|
||||
| {
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
port?: number;
|
||||
coreVersion?: string;
|
||||
}
|
||||
| undefined = recorded?.url
|
||||
? recorded
|
||||
: readSupersededHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: undefined;
|
||||
@@ -449,6 +474,80 @@ function formatPidList(label: string, pids: number[]): string {
|
||||
return `${label} ${c.dim}${pids.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
function readParentPid(pid: number): number | undefined {
|
||||
try {
|
||||
const output = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = Number(output.stdout?.trim());
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function liveParentPid(pid: number): number | undefined {
|
||||
const parent = readParentPid(pid);
|
||||
return parent && isProcessRunning(parent) ? parent : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A daemon whose parent is still running was almost certainly just spawned by
|
||||
* that parent, and killing it only invites the parent to spawn another. Naming
|
||||
* the parent points at the process the user actually has to stop.
|
||||
*/
|
||||
function formatDaemonPidList(label: string, pids: number[]): string {
|
||||
if (pids.length === 0) {
|
||||
return `${label} ${c.dim}0${c.reset}`;
|
||||
}
|
||||
const described = pids.map((pid) => {
|
||||
const parent = liveParentPid(pid);
|
||||
return parent ? `${pid} (spawned by ${parent})` : String(pid);
|
||||
});
|
||||
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advice for processes first seen during the fix. Only a process with a live
|
||||
* parent is known to have been respawned by it; anything else may have been
|
||||
* started independently (a user opening a new session mid-repair), so it gets
|
||||
* a statement of fact rather than an instruction to go kill something.
|
||||
*/
|
||||
export function describeProcessesStartedDuringFix(
|
||||
pids: number[],
|
||||
resolveLiveParent: (pid: number) => number | undefined,
|
||||
): string | undefined {
|
||||
if (pids.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const respawned = pids.filter((pid) => resolveLiveParent(pid) !== undefined);
|
||||
if (respawned.length === 0) {
|
||||
return "\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.";
|
||||
}
|
||||
if (respawned.length === pids.length) {
|
||||
return "\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.";
|
||||
}
|
||||
return `\nSome of these were respawned by a live parent (${respawned.join(", ")}); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.`;
|
||||
}
|
||||
|
||||
function formatStartupLockList(
|
||||
label: string,
|
||||
locks: StartupArtifact[],
|
||||
): string {
|
||||
const described = locks
|
||||
.map((lock) => {
|
||||
if (lock.pid === undefined) {
|
||||
return "unreadable";
|
||||
}
|
||||
return lock.stale ? `${lock.pid} (stale)` : `${lock.pid} (held, live)`;
|
||||
})
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (described.length === 0) {
|
||||
return `${label} ${c.dim}0${c.reset}`;
|
||||
}
|
||||
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
|
||||
const pieces = [
|
||||
record.timestamp ?? "unknown-time",
|
||||
@@ -531,6 +630,7 @@ function killPids(pids: number[]): number {
|
||||
export const __test__ = {
|
||||
decideForeignContainer,
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
describeProcessesStartedDuringFix,
|
||||
formatSupervisedConnector,
|
||||
};
|
||||
|
||||
@@ -556,13 +656,8 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(formatPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(formatDaemonPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(formatStartupLockList("hub startup locks", before.hubStartupLocks));
|
||||
writeln(formatPidList("cli processes", before.staleCliPids));
|
||||
writeln(formatPidList("sidecar processes", before.staleSidecarPids));
|
||||
if (before.activeConnectors.length === 0) {
|
||||
@@ -673,16 +768,56 @@ export async function runDoctorCommand(
|
||||
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
// "Remaining" means a process this run tried to kill and failed to. A
|
||||
// re-scan alone cannot tell that apart from a process that appeared while
|
||||
// the fix was running, and reporting the two together reads as a failure
|
||||
// to kill something that was never targeted.
|
||||
const survived = (targets: number[], remaining: number[]) =>
|
||||
remaining.filter((pid) => targets.includes(pid));
|
||||
const appeared = (targets: number[], remaining: number[]) =>
|
||||
remaining.filter((pid) => !targets.includes(pid));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
"remaining hub listeners",
|
||||
survived(refreshedAfterGracefulStop.listeningPids, after.listeningPids),
|
||||
),
|
||||
);
|
||||
writeln(formatPidList("remaining cli processes", after.staleCliPids));
|
||||
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
|
||||
writeln(
|
||||
formatDaemonPidList(
|
||||
"remaining stale hub daemons",
|
||||
survived(staleHubTargets, after.staleHubPids),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatStartupLockList("remaining hub startup locks", after.hubStartupLocks),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining cli processes",
|
||||
survived(staleCliTargets, after.staleCliPids),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining sidecar processes",
|
||||
survived(staleSidecarTargets, after.staleSidecarPids),
|
||||
),
|
||||
);
|
||||
const spawnedDuringFix = [
|
||||
...appeared(staleHubTargets, after.staleHubPids),
|
||||
...appeared(staleCliTargets, after.staleCliPids),
|
||||
...appeared(staleSidecarTargets, after.staleSidecarPids),
|
||||
];
|
||||
if (spawnedDuringFix.length > 0) {
|
||||
writeln(formatDaemonPidList("started during fix", spawnedDuringFix));
|
||||
const advice = describeProcessesStartedDuringFix(
|
||||
spawnedDuringFix,
|
||||
liveParentPid,
|
||||
);
|
||||
if (advice) {
|
||||
io.writeln(advice);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockHubClientCommand = vi.hoisted(() => vi.fn());
|
||||
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
@@ -16,7 +17,17 @@ vi.mock("@cline/core", async () => {
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
NodeHubClient: class {
|
||||
command = mockHubClientCommand;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
mockNodeHubClientCtor(options);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
close(): void {}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
@@ -74,7 +85,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -96,18 +107,21 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["No schedules found."]);
|
||||
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,
|
||||
},
|
||||
},
|
||||
// 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(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps JSON list output unchanged when --json is provided", async () => {
|
||||
@@ -115,7 +129,7 @@ describe("runScheduleCommand list output", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
@@ -137,7 +151,7 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["[]"]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalled();
|
||||
expect(mockHubClientCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,7 +171,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -189,15 +203,19 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -215,7 +233,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -246,14 +264,11 @@ describe("runScheduleCommand create", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -292,7 +307,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(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
expect(mockHubClientCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -300,7 +315,7 @@ describe("runScheduleCommand create", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_delivery" } },
|
||||
});
|
||||
@@ -339,21 +354,17 @@ describe("runScheduleCommand create", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
|
||||
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",
|
||||
},
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -370,7 +381,7 @@ describe("runScheduleCommand import", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
@@ -411,16 +422,12 @@ describe("runScheduleCommand import", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
|
||||
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",
|
||||
}),
|
||||
},
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith(
|
||||
"schedule.create",
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -444,7 +451,7 @@ describe("runScheduleCommand export", () => {
|
||||
prompt: "review status",
|
||||
workspaceRoot: "/tmp/workspace",
|
||||
};
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
@@ -484,14 +491,9 @@ describe("runScheduleCommand export", () => {
|
||||
|
||||
const written = await readFile(targetPath, "utf8");
|
||||
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.get",
|
||||
payload: { scheduleId: "sched_abc" },
|
||||
},
|
||||
);
|
||||
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
|
||||
scheduleId: "sched_abc",
|
||||
});
|
||||
} finally {
|
||||
await rm(targetPath, { force: true });
|
||||
}
|
||||
@@ -507,7 +509,7 @@ describe("runScheduleCommand export", () => {
|
||||
name: "Weekly Sync",
|
||||
cronPattern: "0 9 * * 1",
|
||||
};
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
mockHubClientCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: scheduleRecord },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
sendHubCommand,
|
||||
NodeHubClient,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
@@ -11,28 +11,51 @@ import {
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export class HubScheduleClient {
|
||||
private hub: Promise<NodeHubClient> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
},
|
||||
private readonly url: string,
|
||||
private readonly workspaceRoot: string,
|
||||
private readonly authToken?: string,
|
||||
) {}
|
||||
|
||||
close(): void {}
|
||||
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;
|
||||
}
|
||||
|
||||
private async command(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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}`);
|
||||
}
|
||||
const client = await this.connectedHub();
|
||||
const reply = await client.command(command as never, payload);
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -97,6 +120,7 @@ export class LocalScheduleClient {
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
private readonly commands = new HubScheduleCommandService(this.service);
|
||||
constructor(private readonly workspaceRoot: string) {}
|
||||
|
||||
close(): void {
|
||||
void this.service.dispose();
|
||||
@@ -106,12 +130,21 @@ 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,
|
||||
});
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
@@ -185,24 +218,27 @@ export async function ensureSchedulerHub(
|
||||
if (!address?.trim()) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const requestedEndpoint = parseHubEndpointOverride(address);
|
||||
const { url: hubUrl } = await ensureCliHubServer(
|
||||
const { url: hubUrl, authToken } = await ensureCliHubServer(
|
||||
workspaceRoot,
|
||||
requestedEndpoint,
|
||||
);
|
||||
const endpoint = parseHubEndpointOverride(hubUrl);
|
||||
return {
|
||||
ok: true,
|
||||
client: new HubScheduleClient(endpoint),
|
||||
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
ok: true,
|
||||
client: new LocalScheduleClient() as unknown as HubScheduleClient,
|
||||
client: new LocalScheduleClient(
|
||||
workspaceRoot,
|
||||
) as unknown as HubScheduleClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
|
||||
mockEnsureCliHubServer: vi.fn(),
|
||||
const { mockSpawn } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,14 +16,10 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
import {
|
||||
applyDeferredUpdate,
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
ensureCliHubServerAfterUpdate,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
@@ -42,14 +36,6 @@ const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createChildProcessThatCloses(exitCode: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
@@ -265,68 +251,100 @@ describe("hub restart owner selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-update hub launch", () => {
|
||||
describe("deferred auto update", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureCliHubServer.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the freshly installed wrapper instead of the current executable", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
|
||||
const env = {
|
||||
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
CLINE_NO_AUTO_UPDATE: "0",
|
||||
};
|
||||
|
||||
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
["hub", "ensure"],
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
env: {
|
||||
...env,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
await ensureCliHubServerAfterUpdate(
|
||||
"C:\\workspace\\project",
|
||||
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
|
||||
"C:\\workspace\\project",
|
||||
);
|
||||
it("does nothing when no update was recorded", async () => {
|
||||
expect(await applyDeferredUpdate(undefined)).toBe("none");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a failure from the freshly installed CLI", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
|
||||
it("starts the detached install when no hub is discoverable", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = join(root, "production.json");
|
||||
const unref = vi.fn();
|
||||
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
|
||||
|
||||
await expect(
|
||||
ensureCliHubServerAfterUpdate(
|
||||
"/workspace/project",
|
||||
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
|
||||
"linux",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"freshly installed Cline failed to start the hub (exit code 1)",
|
||||
const outcome = await applyDeferredUpdate({
|
||||
command: "npm update -g cline --tag latest --min-release-age=0",
|
||||
});
|
||||
|
||||
expect(outcome).toBe("started");
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm update -g cline --tag latest --min-release-age=0",
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
shell: true,
|
||||
stdio: "ignore",
|
||||
}),
|
||||
);
|
||||
expect(unref).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers while another cli client is attached to the hub", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
const discoveryPath = join(root, "production.json");
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
|
||||
const {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
NodeHubClient,
|
||||
startHubWebSocketServer,
|
||||
} = await import("@cline/core");
|
||||
const server = await startHubWebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
owner: { ownerId: "update-test", discoveryPath },
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const cliClient = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientType: "cli",
|
||||
displayName: "fake attached cli",
|
||||
});
|
||||
try {
|
||||
await cliClient.command("client.list", {});
|
||||
|
||||
expect(await applyDeferredUpdate({ command: "echo update" })).toBe(
|
||||
"deferred",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
|
||||
await cliClient.dispose();
|
||||
const unref = vi.fn();
|
||||
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
|
||||
// The hub unregisters the client when its socket closes; poll
|
||||
// briefly rather than assuming the close is processed instantly.
|
||||
let outcome = "deferred";
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (outcome === "deferred" && Date.now() < deadline) {
|
||||
outcome = await applyDeferredUpdate({ command: "echo update" });
|
||||
}
|
||||
expect(outcome).toBe("started");
|
||||
} finally {
|
||||
await cliClient.dispose().catch(() => undefined);
|
||||
await server.close();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
|
||||
+137
-134
@@ -1,17 +1,14 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
NodeHubClient,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -237,50 +234,6 @@ async function runKanbanUpdate(
|
||||
return waitForProcessExit(updateProcess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the hub through the freshly installed CLI after a self-update.
|
||||
*
|
||||
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
|
||||
* remove that cached executable while replacing the package and then block the
|
||||
* postinstall script that recreates it. The current process keeps running from
|
||||
* the unlinked executable, but process.execPath is no longer spawnable. Going
|
||||
* back through the wrapper makes it resolve the newly installed platform
|
||||
* binary instead.
|
||||
*
|
||||
* Windows does not create the bin/.cline cache, and development builds do not
|
||||
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
|
||||
* ensure path.
|
||||
*/
|
||||
export async function ensureCliHubServerAfterUpdate(
|
||||
workspaceRoot: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (!wrapperPath || platform === "win32") {
|
||||
await ensureCliHubServer(workspaceRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(wrapperPath, ["hub", "ensure"], {
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...env,
|
||||
// The fresh CLI only exists to start the hub. Do not let it launch
|
||||
// another background update check while this update is finishing.
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return "";
|
||||
@@ -318,86 +271,40 @@ export function getPreferredKanbanInstaller(
|
||||
);
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let pendingAutoUpdate: ManualUpdateCommand | undefined;
|
||||
let pendingAutoUpdateCheck: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Restart the hub server if one is currently running.
|
||||
* Gracefully asks the running hub process to stop, falls back to process signals,
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!discovery || !health?.url) return;
|
||||
// How long the exit sequence will wait for a still-in-flight startup version
|
||||
// check before giving up on it. Long enough for a typical registry response,
|
||||
// short enough that one-shot commands do not feel it.
|
||||
const UPDATE_CHECK_EXIT_GRACE_MS = 250;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServerAfterUpdate(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
`[hub] failed to restart server: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Hard cap on the exit-time hub query. The hub client's default connect and
|
||||
// command timeouts add up to tens of seconds against a wedged hub, and this
|
||||
// runs while the user is waiting for their shell prompt back.
|
||||
const CLIENT_COUNT_EXIT_TIMEOUT_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Non-blocking auto-update check for CLI startup.
|
||||
* Spawns a detached install process if a newer version is available.
|
||||
*
|
||||
* Deliberately does NOT install right away: replacing the npm package while
|
||||
* cline processes are running swaps the binary under them — their respawn
|
||||
* paths break on the new build fingerprint — and historically also restarted
|
||||
* the hub daemon out from under live sessions. The check only records that an
|
||||
* update is available; the CLI entrypoint calls applyDeferredUpdate() from
|
||||
* its exit sequence (an explicit process.exit() follows, so a beforeExit hook
|
||||
* would never fire), and the install runs only when no other CLI is attached
|
||||
* to the hub — at that point nothing is running that the swap could hurt.
|
||||
* The next launch picks up the new binary and a fresh hub.
|
||||
*
|
||||
* Skipped for npx, dev, unknown installs. Disable with CLINE_NO_AUTO_UPDATE=1.
|
||||
*/
|
||||
export function autoUpdateOnStartup(): void {
|
||||
@@ -409,35 +316,129 @@ export function autoUpdateOnStartup(): void {
|
||||
getInstallationInfo(version);
|
||||
if (!updateCommand) return;
|
||||
|
||||
void (async () => {
|
||||
pendingAutoUpdateCheck = (async () => {
|
||||
try {
|
||||
const latest = await getLatestVersion(packageName, version);
|
||||
if (!latest || compareVersions(version, latest) >= 0) return;
|
||||
const autoUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
pendingAutoUpdate = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
const child = spawn(autoUpdateCommand.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
await restartHubServerIfRunning();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, silently ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a hub is reachable and another cli* client is attached to it.
|
||||
* Only cli* clients run the npm-installed binary — desktop sidecars and
|
||||
* connectors ship their own — so only they make the swap unsafe. This runs
|
||||
* after the entrypoint's disposeAll(), so this process's own registrations
|
||||
* are closed and any cli client still listed belongs to another process. Errors count as attached:
|
||||
* never install unless the hub positively confirms nothing would be hurt.
|
||||
*/
|
||||
async function otherCliClientsAttached(): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url: discovery.url,
|
||||
authToken: discovery.authToken,
|
||||
clientType: "cli-update-check",
|
||||
displayName: "cline update check",
|
||||
});
|
||||
try {
|
||||
const reply = await client.command("client.list", {}, undefined, {
|
||||
timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS,
|
||||
});
|
||||
const clients =
|
||||
(reply.payload as { clients?: Array<{ clientType?: unknown }> })
|
||||
.clients ?? [];
|
||||
if (
|
||||
clients.some(
|
||||
(entry) =>
|
||||
typeof entry?.clientType === "string" &&
|
||||
entry.clientType.startsWith("cli") &&
|
||||
entry.clientType !== "cli-update-check",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// A TUI's registration can be lost in transport churn while its session
|
||||
// connection survives (observed in review), so an empty client list is
|
||||
// not proof of safety. Cross-check for sessions somebody is attached to.
|
||||
// Participants, not session status: finished sessions can linger idle
|
||||
// forever and must not pin updates, and participant-less scheduled runs
|
||||
// live in the hub process, which a binary swap does not touch.
|
||||
const sessions = await client.command(
|
||||
"session.list",
|
||||
{ limit: 500 },
|
||||
undefined,
|
||||
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
|
||||
);
|
||||
const sessionRecords =
|
||||
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
|
||||
.sessions ?? [];
|
||||
return sessionRecords.some(
|
||||
(session) =>
|
||||
Array.isArray(session?.participants) && session.participants.length > 0,
|
||||
);
|
||||
} finally {
|
||||
await client.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns the recorded update install, detached, if no other CLI would be
|
||||
* affected by the package swap. Fire-and-forget: the install outlives this
|
||||
* process and its postinstall never blocks an exit.
|
||||
*/
|
||||
export async function applyDeferredUpdate(
|
||||
pending?: ManualUpdateCommand,
|
||||
): Promise<"none" | "deferred" | "started"> {
|
||||
if (!pending) {
|
||||
// Short-lived commands can reach exit before the startup version check
|
||||
// resolves; give it a brief grace so one-shot-only usage still updates.
|
||||
if (pendingAutoUpdateCheck) {
|
||||
await Promise.race([
|
||||
pendingAutoUpdateCheck,
|
||||
sleep(UPDATE_CHECK_EXIT_GRACE_MS),
|
||||
]);
|
||||
}
|
||||
pending = pendingAutoUpdate;
|
||||
}
|
||||
if (!pending) {
|
||||
return "none";
|
||||
}
|
||||
// The whole query is bounded: the user is waiting on their prompt, and a
|
||||
// wedged hub must not turn a finished command into a hung one. A timeout
|
||||
// counts as "attached" — never install unless the hub positively confirms.
|
||||
const attached = await Promise.race([
|
||||
otherCliClientsAttached(),
|
||||
sleep(CLIENT_COUNT_EXIT_TIMEOUT_MS).then(() => true),
|
||||
]).catch(() => true);
|
||||
if (attached) {
|
||||
return "deferred";
|
||||
}
|
||||
pendingAutoUpdate = undefined;
|
||||
const child = spawn(pending.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: pending.env ? { ...process.env, ...pending.env } : process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
return "started";
|
||||
}
|
||||
|
||||
export interface CheckForUpdatesOptions {
|
||||
verbose?: boolean;
|
||||
includeKanban?: boolean;
|
||||
@@ -554,7 +555,9 @@ export async function checkForUpdates(
|
||||
const exitCode = await runCliUpdate(manualUpdateCommand);
|
||||
if (exitCode === 0) {
|
||||
installedUpdates.push(`${packageName}@${latestVersion}`);
|
||||
await restartHubServerIfRunning();
|
||||
writeln(
|
||||
`${c.dim}The update takes effect the next time cline starts.${c.reset}`,
|
||||
);
|
||||
} else {
|
||||
writeErr(
|
||||
`Cline update failed (exit code ${exitCode}). Try running: ${manualUpdateCommand.command}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { HubSessionClient, HubSessionRow } from "@cline/core";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
ensureParentDir,
|
||||
getProcessStartToken,
|
||||
resolveClineDataDir,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
withResolvedClineBuildEnv,
|
||||
@@ -85,60 +89,6 @@ 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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { isUnusableSessionError } from "@cline/core";
|
||||
import type { GeneratedMedia } from "@cline/shared";
|
||||
import type { SentMessage, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
|
||||
@@ -125,6 +126,7 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
|
||||
stream: AsyncIterable<string>,
|
||||
postFinalReply?: (text: string) => Promise<void>,
|
||||
resolveFallbackText?: () => Promise<string | undefined>,
|
||||
hasNonTextReply?: () => boolean,
|
||||
): Promise<void> {
|
||||
if (transport !== "telegram" && !postFinalReply && !resolveFallbackText) {
|
||||
await thread.post(stream);
|
||||
@@ -135,6 +137,9 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
|
||||
for await (const chunk of stream) {
|
||||
text += chunk;
|
||||
}
|
||||
if (!text.trim() && hasNonTextReply?.()) {
|
||||
return;
|
||||
}
|
||||
if (!text.trim()) {
|
||||
text = (await resolveFallbackText?.())?.trim() || "";
|
||||
}
|
||||
@@ -151,6 +156,67 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
|
||||
await postConnectorText(thread, transport, text);
|
||||
}
|
||||
|
||||
const CONNECTOR_MEDIA_EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/ogg": "ogg",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
};
|
||||
|
||||
async function postConnectorGeneratedMedia<TState extends ConnectorThreadState>(
|
||||
thread: Thread<TState>,
|
||||
mediaItems: readonly GeneratedMedia[],
|
||||
): Promise<void> {
|
||||
if (mediaItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files: Array<{ data: Buffer; filename: string; mimeType: string }> = [];
|
||||
const references: string[] = [];
|
||||
for (const [index, media] of mediaItems.entries()) {
|
||||
const label = media.name?.trim() || `Generated ${media.modality}`;
|
||||
switch (media.source.type) {
|
||||
case "base64": {
|
||||
const data = Buffer.from(media.source.data, "base64");
|
||||
if (data.byteLength === 0) {
|
||||
references.push(
|
||||
`${label} (${media.mediaType}) could not be attached.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const extension =
|
||||
CONNECTOR_MEDIA_EXTENSIONS[media.mediaType.toLowerCase()] ?? "bin";
|
||||
const suppliedName = media.name ? basename(media.name) : "";
|
||||
files.push({
|
||||
data,
|
||||
filename: suppliedName || `generated-${index + 1}.${extension}`,
|
||||
mimeType: media.mediaType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "url":
|
||||
references.push(`[${label}](${media.source.url})`);
|
||||
break;
|
||||
case "artifact":
|
||||
references.push(`${label}: artifact ${media.source.artifactId}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0 && references.length === 0) {
|
||||
return;
|
||||
}
|
||||
await thread.post({
|
||||
markdown: references.length > 0 ? references.join("\n") : "Generated media",
|
||||
...(files.length > 0 ? { files } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a thread's stale session mapping after the hub reported the mapped
|
||||
* session no longer exists, so the next turn starts a fresh session instead of
|
||||
@@ -962,6 +1028,7 @@ export async function handleConnectorUserTurn<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
@@ -1051,6 +1118,7 @@ async function runConnectorRuntimeTurnWithRecovery<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
);
|
||||
const request: ChatRunTurnRequest = {
|
||||
config: startRequest,
|
||||
@@ -1151,6 +1219,7 @@ async function runConnectorRuntimeTurn<
|
||||
client: input.client,
|
||||
sessionId,
|
||||
});
|
||||
const generatedMedia: GeneratedMedia[] = [];
|
||||
|
||||
const activeTurn: ActiveConnectorTurn = {
|
||||
sessionId,
|
||||
@@ -1200,6 +1269,9 @@ async function runConnectorRuntimeTurn<
|
||||
formatConnectorApprovalPrompt(approval),
|
||||
);
|
||||
},
|
||||
onMedia: (media) => {
|
||||
generatedMedia.push(media);
|
||||
},
|
||||
onCompleted: async (result) => {
|
||||
await input.onReplyCompleted?.({
|
||||
sessionId,
|
||||
@@ -1219,7 +1291,9 @@ async function runConnectorRuntimeTurn<
|
||||
}),
|
||||
postFinalReply,
|
||||
resolveFallbackText,
|
||||
() => generatedMedia.length > 0,
|
||||
);
|
||||
await postConnectorGeneratedMedia(input.thread, generatedMedia);
|
||||
} finally {
|
||||
input.pendingApprovals.delete(input.thread.id);
|
||||
if (input.activeTurns?.get(turnKey) === activeTurn) {
|
||||
|
||||
@@ -11,6 +11,49 @@ type StreamHandlers = {
|
||||
};
|
||||
|
||||
describe("createConnectorRuntimeTurnStream", () => {
|
||||
it("forwards generated media without adding binary data to the text stream", async () => {
|
||||
let handlers: StreamHandlers | undefined;
|
||||
const media = {
|
||||
id: "generated-1",
|
||||
modality: "image" as const,
|
||||
mediaType: "image/png",
|
||||
source: { type: "base64" as const, data: "aGVsbG8=" },
|
||||
};
|
||||
const client = {
|
||||
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
|
||||
handlers = callbacks;
|
||||
return () => {};
|
||||
},
|
||||
sendRuntimeSession: async () => {
|
||||
handlers?.onEvent({
|
||||
eventType: "runtime.chat.media",
|
||||
payload: { media },
|
||||
});
|
||||
return { result: { text: "", finishReason: "stop", iterations: 1 } };
|
||||
},
|
||||
};
|
||||
const receivedMedia: unknown[] = [];
|
||||
const chunks: string[] = [];
|
||||
|
||||
for await (const chunk of createConnectorRuntimeTurnStream({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
request: { config: {} as never, prompt: "make an image" },
|
||||
clientId: "client-1",
|
||||
logger: { core: {} } as unknown as CliLoggerAdapter,
|
||||
transport: "slack",
|
||||
conversationId: "thread-1",
|
||||
onMedia: (item) => {
|
||||
receivedMedia.push(item);
|
||||
},
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([]);
|
||||
expect(receivedMedia).toEqual([media]);
|
||||
});
|
||||
|
||||
it("delivers tool status via callbacks instead of appending it to streamed text", async () => {
|
||||
let handlers: StreamHandlers | undefined;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatRunTurnRequest, HubSessionClient } from "@cline/core";
|
||||
import { type GeneratedMedia, isGeneratedMedia } from "@cline/shared";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
|
||||
export type PendingConnectorApproval = {
|
||||
@@ -141,6 +142,7 @@ export function createConnectorRuntimeTurnStream(input: {
|
||||
conversationId: string;
|
||||
onToolStatus?: (message: string) => Promise<void>;
|
||||
onApprovalRequested?: (approval: PendingConnectorApproval) => Promise<void>;
|
||||
onMedia?: (media: GeneratedMedia) => Promise<void> | void;
|
||||
onCompleted?: (result: {
|
||||
text: string;
|
||||
finishReason?: string;
|
||||
@@ -189,6 +191,13 @@ export function createConnectorRuntimeTurnStream(input: {
|
||||
},
|
||||
{
|
||||
onEvent: (event) => {
|
||||
if (event.eventType === "runtime.chat.media") {
|
||||
const media = event.payload.media;
|
||||
if (isGeneratedMedia(media)) {
|
||||
void input.onMedia?.(media);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.eventType === "approval.requested") {
|
||||
const approvalId =
|
||||
typeof event.payload.approvalId === "string"
|
||||
|
||||
@@ -165,7 +165,6 @@ describe("buildConnectorStartRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("isReusableConnectorSession", () => {
|
||||
it("rejects missing and terminal sessions", () => {
|
||||
expect(isReusableConnectorSession(undefined)).toBe(false);
|
||||
|
||||
@@ -235,9 +235,7 @@ export async function getOrCreateSessionId<
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
...(existingSession?.status
|
||||
? { status: existingSession.status }
|
||||
: {}),
|
||||
...(existingSession?.status ? { status: existingSession.status } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,15 @@ if (!isMainThread) {
|
||||
} finally {
|
||||
await disposeAll();
|
||||
}
|
||||
// The explicit process.exit below means beforeExit never fires, so a
|
||||
// startup-recorded auto-update must be applied here, after all runtime
|
||||
// teardown. It spawns detached and only when no other CLI is attached.
|
||||
try {
|
||||
const { applyDeferredUpdate } = await import("./commands/update");
|
||||
await applyDeferredUpdate();
|
||||
} catch {
|
||||
// Best-effort; never block exit on the updater.
|
||||
}
|
||||
process.exit(exitCode || (process.exitCode as number) || 0);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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 { palette } from "../tui/palette";
|
||||
import { useDialogPalette } from "../tui/hooks/use-theme";
|
||||
import {
|
||||
type DialogDismissKey,
|
||||
isAnyKeyDismiss,
|
||||
@@ -35,6 +35,7 @@ export function MigrationNoticeContent(
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const palette = useDialogPalette();
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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,
|
||||
@@ -1029,7 +1030,9 @@ export async function runCli(): Promise<void> {
|
||||
`${c.dim}[model-catalog] catalog resolution failed (${message})${c.reset}`,
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const knownModelIds = knownModels
|
||||
? Object.keys(filterChatModels(knownModels))
|
||||
: [];
|
||||
const resolvedReasoning = resolveCliReasoning({
|
||||
thinking: args.thinking,
|
||||
thinkingExplicitlySet: args.thinkingExplicitlySet,
|
||||
|
||||
@@ -1012,7 +1012,9 @@ Review with the bundled skill.`,
|
||||
const linear = data.mcp.find((item) => item.name === "linear");
|
||||
const docs = data.mcp.find((item) => item.name === "docs");
|
||||
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
|
||||
expect(linear?.description).toBe(
|
||||
"streamableHttp, oauth error, timeout 60s",
|
||||
);
|
||||
expect(linear?.loadError).toBe("OAuth authorization failed");
|
||||
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
|
||||
expect(docs?.loadError).toBeUndefined();
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ToolApprovalResult,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { createCliCore } from "../../session/session";
|
||||
import { submitAndExitInTerminal } from "../../utils/approval";
|
||||
import type {
|
||||
@@ -56,11 +56,11 @@ type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type CurrentMessagesRead =
|
||||
| { messages: Message[]; status: "read" }
|
||||
| { messages: Message[]; status: "recovered" }
|
||||
| { messages: Message[]; status: "stale" };
|
||||
| { messages: MessageWithMetadata[]; status: "read" }
|
||||
| { messages: MessageWithMetadata[]; status: "recovered" }
|
||||
| { messages: MessageWithMetadata[]; status: "stale" };
|
||||
type MissingSessionRecovery = {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
@@ -210,7 +210,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
initial: MessageWithMetadata[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
@@ -243,7 +243,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
const startResumedSession = async (
|
||||
resumeId: string,
|
||||
initial: Message[] | undefined,
|
||||
initial: MessageWithMetadata[] | undefined,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
@@ -421,7 +421,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
@@ -659,7 +659,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
const resumeSession = async (
|
||||
sessionId: string,
|
||||
): Promise<MessageWithMetadata[]> => {
|
||||
const manager = await ensureSessionManager();
|
||||
const sessionRecord = await manager.get(sessionId);
|
||||
if (!sessionRecord) {
|
||||
@@ -754,7 +756,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
const getCheckpointData = async (): Promise<
|
||||
| {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
checkpointHistory: CheckpointEntry[];
|
||||
}
|
||||
| undefined
|
||||
@@ -777,7 +779,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restoreCheckpoint = async (
|
||||
runCount: number,
|
||||
restoreWorkspace: boolean,
|
||||
): Promise<{ newSessionId: string; messages: Message[] } | undefined> => {
|
||||
): Promise<
|
||||
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
|
||||
> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager || !activeSessionId) {
|
||||
return undefined;
|
||||
|
||||
@@ -3,7 +3,9 @@ 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";
|
||||
@@ -79,9 +81,30 @@ 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[];
|
||||
@@ -90,7 +113,9 @@ export async function buildUserInputMessage(
|
||||
// First, resolve slash commands if the core config service is available.
|
||||
let prompt = rawPrompt;
|
||||
if (userInstructionService) {
|
||||
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt);
|
||||
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt, {
|
||||
expandSkillCommands: shouldExpandSkillSlashCommands(options?.mode),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasFileMentions(prompt)) {
|
||||
|
||||
@@ -277,7 +277,9 @@ export async function runAgent(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService);
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
mode: config.mode,
|
||||
});
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("applyInteractiveModelChange", () => {
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
modes: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
|
||||
@@ -621,7 +621,9 @@ export async function runInteractive(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionService);
|
||||
} = await buildUserInputMessage(input, userInstructionService, {
|
||||
mode,
|
||||
});
|
||||
const mergedUserImages = [
|
||||
...(attachments?.userImages ?? []),
|
||||
...userImages,
|
||||
|
||||
@@ -78,7 +78,11 @@ export async function runZen(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService);
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
// Zen runs in yolo mode, whose preset has no skills tool — skill
|
||||
// commands must keep expanding textually.
|
||||
mode: "yolo",
|
||||
});
|
||||
|
||||
const startRequest: ChatStartSessionRequest = {
|
||||
workspaceRoot,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
getCoreBuiltinToolCatalog,
|
||||
resolveDisabledToolNames,
|
||||
resolveModelToolSettings,
|
||||
type ToolCatalogEntry,
|
||||
} from "@cline/core";
|
||||
|
||||
@@ -10,8 +11,14 @@ export type { ToolCatalogEntry } from "@cline/core";
|
||||
export function getToolCatalog(
|
||||
availabilityContext?: BuiltinToolAvailabilityContext,
|
||||
): ToolCatalogEntry[] {
|
||||
const modelToolSettings = resolveModelToolSettings();
|
||||
return getCoreBuiltinToolCatalog({
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
enabledModelToolIds: new Set(
|
||||
Object.entries(modelToolSettings)
|
||||
.filter(([, setting]) => setting?.enabled === true)
|
||||
.map(([name]) => name),
|
||||
),
|
||||
...availabilityContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateConversationHTML } from "./export";
|
||||
|
||||
describe("generateConversationHTML", () => {
|
||||
it("renders provider model activity with the ordinary tool HTML", () => {
|
||||
const html = generateConversationHTML(
|
||||
{
|
||||
version: 1,
|
||||
updated_at: "2026-08-13T00:00:00.000Z",
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"session",
|
||||
);
|
||||
|
||||
expect(html).toContain("web_search");
|
||||
expect(html).toContain("latest Bun release");
|
||||
expect(html).toContain('<span class="success">Success</span>');
|
||||
expect(html).toContain("Bun 1.3.14 is current.");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
@@ -30,9 +31,12 @@ export function generateConversationHTML(
|
||||
data: ConversationHistory,
|
||||
fileName: string,
|
||||
): string {
|
||||
const displayMessages = projectSessionMessagesForDisplay(data.messages).map(
|
||||
({ message }) => message,
|
||||
);
|
||||
// Build tool results map
|
||||
const toolResultsMap = new Map<string, ToolResultContent>();
|
||||
data.messages.forEach((msg) => {
|
||||
displayMessages.forEach((msg) => {
|
||||
if (!isStringContent(msg.content)) {
|
||||
msg.content.forEach((block) => {
|
||||
if (block.type === "tool_result") {
|
||||
@@ -43,7 +47,7 @@ export function generateConversationHTML(
|
||||
});
|
||||
|
||||
// Filter messages (same logic as viewer)
|
||||
const filteredMessages = data.messages.filter((msg) => {
|
||||
const filteredMessages = displayMessages.filter((msg) => {
|
||||
if (msg.role === "assistant") return true;
|
||||
if (isStringContent(msg.content)) {
|
||||
return msg.content.trim().length > 0;
|
||||
@@ -696,6 +700,14 @@ function renderContentHTML(
|
||||
return renderToolUseHTML(block, toolResultsMap.get(block.id));
|
||||
case "tool_result":
|
||||
return ""; // Tool results are rendered with their corresponding tool_use
|
||||
case "image":
|
||||
return renderGeneratedMediaHTML({
|
||||
modality: "image",
|
||||
mediaType: block.mediaType,
|
||||
source: { type: "base64", data: block.data },
|
||||
});
|
||||
case "media":
|
||||
return renderGeneratedMediaHTML(block.media);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -703,6 +715,37 @@ function renderContentHTML(
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function renderGeneratedMediaHTML(media: {
|
||||
modality: "image" | "audio" | "video" | "file";
|
||||
mediaType: string;
|
||||
source:
|
||||
| { type: "base64"; data: string }
|
||||
| { type: "url"; url: string }
|
||||
| { type: "artifact"; artifactId: string };
|
||||
}): string {
|
||||
const source =
|
||||
media.source.type === "base64"
|
||||
? `data:${media.mediaType};base64,${media.source.data}`
|
||||
: media.source.type === "url"
|
||||
? media.source.url
|
||||
: undefined;
|
||||
if (!source) {
|
||||
return `<p class="generated-media">Generated ${escapeHtml(media.modality)} (${escapeHtml(media.mediaType)})</p>`;
|
||||
}
|
||||
const escapedSource = escapeHtml(source);
|
||||
const escapedType = escapeHtml(media.mediaType);
|
||||
switch (media.modality) {
|
||||
case "image":
|
||||
return `<img class="generated-media" src="${escapedSource}" alt="Generated image" />`;
|
||||
case "audio":
|
||||
return `<audio class="generated-media" controls src="${escapedSource}" type="${escapedType}"></audio>`;
|
||||
case "video":
|
||||
return `<video class="generated-media" controls src="${escapedSource}" type="${escapedType}"></video>`;
|
||||
case "file":
|
||||
return `<a class="generated-media" href="${escapedSource}" download>Generated file (${escapedType})</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextHTML(text: string): string {
|
||||
// Simple markdown-like rendering
|
||||
let html = escapeHtml(text);
|
||||
|
||||
@@ -2,10 +2,9 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
type ClineSubscriptionPlan,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
|
||||
@@ -169,6 +169,39 @@ 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,19 +245,33 @@ 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") {
|
||||
if (command?.execution !== "user-command" || skipCommand(command)) {
|
||||
return match;
|
||||
}
|
||||
return `${prefix}${formatUserCommandBlock(command.instructions, command.name)}`;
|
||||
@@ -272,7 +286,11 @@ export function expandUserCommandPrompt(
|
||||
return input;
|
||||
}
|
||||
const command = resolveSlashCommand(registry, match[1] ?? "");
|
||||
if (!command || command.execution !== "user-command") {
|
||||
if (
|
||||
!command ||
|
||||
command.execution !== "user-command" ||
|
||||
skipCommand(command)
|
||||
) {
|
||||
return input;
|
||||
}
|
||||
const rest = (match[2] ?? "").trim();
|
||||
|
||||
@@ -45,6 +45,13 @@ function trimLeading(text: string): string {
|
||||
return text.replace(/^\n+/, "");
|
||||
}
|
||||
|
||||
function formatMediaSize(byteLength: number): string {
|
||||
if (byteLength <= 0) return "unknown size";
|
||||
if (byteLength < 1024) return `${byteLength} B`;
|
||||
if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KiB`;
|
||||
return `${(byteLength / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function ReasoningBlock(props: { text: string; streaming: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { width } = useTerminalDimensions();
|
||||
@@ -640,10 +647,23 @@ export function ChatEntryView(props: {
|
||||
)}
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
{/*
|
||||
* internalBlockMode="top-level" keeps each markdown block as its
|
||||
* own renderable. The default coalesced mode merges the whole
|
||||
* message into one block that is torn down and re-highlighted on
|
||||
* every streamed chunk, which flashes already-rendered headings
|
||||
* and links back to raw uncolored markdown while tree-sitter
|
||||
* re-highlights asynchronously. Top-level blocks are reused by
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
</box>
|
||||
@@ -651,6 +671,20 @@ export function ChatEntryView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
case "assistant_media":
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<box width={2}>
|
||||
<text fg={accent}>*</text>
|
||||
</box>
|
||||
<text fg={defaultFg} selectable>
|
||||
{entry.location
|
||||
? `Generated ${entry.modality} (${entry.mediaType}, ${formatMediaSize(entry.byteLength)}): ${entry.location}`
|
||||
: `Generated ${entry.modality} (${entry.mediaType}) could not be saved`}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
case "reasoning":
|
||||
return <ReasoningBlock text={entry.text} streaming={entry.streaming} />;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export type AccountDialogAction =
|
||||
| "change-model"
|
||||
@@ -121,6 +121,7 @@ function AccountActionRow(props: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
const fg = props.selected ? palette.textOnSelection : undefined;
|
||||
return (
|
||||
<box
|
||||
@@ -138,7 +139,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}
|
||||
@@ -161,6 +162,7 @@ function OrganizationRow(props: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const palette = useDialogPalette();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -177,7 +179,7 @@ function OrganizationRow(props: {
|
||||
fg={props.selected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.selected ? ">" : " "}
|
||||
{props.selected ? "❯" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={props.selected ? palette.textOnSelection : undefined}
|
||||
@@ -234,6 +236,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export function AskQuestionContent(
|
||||
props: ChoiceContext<string | null> & {
|
||||
@@ -11,6 +11,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export type CheckpointRestoreMode = "chat-only" | "chat-and-workspace";
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export interface CheckpointPickerItem {
|
||||
runCount: number;
|
||||
@@ -36,6 +36,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
buildCommandPaletteItems,
|
||||
type CommandPaletteResult,
|
||||
@@ -32,6 +32,7 @@ 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,6 +23,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
type HelpRow =
|
||||
| { kind: "heading"; id: string; text: string }
|
||||
@@ -255,6 +255,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
@@ -12,6 +12,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export interface McpEntry {
|
||||
name: string;
|
||||
@@ -70,6 +70,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
@@ -63,6 +63,7 @@ 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);
|
||||
@@ -272,6 +273,7 @@ export function UseExistingOrReconfigureContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
|
||||
const palette = useDialogPalette();
|
||||
const options: ExistingProviderOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
@@ -351,6 +353,7 @@ function ClinePassBrowserPageContent(
|
||||
url,
|
||||
openedStatus,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState("Opening browser...");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -489,6 +492,7 @@ export function ProviderConfigInputContent(
|
||||
providerName,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
const config = useMemo(
|
||||
() => getProviderConfigFields(providerId),
|
||||
@@ -656,6 +660,7 @@ export function CodexCliStatusContent(
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
@@ -749,6 +754,7 @@ export function OAuthLoginContent(
|
||||
providerName,
|
||||
allowApiKeyFallback,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [mode, setMode] = useState<"browser" | "device">(
|
||||
providerId === "cline" ? "device" : "browser",
|
||||
);
|
||||
@@ -969,6 +975,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export const SKILLS_MARKETPLACE_ACTION = "__skills_marketplace__";
|
||||
export const SKILLS_MARKETPLACE_URL = "https://skills.sh/";
|
||||
@@ -26,6 +26,7 @@ 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,8 +2,7 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useThemeController } from "../../hooks/use-theme";
|
||||
import { palette } from "../../palette";
|
||||
import { useDialogPalette, useThemeController } from "../../hooks/use-theme";
|
||||
import { getThemeSwatchColors, THEMES } from "../../themes";
|
||||
|
||||
const SWATCH_BLOCK = "\u25a0";
|
||||
@@ -12,6 +11,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
@@ -139,6 +139,7 @@ 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,7 +7,8 @@ import {
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type { DialogPalette } from "../../themes";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -24,7 +25,7 @@ export {
|
||||
freeTierDescriptionFor,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
function tagColor(tag: string, palette: DialogPalette): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
@@ -58,6 +59,7 @@ export function ClineModelPicker(props: {
|
||||
currentModelId?: string;
|
||||
}) {
|
||||
const { entries, selected, loading, currentModelId } = props;
|
||||
const palette = useDialogPalette();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -119,7 +121,7 @@ export function ClineModelPicker(props: {
|
||||
{tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import type { DialogPalette } from "../../themes";
|
||||
import {
|
||||
CLINE_MODEL_PICKER_TIER_LABELS,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -18,7 +19,7 @@ type ClineModelEntriesState =
|
||||
| { status: "loaded"; entries: ClineModelPickerEntry[] }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
function tagColor(tag: string, palette: DialogPalette): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
if (tag === "BEST") return "magenta";
|
||||
return palette.act;
|
||||
@@ -39,6 +40,7 @@ export function ClineModelSelectorContent(
|
||||
currentProviderName,
|
||||
entries,
|
||||
} = props;
|
||||
const palette = useDialogPalette();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
|
||||
@@ -188,7 +190,7 @@ export function ClineModelSelectorContent(
|
||||
{row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
@@ -222,6 +224,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
export interface ModelOption {
|
||||
@@ -65,6 +65,7 @@ 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);
|
||||
@@ -328,6 +329,7 @@ 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);
|
||||
@@ -516,6 +518,7 @@ function CreateCustomModelRow(props: {
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { isSelected, dimmed, onSelect } = props;
|
||||
const palette = useDialogPalette();
|
||||
const active = isSelected && !dimmed;
|
||||
const bg = active
|
||||
? palette.selection
|
||||
@@ -553,6 +556,7 @@ 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 { palette } from "../../palette";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
|
||||
export function ProviderRow({
|
||||
providerName,
|
||||
@@ -8,6 +8,7 @@ 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}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveNonCompactionStatusLabel } from "../../utils/events";
|
||||
import { materializeGeneratedMedia } from "../../utils/generated-media";
|
||||
import {
|
||||
formatToolInput,
|
||||
formatToolOutput,
|
||||
@@ -205,6 +206,26 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
closeToolEntry(event);
|
||||
break;
|
||||
}
|
||||
case "media": {
|
||||
closeInlineStream();
|
||||
const media = event.media;
|
||||
if (!media) break;
|
||||
const saved = materializeGeneratedMedia(media);
|
||||
appendEntry({
|
||||
kind: "assistant_media",
|
||||
modality: media.modality,
|
||||
mediaType: media.mediaType,
|
||||
byteLength: saved?.byteLength ?? media.sizeBytes ?? 0,
|
||||
location:
|
||||
saved?.path ??
|
||||
(media.source.type === "url"
|
||||
? media.source.url
|
||||
: media.source.type === "artifact"
|
||||
? `artifact:${media.source.artifactId}`
|
||||
: undefined),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -308,6 +309,12 @@ 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,6 +1,12 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { TerminalTheme } from "../palette";
|
||||
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
type DialogPalette,
|
||||
getDialogPalette,
|
||||
type ResolvedTheme,
|
||||
resolveTheme,
|
||||
} from "../themes";
|
||||
|
||||
export interface TerminalColors {
|
||||
background: string | null;
|
||||
@@ -41,6 +47,17 @@ 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,6 +169,15 @@ 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,6 +23,7 @@ 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,
|
||||
@@ -584,6 +585,18 @@ function App(props: TuiProps) {
|
||||
if (!hubBuildMismatch) return;
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
if (hubBuildMismatch.reason === "outdated_hub") {
|
||||
// This CLI is already the newer build. The Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving, and it is
|
||||
// replaced on its own at the next launch. Nothing is wrong, nothing is
|
||||
// asked, and nothing the user can act on differs - so say nothing, the
|
||||
// same conclusion the desktop surface reached.
|
||||
//
|
||||
// The classification still earns its keep here: it is what stops the
|
||||
// update-and-restart prompt below from firing at someone who has
|
||||
// nothing to update.
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
@@ -1026,6 +1039,7 @@ export function Root(
|
||||
<TerminalColorsContext value={terminalColors}>
|
||||
<ThemeProvider initialThemeId={props.initialThemeId}>
|
||||
<DialogProvider size="medium">
|
||||
<DialogThemeSync />
|
||||
<SessionProvider
|
||||
config={props.config}
|
||||
initialEntries={initialEntries}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { diffPalettes, themePalette } from "./palette";
|
||||
import {
|
||||
AUTO_THEME_ID,
|
||||
DEFAULT_DIALOG_SURFACE,
|
||||
getDialogAccents,
|
||||
getDialogPalette,
|
||||
getDialogSurface,
|
||||
getThemeDefinition,
|
||||
getThemeModeAccent,
|
||||
getThemeSwatchColors,
|
||||
@@ -164,4 +167,66 @@ 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,6 +1,7 @@
|
||||
import {
|
||||
diffPalettes,
|
||||
getDefaultForeground,
|
||||
getDialogSurfaceBackground,
|
||||
getTerminalTheme,
|
||||
hexToOklab,
|
||||
oklabToHex,
|
||||
@@ -567,6 +568,53 @@ 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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TeamEvent,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
Message,
|
||||
MessageWithMetadata,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/shared";
|
||||
@@ -29,6 +29,13 @@ import type { InteractiveSlashCommand } from "./interactive-welcome";
|
||||
export type ChatEntry = (
|
||||
| { kind: "user"; text: string }
|
||||
| { kind: "assistant_text"; text: string; streaming: boolean }
|
||||
| {
|
||||
kind: "assistant_media";
|
||||
modality: "image" | "audio" | "video" | "file";
|
||||
mediaType: string;
|
||||
byteLength: number;
|
||||
location?: string;
|
||||
}
|
||||
| { kind: "reasoning"; text: string; streaming: boolean }
|
||||
| {
|
||||
kind: "tool_call";
|
||||
@@ -92,7 +99,7 @@ export interface InteractiveTurnResult {
|
||||
}
|
||||
|
||||
export interface ResumedSessionResult {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
totalCost?: number;
|
||||
currentContextSize?: number;
|
||||
}
|
||||
@@ -145,7 +152,7 @@ export interface TuiProps {
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
loadDeferredInitialMessages?: () => Promise<ResumedSessionResult>;
|
||||
initialRepoStatus?: RepoStatus;
|
||||
workflowSlashCommands?: InteractiveSlashCommand[];
|
||||
@@ -218,12 +225,18 @@ export interface TuiProps {
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
| {
|
||||
messages: MessageWithMetadata[];
|
||||
checkpointHistory: CheckpointEntry[];
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
onRestoreCheckpoint: (
|
||||
runCount: number,
|
||||
restoreWorkspace: boolean,
|
||||
) => Promise<{ newSessionId: string; messages: Message[] } | undefined>;
|
||||
) => Promise<
|
||||
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
|
||||
>;
|
||||
setToolApprover: (
|
||||
approver:
|
||||
| ((request: ToolApprovalRequest) => Promise<ToolApprovalResult>)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { Message, MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
@@ -106,6 +108,7 @@ describe("hydrateSessionMessages", () => {
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "switch_to_act_mode",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: {},
|
||||
@@ -155,6 +158,7 @@ describe("hydrateSessionMessages", () => {
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
inputSummary: "",
|
||||
rawInput: { command: null },
|
||||
@@ -180,4 +184,119 @@ describe("hydrateSessionMessages", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("materializes generated images from resumed assistant history", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.from("history-image").toString("base64"),
|
||||
mediaType: "image/webp",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
const [entry] = hydrateSessionMessages(messages);
|
||||
expect(entry).toMatchObject({
|
||||
kind: "assistant_media",
|
||||
modality: "image",
|
||||
mediaType: "image/webp",
|
||||
byteLength: 13,
|
||||
mode: undefined,
|
||||
});
|
||||
if (entry?.kind !== "assistant_media" || !entry.location) {
|
||||
throw new Error("Expected a materialized assistant image");
|
||||
}
|
||||
try {
|
||||
expect(readFileSync(entry.location, "utf8")).toBe("history-image");
|
||||
} finally {
|
||||
rmSync(dirname(entry.location), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("hydrates provider model tools through the ordinary tool card path", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is the latest stable release.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun stable release" },
|
||||
output: { sources: ["https://bun.sh/blog/bun-v1.3.14"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: { query: "latest Bun stable release" },
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
result: {
|
||||
outputSummary: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
|
||||
rawOutput: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Bun 1.3.14 is the latest stable release.",
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates structured native search output and mirrors live error payloads", () => {
|
||||
const nativeResult = {
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
};
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Search failed.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-native",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: [nativeResult],
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [toolEntry] = hydrateSessionMessages(messages);
|
||||
expect(toolEntry).toMatchObject({
|
||||
kind: "tool_call",
|
||||
toolCallId: "search-native",
|
||||
result: {
|
||||
outputSummary: "",
|
||||
rawOutput: undefined,
|
||||
error: JSON.stringify([nativeResult]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import { type AgentMode, projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type GeneratedMedia,
|
||||
type MessageWithMetadata,
|
||||
parseUserInputMode,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { materializeGeneratedMedia } from "../../utils/generated-media";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
type PersistedMessage = Message & {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
function getDisplayRole(msg: MessageWithMetadata): string | undefined {
|
||||
const role = msg.metadata?.displayRole;
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
@@ -33,13 +31,29 @@ function stringifyToolResult(
|
||||
return block.text;
|
||||
if (block.type === "file" && typeof block.path === "string")
|
||||
return `Attached file: ${block.path}`;
|
||||
return "";
|
||||
if (block.type === "image") return "[image]";
|
||||
try {
|
||||
return JSON.stringify(block);
|
||||
} catch {
|
||||
return String(block);
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
function stringifyToolError(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
try {
|
||||
return JSON.stringify(content) ?? String(content);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateSessionMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
): ChatEntry[] {
|
||||
const entries: ChatEntry[] = [];
|
||||
const toolUseMap = new Map<string, number>();
|
||||
// Mode each entry was produced in, recovered from <user_input mode="...">
|
||||
@@ -49,7 +63,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
// wrappers on session restarts).
|
||||
let mode: AgentMode | undefined;
|
||||
|
||||
for (const msg of messages as PersistedMessage[]) {
|
||||
for (const { message: msg } of projectSessionMessagesForDisplay(messages)) {
|
||||
const displayRole = getDisplayRole(msg);
|
||||
if (displayRole === "system" || displayRole === "status") {
|
||||
continue;
|
||||
@@ -76,6 +90,39 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
const userTextParts: string[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (
|
||||
msg.role === "assistant" &&
|
||||
(block.type === "image" || block.type === "media")
|
||||
) {
|
||||
const media: GeneratedMedia =
|
||||
block.type === "media"
|
||||
? block.media
|
||||
: {
|
||||
id: `${msg.id ?? "history"}:media:${entries.length}`,
|
||||
modality: "image",
|
||||
mediaType: block.mediaType,
|
||||
source: { type: "base64", data: block.data },
|
||||
};
|
||||
if (media.source.type !== "base64" || media.source.data.length > 0) {
|
||||
const saved = materializeGeneratedMedia(media);
|
||||
entries.push({
|
||||
kind: "assistant_media",
|
||||
modality: media.modality,
|
||||
mediaType: media.mediaType,
|
||||
byteLength: saved?.byteLength ?? media.sizeBytes ?? 0,
|
||||
location:
|
||||
saved?.path ??
|
||||
(media.source.type === "url"
|
||||
? media.source.url
|
||||
: media.source.type === "artifact"
|
||||
? `artifact:${media.source.artifactId}`
|
||||
: undefined),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
if (msg.role === "user") {
|
||||
userTextParts.push(block.text);
|
||||
@@ -107,6 +154,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (block.type === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
toolCallId: block.id,
|
||||
toolName: block.name,
|
||||
inputSummary: formatToolInput(block.name, block.input),
|
||||
rawInput: block.input,
|
||||
@@ -132,11 +180,16 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
| string
|
||||
| Array<{ type: string; text?: string; path?: string }>,
|
||||
);
|
||||
entry.result = {
|
||||
outputSummary: resultText.slice(0, 500),
|
||||
rawOutput: block.content,
|
||||
error: block.is_error ? resultText : undefined,
|
||||
};
|
||||
const error = block.is_error
|
||||
? stringifyToolError(block.content)
|
||||
: undefined;
|
||||
entry.result = error
|
||||
? { outputSummary: "", rawOutput: undefined, error }
|
||||
: {
|
||||
outputSummary: resultText.slice(0, 500),
|
||||
rawOutput: block.content,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
|
||||
import { resolveModelDisplayName } from "../components/status-bar";
|
||||
import { useThemeController } from "../hooks/use-theme";
|
||||
import { palette } from "../palette";
|
||||
import { getDialogAccents, getThemeDefinition } from "../themes";
|
||||
import { useDialogPalette, useThemeController } from "../hooks/use-theme";
|
||||
import { type DialogPalette, getThemeDefinition } from "../themes";
|
||||
import {
|
||||
type ConfigAction,
|
||||
canDeleteConfigFooterRow,
|
||||
@@ -118,11 +117,13 @@ function getVisibleWindow<T>(
|
||||
return { items: items.slice(start, end), startIndex: start };
|
||||
}
|
||||
|
||||
const COMPACTION_MODE_COLORS: Record<CliCompactionMode, string> = {
|
||||
agentic: palette.success,
|
||||
basic: "yellow",
|
||||
off: "gray",
|
||||
};
|
||||
function getCompactionModeColor(
|
||||
mode: CliCompactionMode,
|
||||
palette: DialogPalette,
|
||||
): string {
|
||||
if (mode === "agentic") return palette.success;
|
||||
return mode === "basic" ? "yellow" : "gray";
|
||||
}
|
||||
|
||||
export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
|
||||
config: Config;
|
||||
@@ -409,7 +410,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const [toggleError, setToggleError] = useState<string | undefined>();
|
||||
const [navPos, setNavPos] = useState(0);
|
||||
const themeController = useThemeController();
|
||||
const dialogAccents = getDialogAccents(themeController.theme);
|
||||
const palette = useDialogPalette();
|
||||
const currentThemeLabel =
|
||||
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
|
||||
|
||||
@@ -823,11 +824,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
let valueColor: string;
|
||||
if (row.id === "mode") {
|
||||
value = mode === "plan" ? "Plan" : "Act";
|
||||
valueColor =
|
||||
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
|
||||
valueColor = mode === "plan" ? palette.plan : palette.act;
|
||||
} else if (row.id === "theme") {
|
||||
value = currentThemeLabel;
|
||||
valueColor = dialogAccents.act;
|
||||
valueColor = palette.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 = COMPACTION_MODE_COLORS[compactionMode];
|
||||
valueColor = getCompactionModeColor(compactionMode, palette);
|
||||
} 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 { palette } from "../palette";
|
||||
import { useDialogPalette } from "../hooks/use-theme";
|
||||
import {
|
||||
buildHistoryFooterText,
|
||||
HISTORY_EXPORT_OPTIONS,
|
||||
@@ -98,6 +98,7 @@ function HistoryListContent({
|
||||
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
|
||||
registerKeyHandler,
|
||||
}: HistoryListContentProps) {
|
||||
const palette = useDialogPalette();
|
||||
const { width } = useTerminalDimensions();
|
||||
const [rows, setRows] = useState<SessionHistoryRecord[]>(
|
||||
() => initialRows ?? [],
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -233,7 +234,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) {
|
||||
for (const m of result.value.models.filter(isChatProviderModel)) {
|
||||
if (m.supportsReasoning) ids.add(m.id);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +284,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId,
|
||||
providerConfig,
|
||||
);
|
||||
return models.map(toModelEntry);
|
||||
return models.filter(isChatProviderModel).map(toModelEntry);
|
||||
})
|
||||
.then((models) => {
|
||||
setModelEntries(models);
|
||||
|
||||
@@ -128,6 +128,31 @@ 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,3 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -151,11 +157,16 @@ 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 {
|
||||
@@ -185,6 +196,13 @@ 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,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -28,7 +28,7 @@ describe("cline-pass-errors", () => {
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
|
||||
@@ -21,11 +21,11 @@ export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
if(!CLI_PROMO_CODE) {
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -118,6 +120,41 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toMatch(/\[run_commands\].*\n.*\[read_files\]/s);
|
||||
});
|
||||
|
||||
it("saves generated images and prints an openable path", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "content_end",
|
||||
contentType: "media",
|
||||
media: {
|
||||
id: "generated-1",
|
||||
modality: "image",
|
||||
mediaType: "image/png",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: Buffer.from("one-shot-image").toString("base64"),
|
||||
},
|
||||
},
|
||||
} as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(output).toContain("[generated image]");
|
||||
const suffix = "/generated.png";
|
||||
const pathEnd = output.indexOf(suffix);
|
||||
const pathStart = output.lastIndexOf(" ", pathEnd);
|
||||
const path =
|
||||
pathEnd >= 0 && pathStart >= 0
|
||||
? output.slice(pathStart + 1, pathEnd + suffix.length)
|
||||
: undefined;
|
||||
expect(path).toBeDefined();
|
||||
if (!path) throw new Error("Expected generated image path in CLI output");
|
||||
try {
|
||||
expect(readFileSync(path, "utf8")).toBe("one-shot-image");
|
||||
} finally {
|
||||
rmSync(dirname(path), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not echo ask_question through the generic tool renderer", () => {
|
||||
handleEvent(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "../tui/utils/compaction-status";
|
||||
import { formatCliErrorMessage } from "./cline-pass-errors";
|
||||
import { materializeGeneratedMedia } from "./generated-media";
|
||||
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
|
||||
import {
|
||||
c,
|
||||
@@ -184,6 +185,30 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
}
|
||||
shouldPrefixNextTextWithBlankLine = false;
|
||||
break;
|
||||
case "media": {
|
||||
closeInlineStreamIfNeeded();
|
||||
const media = event.media;
|
||||
if (!media) break;
|
||||
const saved = materializeGeneratedMedia(media);
|
||||
if (saved) {
|
||||
write(
|
||||
`${c.dim}[generated ${media.modality}]${c.reset} ${saved.path}\n`,
|
||||
);
|
||||
} else if (media.source.type === "url") {
|
||||
write(
|
||||
`${c.dim}[generated ${media.modality}]${c.reset} ${media.source.url}\n`,
|
||||
);
|
||||
} else if (media.source.type === "artifact") {
|
||||
write(
|
||||
`${c.dim}[generated ${media.modality}]${c.reset} artifact:${media.source.artifactId}\n`,
|
||||
);
|
||||
} else {
|
||||
write(
|
||||
`${c.dim}[generated ${media.modality}]${c.reset} ${media.mediaType} could not be saved\n`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupMaterializedGeneratedMedia,
|
||||
materializeGeneratedMedia,
|
||||
} from "./generated-media";
|
||||
|
||||
describe("materializeGeneratedMedia", () => {
|
||||
afterEach(cleanupMaterializedGeneratedMedia);
|
||||
|
||||
it("writes decoded media data to a private temporary file", () => {
|
||||
const saved = materializeGeneratedMedia({
|
||||
id: "generated-1",
|
||||
modality: "image",
|
||||
mediaType: "image/png",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: Buffer.from("generated-image").toString("base64"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(saved).toBeDefined();
|
||||
if (!saved) throw new Error("Expected generated media to be saved");
|
||||
expect(saved).toMatchObject({ mediaType: "image/png", byteLength: 15 });
|
||||
expect(saved.path).toMatch(/generated\.png$/);
|
||||
expect(readFileSync(saved.path, "utf8")).toBe("generated-image");
|
||||
});
|
||||
|
||||
it("cleans up materialized media directories", () => {
|
||||
const saved = materializeGeneratedMedia({
|
||||
id: "generated-cleanup",
|
||||
modality: "audio",
|
||||
mediaType: "audio/mpeg",
|
||||
source: { type: "base64", data: "SUQz" },
|
||||
});
|
||||
|
||||
expect(saved).toBeDefined();
|
||||
if (!saved) throw new Error("Expected generated media to be saved");
|
||||
const directory = dirname(saved.path);
|
||||
expect(existsSync(directory)).toBe(true);
|
||||
|
||||
cleanupMaterializedGeneratedMedia();
|
||||
|
||||
expect(existsSync(directory)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-materializable payloads", () => {
|
||||
expect(
|
||||
materializeGeneratedMedia({
|
||||
id: "generated-remote",
|
||||
modality: "audio",
|
||||
mediaType: "audio/mpeg",
|
||||
source: { type: "url", url: "https://example.com/audio.mp3" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { GeneratedMedia } from "@cline/shared";
|
||||
|
||||
export interface MaterializedGeneratedMedia {
|
||||
path: string;
|
||||
mediaType: string;
|
||||
modality: GeneratedMedia["modality"];
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
const materializedMediaDirectories = new Set<string>();
|
||||
let exitCleanupRegistered = false;
|
||||
|
||||
/** Remove temporary files created for terminal display. */
|
||||
export function cleanupMaterializedGeneratedMedia(): void {
|
||||
for (const directory of materializedMediaDirectories) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
materializedMediaDirectories.clear();
|
||||
}
|
||||
|
||||
function trackMaterializedMediaDirectory(directory: string): void {
|
||||
materializedMediaDirectories.add(directory);
|
||||
if (exitCleanupRegistered) return;
|
||||
exitCleanupRegistered = true;
|
||||
process.once("exit", cleanupMaterializedGeneratedMedia);
|
||||
}
|
||||
|
||||
const MEDIA_EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/avif": "avif",
|
||||
"image/svg+xml": "svg",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/ogg": "ogg",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist generated media where terminal users can open it with their usual
|
||||
* local tools. Each item gets a private temporary directory so concurrent
|
||||
* sessions cannot overwrite one another.
|
||||
*/
|
||||
export function materializeGeneratedMedia(
|
||||
media: GeneratedMedia,
|
||||
): MaterializedGeneratedMedia | undefined {
|
||||
const mediaType = media.mediaType.trim().toLowerCase();
|
||||
if (media.source.type !== "base64" || media.source.data.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let directory: string | undefined;
|
||||
try {
|
||||
const bytes = Buffer.from(media.source.data, "base64");
|
||||
if (bytes.byteLength === 0) {
|
||||
return undefined;
|
||||
}
|
||||
directory = mkdtempSync(join(tmpdir(), "cline-generated-media-"));
|
||||
const extension = MEDIA_EXTENSIONS[mediaType] ?? "bin";
|
||||
const path = join(directory, `generated.${extension}`);
|
||||
writeFileSync(path, bytes, { mode: 0o600 });
|
||||
trackMaterializedMediaDirectory(directory);
|
||||
return {
|
||||
path,
|
||||
mediaType,
|
||||
modality: media.modality,
|
||||
byteLength: bytes.byteLength,
|
||||
};
|
||||
} catch {
|
||||
if (directory !== undefined) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ClineCore } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
export async function loadInteractiveResumeMessages(
|
||||
sessionManager: ClineCore,
|
||||
resumeSessionId?: string,
|
||||
): Promise<Message[] | undefined> {
|
||||
): Promise<MessageWithMetadata[] | undefined> {
|
||||
const target = resumeSessionId?.trim();
|
||||
if (!target) {
|
||||
return undefined;
|
||||
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["src/**/*.e2e.test.ts", "src/tests/**"],
|
||||
// Default 5s is tight on CI: each test uses `resetModules()` + dynamic `import("./main")`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Unit tests (and any subprocess they spawn, via env inheritance) must never
|
||||
// touch the developer's real ~/.cline: a test that reaches core startup can
|
||||
// otherwise spawn a real hub daemon against the real discovery record, or
|
||||
// trigger a real background auto-update. Point everything at a per-worker
|
||||
// temp dir before any test file is imported. Tests that need specific paths
|
||||
// still override these per-test.
|
||||
const isolatedRoot = mkdtempSync(join(tmpdir(), "cline-cli-vitest-"));
|
||||
process.env.CLINE_DIR = join(isolatedRoot, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(isolatedRoot, "data");
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = join(isolatedRoot, "hub-discovery.json");
|
||||
process.env.CLINE_NO_AUTO_UPDATE = "1";
|
||||
@@ -94,6 +94,13 @@ function forwardAgentEvent(
|
||||
error: event.error,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.contentType === "media" && event.media) {
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "assistant_media",
|
||||
media: event.media,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export async function handleDesktopCommand(
|
||||
return path;
|
||||
}
|
||||
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
|
||||
}
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot, cwd: workspaceRoot };
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isChatCompatibleModel } from "@cline/shared";
|
||||
import type {
|
||||
WebviewInboundMessage,
|
||||
WebviewProviderModel,
|
||||
@@ -86,12 +87,25 @@ export async function loadModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
}));
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter((model) =>
|
||||
isChatCompatibleModel({
|
||||
operation: model.operation,
|
||||
modalities: {
|
||||
input: model.inputModalities,
|
||||
output: model.outputModalities,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
operation: model.operation,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
inputModalities: model.inputModalities,
|
||||
outputModalities: model.outputModalities,
|
||||
}));
|
||||
ctx.send(peer, { type: "models", providerId: provider, models });
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,20 @@ 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,
|
||||
});
|
||||
const reply = await getCommands().handleCommand(
|
||||
{
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
clientId: "cline-hub-schedules",
|
||||
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
|
||||
},
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
@@ -70,14 +77,17 @@ 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([
|
||||
clientCommand("schedule.list", {
|
||||
commandHub("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
commandHub("schedule.active"),
|
||||
commandHub("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const scheduleRows = Array.isArray(schedules.schedules)
|
||||
? schedules.schedules
|
||||
@@ -88,7 +98,7 @@ export async function handleRoutineScheduleCommand(
|
||||
(schedule as Record<string, unknown>).scheduleId,
|
||||
);
|
||||
if (!scheduleId) return undefined;
|
||||
const reply = await clientCommand("schedule.list_executions", {
|
||||
const reply = await commandHub("schedule.list_executions", {
|
||||
scheduleId,
|
||||
limit: 1,
|
||||
});
|
||||
@@ -115,7 +125,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
const created = await commandHub("schedule.create", {
|
||||
name,
|
||||
...timing,
|
||||
prompt,
|
||||
@@ -149,7 +159,7 @@ export async function handleRoutineScheduleCommand(
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
const reply = await commandHub("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
...timing,
|
||||
@@ -180,25 +190,25 @@ export async function handleRoutineScheduleCommand(
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
const reply = await commandHub("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
const reply = await commandHub("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const existing = await clientCommand("schedule.get", { scheduleId });
|
||||
const existing = await commandHub("schedule.get", { scheduleId });
|
||||
if (!existing.schedule)
|
||||
throw new Error(`schedule not found: ${scheduleId}`);
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
const reply = await commandHub("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
const reply = await commandHub("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("preserves tolerant handling of malformed history entries", () => {
|
||||
expect(() => mapHistoryToWebviewMessages([null, 42])).not.toThrow();
|
||||
});
|
||||
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
@@ -228,4 +232,79 @@ describe("mapHistoryToWebviewMessages", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates provider model activities through ordinary tool events", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: { answer: "1.3.14" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
name: "web_search",
|
||||
state: "output-available",
|
||||
input: { query: "latest Bun release" },
|
||||
output: '{"answer":"1.3.14"}',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
text: "Bun 1.3.14 is current.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps id-less history row ids stable as a provider result completes", () => {
|
||||
const source = {
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const pending = mapHistoryToWebviewMessages([source]);
|
||||
const completed = mapHistoryToWebviewMessages([
|
||||
{
|
||||
...source,
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
...source.metadata.modelToolActivities[0],
|
||||
output: "1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pending.at(-1)?.id).toBe("history-0");
|
||||
expect(completed.at(-1)?.id).toBe("history-0");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user