Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 70929a1c89 chore(desktop): release v0.0.32 2026-09-17 18:08:32 -07:00
Bee 706ceac669 feat(ci): add nightly desktop publish workflow (#14244)
* feat(ci): add nightly desktop publish workflow

Support manually dispatched nightly desktop builds from main, with auto-generated version tags and conflict-safe concurrency.

* fix(desktop): keep nightly builds as Actions artifacts
2026-09-17 17:39:14 -07:00
Saoud Rizwan 999a2b8e7b fix(desktop): claim the Hub daemon sentinel in the sidecar entrypoint (#14243)
* fix(desktop): claim the Hub daemon sentinel in the sidecar entrypoint

Since #14117 the sidecar left the daemon sentinel claim to the shared
remote helper in @cline/core, whose daemon import resolves to the dist
build of core, while the sidecar bundle itself resolves core from source
through tsconfig paths. The daemon therefore published a different build
id than the sidecar expected, the sidecar retired its own Hub on every
launch, timed out with "No compatible hub runtime is available", and
Tauri reported "desktop backend exited before publishing its endpoint"
(desktop 0.0.31). Claim the sentinel in the sidecar again so the daemon
loads from the same copy of core.

* test(desktop): start the compiled sidecar against an isolated Hub in CI

Source-level tests cannot catch a build identity split between the
desktop client and its embedded Hub daemon, so compile the real
entrypoint, bootstrap an isolated Hub with --remote-hub-ensure on an
OS-assigned port and a dedicated discovery path, pin the sidecar to that
port, and require the ready line plus a healthy /health response. Fails
with "No compatible hub runtime is available" without the entrypoint fix.
2026-09-17 17:06:36 -07:00
Saoud Rizwan d6ede1a89c fix(desktop): seed Bun's cache so the Windows publish job can cross-compile the Linux SSH helpers
build-sidecar-bin.ts cross-compiles the Linux remote helpers on every
platform, and the Windows installer ships them as Tauri resources. Bun
cross-compiles by downloading the target runtime's release zip and
extracting the executable, and on a Windows host that extraction fails
deterministically ("Failed to extract executable for
'bun-linux-x64-v1.3.13'. The download may be incomplete.") even though
the download and archive are intact. This broke the 0.0.31 publish run
twice on fresh runners; the macOS job builds the same helpers fine.

Bun only downloads when $BUN_INSTALL_CACHE_DIR lacks
bun-<target>-v<version>, so the Windows job now seeds both Linux
runtimes from the @oven/bun-<target> npm packages (byte-identical to
the zip payload) before building, and the broken extractor never runs.
2026-09-17 14:14:19 -07:00
John Choi a9ca528141 feat(desktop): add cloud session interactions (#13857)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* feat(desktop): add cloud session foundations

* fix(desktop): require boolean cloud rollout flag

* test(desktop): run sidecar suite in pull requests

* ci: test desktop changes in stacked pull requests

* fix(desktop): scope cloud model catalog rollout

* fix(sdk): clear feature flags on identity change

* test(hub): reconnect approval session owner

* feat(desktop): add cloud session REST client

* feat(desktop): add cloud transcript reconciliation

* feat(desktop): add cloud session lifecycle

* feat(desktop): add cloud session interactions

* feat(desktop): add cloud Hub runtime

* test(desktop): cover cloud session REST client

* test(desktop): cover cloud session lifecycle

* test(desktop): keep lifecycle fixture layer-local

* test(desktop): cover cloud session Hub runtime

* fix(desktop): preserve cloud status ordering

* fix(desktop): guard unsequenced status snapshots

* docs(sdk): clarify remote Hub connection headers

* docs(hub): describe approval recovery

* docs(llms): describe cloud catalog opt-in

* test(sdk): tighten hub header coverage

* test(hub): remove redundant approval setup

* chore(desktop): trim cloud foundation scaffolding

* test(desktop): trim cloud REST coverage

* test(desktop): consolidate cloud API cases

* test(desktop): remove unused lifecycle fixtures

* chore(desktop): trim cloud interaction comments

* test(desktop): consolidate cloud hub coverage

* test(desktop): complete lifecycle context fixture

* fix(hub): preserve approval recovery for existing clients

* fix(desktop): reconcile submitted prompts against cloud history

* test(core): batch root history fixture inserts

* test(desktop): align cloud fixtures with stream context

* test(desktop): align cloud fixtures with stream context

* fix(desktop): preserve reflected queued prompt lifecycle

* test(sdk): await daemon health after discovery publication

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* test(hub): reconnect approval session owner

* docs(hub): describe approval recovery

* test(hub): remove redundant approval setup

* fix(hub): preserve approval recovery for existing clients

* refactor(desktop): remove pending approval lookup dependency

* refactor(hub): drop cloud-only pending approval API

* fix(desktop): recover cloud approvals through existing Hub replay

* fix(desktop): rebuild approval subscription in initial runtime layer

* test(desktop): consolidate cloud Hub test setup

* test(desktop): scope cloud runtime tests to connection behavior

* test(desktop): cover cloud interactions in their owning layer

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* refactor(desktop): defer cloud interaction helpers to their owning layers

* fix(desktop): preserve cached flags and share account auth

* fix(desktop): return cloud session identity before provisioning completes

* fix(desktop): preserve the existing Hub boundary during readiness gating

* test(desktop): provide canonical rows during provisioning readiness

* fix(desktop): cancel cloud prompts still awaiting dispatch

* fix(desktop): keep cloud connection cleanup with deletion

* refactor(desktop): place cloud Hub runtime before interactions

* test(desktop): keep interaction-only fixtures with their tests

* test(desktop): simplify cloud create recovery fixtures

* fix(desktop): reject invalid cloud history snapshots

* fix(desktop): refresh expired cloud sessions without a Hub connection

* refactor(desktop): rely on backend GitHub token retries

* refactor(desktop): require the cloud status API

* feat(desktop): isolate cloud Hub snapshot reconciliation

* refactor(desktop): separate snapshot logic from Hub runtime

* test(desktop): align split runtime fixtures with current types

* fix(desktop): reconcile reflected attachment-only cloud prompts

* fix(desktop): preserve unreflected cloud tool and reasoning events

* fix(desktop): preserve unfinished aborted cloud output

* fix(desktop): retain interrupted output after saved replies

* fix(desktop): clear approvals when cloud connections close

* fix(desktop): guard cloud send recovery during teardown

* fix(desktop): clarify cloud session auth and work preservation

* fix(desktop): retain output completed after cloud snapshot

* test(desktop): expect approval cleanup in cloud deletion test

* fix(desktop): reject queue replies after cloud disposal

* fix(desktop): reconcile failed cloud session status

* fix(desktop): use conversation activity for failed cloud history

* fix(desktop): exclude tool output from empty prompt matches

* fix(desktop): preserve snapshot boundaries on buffer eviction

* test(desktop): initialize reconnect buffer eviction count

* refactor(desktop): remove redundant cloud runtime state

* fix(desktop): preserve cloud queue and rename outcomes

* fix(desktop): preserve manual cloud titles during first send

* fix(desktop): align cloud prompt with core-platform authentication guidance

* fix(desktop): preserve cloud work on task branches

* fix(desktop): clarify uncertain cloud message delivery

* feat(desktop): isolate cloud Hub snapshot reconciliation

* fix(desktop): reconcile reflected attachment-only cloud prompts

* fix(desktop): preserve unreflected cloud tool and reasoning events

* fix(desktop): preserve unfinished aborted cloud output

* fix(desktop): retain interrupted output after saved replies

* fix(desktop): retain output completed after cloud snapshot

* fix(desktop): exclude tool output from empty prompt matches
2026-09-17 13:24:03 -07:00
Saoud Rizwan ad521395f2 chore(desktop): release v0.0.31 2026-09-17 12:47:05 -07:00
Tomás BarreiroandSaoud Rizwan 7f75100da2 Add Client Type to Recommended Models requests (#14224)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-17 12:40:44 -07:00
Bee 721d185485 fix(agents): run subagent tool calls concurrently by default (#14197) 2026-09-17 12:17:10 -07:00
John Choi a776ce57e4 feat(desktop): add cloud Hub runtime (#14078)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* feat(desktop): add cloud session foundations

* fix(desktop): require boolean cloud rollout flag

* test(desktop): run sidecar suite in pull requests

* ci: test desktop changes in stacked pull requests

* fix(desktop): scope cloud model catalog rollout

* fix(sdk): clear feature flags on identity change

* test(hub): reconnect approval session owner

* feat(desktop): add cloud session REST client

* feat(desktop): add cloud transcript reconciliation

* feat(desktop): add cloud session lifecycle

* feat(desktop): add cloud session interactions

* feat(desktop): add cloud Hub runtime

* test(desktop): cover cloud session REST client

* test(desktop): cover cloud session lifecycle

* test(desktop): keep lifecycle fixture layer-local

* test(desktop): cover cloud session Hub runtime

* fix(desktop): preserve cloud status ordering

* fix(desktop): guard unsequenced status snapshots

* docs(sdk): clarify remote Hub connection headers

* docs(hub): describe approval recovery

* docs(llms): describe cloud catalog opt-in

* test(sdk): tighten hub header coverage

* test(hub): remove redundant approval setup

* chore(desktop): trim cloud foundation scaffolding

* test(desktop): trim cloud REST coverage

* test(desktop): consolidate cloud API cases

* test(desktop): remove unused lifecycle fixtures

* chore(desktop): trim cloud interaction comments

* test(desktop): consolidate cloud hub coverage

* test(desktop): complete lifecycle context fixture

* fix(hub): preserve approval recovery for existing clients

* fix(desktop): reconcile submitted prompts against cloud history

* test(core): batch root history fixture inserts

* test(desktop): align cloud fixtures with stream context

* test(desktop): align cloud fixtures with stream context

* fix(desktop): preserve reflected queued prompt lifecycle

* test(sdk): await daemon health after discovery publication

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* test(hub): reconnect approval session owner

* docs(hub): describe approval recovery

* test(hub): remove redundant approval setup

* fix(hub): preserve approval recovery for existing clients

* refactor(desktop): remove pending approval lookup dependency

* refactor(hub): drop cloud-only pending approval API

* fix(desktop): recover cloud approvals through existing Hub replay

* fix(desktop): rebuild approval subscription in initial runtime layer

* test(desktop): consolidate cloud Hub test setup

* test(desktop): scope cloud runtime tests to connection behavior

* test(desktop): cover cloud interactions in their owning layer

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* refactor(desktop): defer cloud interaction helpers to their owning layers

* fix(desktop): preserve cached flags and share account auth

* fix(desktop): return cloud session identity before provisioning completes

* fix(desktop): preserve the existing Hub boundary during readiness gating

* test(desktop): provide canonical rows during provisioning readiness

* fix(desktop): cancel cloud prompts still awaiting dispatch

* fix(desktop): keep cloud connection cleanup with deletion

* refactor(desktop): place cloud Hub runtime before interactions

* test(desktop): keep interaction-only fixtures with their tests

* test(desktop): simplify cloud create recovery fixtures

* fix(desktop): reject invalid cloud history snapshots

* fix(desktop): refresh expired cloud sessions without a Hub connection

* refactor(desktop): rely on backend GitHub token retries

* refactor(desktop): require the cloud status API

* feat(desktop): isolate cloud Hub snapshot reconciliation

* refactor(desktop): separate snapshot logic from Hub runtime

* test(desktop): align split runtime fixtures with current types

* fix(desktop): reconcile reflected attachment-only cloud prompts

* fix(desktop): preserve unreflected cloud tool and reasoning events

* fix(desktop): preserve unfinished aborted cloud output

* fix(desktop): retain interrupted output after saved replies

* fix(desktop): clear approvals when cloud connections close

* fix(desktop): retain output completed after cloud snapshot

* fix(desktop): reconcile failed cloud session status

* fix(desktop): use conversation activity for failed cloud history

* fix(desktop): exclude tool output from empty prompt matches

* fix(desktop): preserve snapshot boundaries on buffer eviction

* test(desktop): initialize reconnect buffer eviction count

* refactor(desktop): remove redundant cloud runtime state

* feat(desktop): isolate cloud Hub snapshot reconciliation

* fix(desktop): reconcile reflected attachment-only cloud prompts

* fix(desktop): preserve unreflected cloud tool and reasoning events

* fix(desktop): preserve unfinished aborted cloud output

* fix(desktop): retain interrupted output after saved replies

* fix(desktop): retain output completed after cloud snapshot

* fix(desktop): exclude tool output from empty prompt matches
2026-09-17 11:27:25 -07:00
419829e9d0 feat(desktop): integrate SSH remote environments (#14117)
* feat(core): add SSH remote environments

* feat(desktop): integrate SSH remote environments

* fix(core): harden SSH lifecycle and helper package exports

* fix(desktop): correct SSH credentials, history and packaging

* fix(core): use current SSH identity for pending cleanup

* docs(core): clarify SSH destination invariants during cleanup

* fix(desktop): restore remote history before reopening SSH sessions

* fix(core): recover SSH cleanup after remote Hub crashes

* test(core): make SSH regression coverage portable on Windows

* fix(core): leave account connectors untouched by SSH Hubs

* fix(core): restore missing SSH helpers for pending cleanup

* fix(desktop): route remote detach and refresh SSH profiles

* UI clean up

* fix(desktop): open remote settings from environment selector

* fix(desktop): find bundled SSH helpers in Tauri's Linux resource directory

Tauri's deb, rpm, and AppImage bundles install binaries under usr/bin and
resources under usr/lib/<productName>. The sidecar only probed the macOS
and Windows layouts, so packaged Linux builds could not locate the remote
helper and every SSH connect failed with "no compatible remote helper".

* fix(desktop): drive the environment selector from the thread's environment

The selector showed the globally connected environment while prompts were
routed to the active thread's environment, so navigating Back from an SSH
draft to a local draft displayed SSH while sending to the local machine.
Re-selecting the already-connected host now just opens its draft instead
of tearing down and rebuilding the remote runtime.

* build(desktop): bundle only Linux remote helpers

The macOS helpers land in Contents/Resources as Mach-O files. Tauri only
codesigns frameworks, externalBin, and the main binaries, and an unsigned
Mach-O anywhere in the bundle fails notarization, so shipping them would
break the next desktop-publish run. Linux x64 and arm64 cover common SSH
hosts; macOS targets can still use CLINE_REMOTE_HELPER_BINARY.

* style(desktop): format Linux remote helper test

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-17 10:16:11 -07:00
John Choi 9e12953bed fix(ci): build LLM dependency before UI release chat tests (#14202) 2026-09-17 09:35:34 -07:00
Saoud Rizwan 27fe60d79f chore(vscode): release v4.1.19
4.1.18 was tagged but never reached the Marketplace: every publish attempt
timed out on upload. Its notes carry forward under 4.1.19 together with
what landed on main since, so the first version users actually receive
describes everything in it.
2026-09-17 00:23:12 -07:00
Saoud Rizwan 48285afa65 Fix formatting in README description 2026-09-16 19:46:40 -07:00
110 changed files with 11677 additions and 1305 deletions
+124 -26
View File
@@ -1,11 +1,14 @@
name: desktop-publish
# Like ext-vscode-publish-nightly, nightlies are manually dispatched because
# PublishDesktop requires approval. Dispatch from main with channel=nightly.
# Nightlies upload Actions artifacts only; they do not publish releases or feeds.
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
description: "Existing stable/beta tag; leave empty for nightly"
required: false
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
@@ -18,8 +21,13 @@ on:
options:
- stable
- beta
- nightly
default: stable
concurrency:
group: desktop-publish-${{ inputs.channel }}
cancel-in-progress: false
permissions:
contents: read
@@ -32,10 +40,12 @@ jobs:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
ref: ${{ steps.version.outputs.ref }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
channel: ${{ steps.version.outputs.channel }}
@@ -84,7 +94,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
ref: ${{ inputs.channel == 'nightly' && github.sha || inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
@@ -103,6 +113,20 @@ jobs:
# manifest landing on desktop-latest would auto-update every stable
# install onto the beta. The stable regex rejects prerelease
# suffixes for the same reason.
if [ "$CHANNEL" = "nightly" ]; then
if [ -n "$TAG" ]; then
echo "nightly builds do not use a release tag; leave git_tag empty"
exit 1
fi
BASE=$(node -p "require('./apps/examples/desktop-app/package.json').version.split('-')[0]")
VERSION="${BASE}-nightly.$(date -u +%Y%m%d%H%M%S).${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}"
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=nightly" >> "$GITHUB_OUTPUT"
echo "product=Cline Nightly" >> "$GITHUB_OUTPUT"
exit 0
fi
case "$CHANNEL" in
stable)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -155,6 +179,7 @@ jobs:
exit 1
fi
echo "ref=${HEAD_COMMIT}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
@@ -237,7 +262,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.ref }}
- name: Stamp nightly version
if: needs.validate.outputs.channel == 'nightly'
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
node <<'JS'
const fs = require('node:fs');
for (const file of ['apps/examples/desktop-app/package.json', 'apps/examples/desktop-app/src-tauri/tauri.conf.json']) {
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.version = process.env.VERSION;
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
}
JS
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -292,7 +332,7 @@ jobs:
# 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' }}
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || 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`
@@ -369,11 +409,15 @@ jobs:
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-(beta|nightly)/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-(latest|nightly)/latest.json"
;;
nightly)
WANT=""
FORBID="releases/download/desktop-(latest|beta|nightly)/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
@@ -386,22 +430,22 @@ jobs:
# pipefail.
found=0
for bin in "$APP/Contents/MacOS/"*; do
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
if strings -a "$bin" | grep -E "$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
if [ -n "$WANT" ] && strings -a "$bin" | grep "$WANT" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
if [ -n "$WANT" ] && [ "$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}"
echo "Updater endpoint verified: ${WANT:-disabled}"
# Guardrail: assert the telemetry config actually made it into the
# compiled sidecar. Missing env on the build step (or a regression in
@@ -465,6 +509,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: desktop-universal
retention-days: 30
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
@@ -528,13 +573,59 @@ jobs:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.ref }}
- name: Stamp nightly version
if: needs.validate.outputs.channel == 'nightly'
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
node <<'JS'
const fs = require('node:fs');
for (const file of ['apps/examples/desktop-app/package.json', 'apps/examples/desktop-app/src-tauri/tauri.conf.json']) {
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.version = process.env.VERSION;
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
}
JS
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"
# build-sidecar-bin.ts cross-compiles the Linux SSH remote helpers on
# every platform, and the Windows installer ships them (they are Tauri
# resources, bundle.resources in tauri.conf.json). Bun cross-compiles by
# downloading the target's release zip and extracting the executable
# from it, and on a Windows host that extraction fails deterministically
# ("Failed to extract executable for 'bun-linux-x64-v1.3.13'. The
# download may be incomplete."): the download and archive are fine, Bun
# just never finds the `bun-linux-x64/bun` entry it needs. Bun only
# downloads when the cache lacks `<target>-v<version>`, so seed the cache
# with the same runtimes from npm (@oven/bun-<target>, byte-identical to
# the zip payload) and the build never enters the broken path. The macOS
# job does not need this; the extractor works there.
- name: Seed Bun cache with Linux remote-helper runtimes
shell: bash
run: |
BUN_VERSION="$(bun --version)"
CACHE_DIR="${RUNNER_TEMP//\\//}/bun-install-cache"
mkdir -p "$CACHE_DIR"
for target in linux-x64 linux-aarch64; do
dest="$CACHE_DIR/bun-$target-v$BUN_VERSION"
work="$(mktemp -d)"
(cd "$work" && npm pack "@oven/bun-$target@$BUN_VERSION" --silent >/dev/null && tar -xzf ./*.tgz package/bin/bun)
mv "$work/package/bin/bun" "$dest"
if [ "$(head -c 4 "$dest" | od -An -c | tr -d ' ')" != "177ELF" ]; then
echo "$dest is not an ELF executable; refusing to seed a bad runtime"
exit 1
fi
echo "seeded $dest ($(stat -c %s "$dest") bytes)"
done
echo "BUN_INSTALL_CACHE_DIR=${CACHE_DIR}" >> "$GITHUB_ENV"
- name: Setup Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
with:
@@ -600,7 +691,7 @@ jobs:
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
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' }}
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || 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 inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -632,11 +723,15 @@ jobs:
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-(beta|nightly)/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-(latest|nightly)/latest.json"
;;
nightly)
WANT=""
FORBID="releases/download/desktop-(latest|beta|nightly)/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
@@ -646,22 +741,22 @@ jobs:
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
if grep -aE "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
if [ -n "$WANT" ] && grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
if [ -n "$WANT" ] && [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release 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}"
echo "Updater endpoint verified: ${WANT:-disabled}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
@@ -738,12 +833,14 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
retention-days: 30
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build, build-windows]
if: needs.validate.outputs.channel != 'nightly'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -751,7 +848,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.ref }}
fetch-depth: 0
fetch-tags: true
@@ -829,15 +926,15 @@ jobs:
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
SOURCE_REF: ${{ needs.validate.outputs.ref }}
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
# 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 "")
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-*' "$SOURCE_REF^" 2>/dev/null || echo "")
else
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$SOURCE_REF^" 2>/dev/null || echo "")
fi
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
@@ -845,6 +942,7 @@ jobs:
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
target_commitish: ${{ needs.validate.outputs.ref }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
@@ -884,10 +982,10 @@ jobs:
fi
if ! gh release view "$FEED" >/dev/null 2>&1; then
if [ "$CHANNEL" = "beta" ]; then
if [ "$CHANNEL" != "stable" ]; 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." \
--title "Cline desktop ${CHANNEL} (auto-update feed)" \
--notes "Rolling release backing the ${CHANNEL} desktop app auto-updater. Only ${CHANNEL} installs poll this feed. Do not delete." \
--latest=false \
--prerelease \
--target "$(git rev-parse HEAD)"
+3 -1
View File
@@ -100,7 +100,9 @@ jobs:
- name: Run Desktop Sidecar Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'ubuntu-latest' }}
run: bun -F @cline/code test:sidecar
run: |
bun -F @cline/code test:sidecar
bun -F @cline/code test:sidecar-startup
- name: Run Desktop Settings UI Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
+3
View File
@@ -64,6 +64,9 @@ jobs:
- name: Build UI package
run: bun -F @cline/ui build
- name: Build LLM package for desktop chat tests
run: bun -F @cline/llms build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
+7 -1
View File
@@ -1,13 +1,19 @@
# Changelog
## [4.1.18]
## [4.1.19]
### Added
- Images attached to a model that cannot read them are now flagged instead of silently discarded. Thumbnails get a warning badge and the composer explains that the images will be ignored, with a button to switch to an image-capable model. Previously the thumbnail looked normal and the image was replaced with a text placeholder just before the request, so there was no way to tell it had been dropped. Model info and the attachment picker also report image support accurately for models that declare text-only input without listing capabilities.
### Changed
- Cline's logo and icons have been refreshed throughout the extension, including the activity bar and panel icons.
- The W&B Inference provider is now listed as CoreWeave, with its sign-up and API-key links updated to match CoreWeave's current documentation.
### Fixed
- Long tasks now compact when they actually need to rather than running out of room. The trigger compared a character-based estimate of the conversation (~3 characters per token) against the model's context limit, so content that tokenizes far denser than that — disassembly, image dumps, minified sources — could fill the real context window while the estimate stayed under the threshold and compaction never fired; the turn was then squeezed down to a handful of output tokens. The trigger now also uses the token count the provider itself reports, and how much history is kept is scaled by how far off the estimate turned out to be. The summarizer's output budget also doubled, so a model that reasons before answering can no longer spend the whole budget thinking and return no summary at all.
- On Windows, opening a repository that contains a file named `rg.exe`, `git.exe`, or `powershell.exe` no longer runs that file in place of the real program. Bare program names were resolved through the workspace directory before PATH, so a planted executable ran with your privileges as soon as the workspace was indexed. Cline now sets Windows' `NoDefaultCurrentDirectoryInExePath` opt-out at startup, in both the VS Code extension and the JetBrains core. Processes Cline launches inherit it, so inside a Command Prompt shell a program in the current directory now needs `.\` as it already does in PowerShell.
- A model turn that fails mid-stream with a transient provider error is now retried up to three times with backoff instead of ending the task. A single rate-limit response forwarded by a gateway previously surfaced as a failed task. A turn that has already streamed output is never retried, so nothing is duplicated.
- Terminal commands that succeed without printing anything (`git add -A` on a clean tree, for example) are now reported as empty output. They were treated as a shell-integration failure, which fed the model a snapshot of unrelated terminal scrollback prefixed with a warning that the output could not be captured, so silent commands intermittently looked like failures.
+1 -1
View File
@@ -5,7 +5,7 @@
<h1 align="center">Cline</h1>
<p align="center">
The open source coding agent in your IDE, terminal, and desktop.
The open source coding agent in your IDE, terminal, & desktop.
</p>
<div align="center">
+3
View File
@@ -1,4 +1,5 @@
import { Readable, Writable } from "node:stream";
import { registerClineClientIdentity } from "../utils/cline-client-identity";
import { writeDiagnostic } from "../utils/output";
export interface AcpModeOptions {
@@ -11,6 +12,8 @@ export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
);
const { AcpAgent } = await import("./acpAgent");
registerClineClientIdentity("cline-acp");
writeDiagnostic("[acp] starting ACP mode over stdio…");
const stream = ndJsonStream(
+2
View File
@@ -15,6 +15,7 @@ import {
cleanupActiveRuntime,
isAbortInProgress,
} from "./runtime/active-runtime";
import { registerClineClientIdentity } from "./utils/cline-client-identity";
import { resolveCliLaunchSpec } from "./utils/internal-launch";
import { writeErr } from "./utils/output";
@@ -32,6 +33,7 @@ if (!isMainThread) {
// daemon-hosted session spawns do not inherit it and try to become daemons.
// The hub daemon owns its process-level abort handling. Installing the CLI's
// fatal rejection handler first would make expected abort rejections exit it.
registerClineClientIdentity("cline-cli");
void import("@cline/core/hub/daemon-entry");
} else {
// Same reasoning as the daemon sentinel above: consume the supervised-connector
+2
View File
@@ -18,6 +18,7 @@ import {
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import type { TuiStartupTarget } from "./tui/types";
import { filterChatModels } from "./utils/chat-models";
import { registerClineClientIdentity } from "./utils/cline-client-identity";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
@@ -146,6 +147,7 @@ function startupTargetTakesPrecedenceOverMigrationNotice(
}
export async function runCli(): Promise<void> {
registerClineClientIdentity("cline-cli");
installStreamErrorGuards();
autoUpdateOnStartup();
@@ -0,0 +1,12 @@
import { setClineClientIdentity } from "@cline/shared";
import { getCliBuildInfo } from "./common";
export function registerClineClientIdentity(name: string): void {
const { version } = getCliBuildInfo();
setClineClientIdentity({
name,
version,
platform: "cli",
platformVersion: version,
});
}
@@ -1,5 +1,7 @@
import type { AgentEvent } from "@cline/core";
import { setClineClientIdentity } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerClineClientIdentity } from "./cline-client-identity";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
@@ -9,6 +11,7 @@ import {
afterEach(() => {
clearClineFreeModelCostCache();
setClineClientIdentity(undefined);
vi.unstubAllGlobals();
});
@@ -39,6 +42,29 @@ describe("shouldZeroClineFreeModelCost", () => {
);
});
it("identifies the CLI client on the free model request", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(JSON.stringify({ free: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
);
vi.stubGlobal("fetch", fetchMock);
registerClineClientIdentity("cline-cli");
await shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
});
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
"X-CLIENT-TYPE": "cline-cli",
});
});
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
vi.stubGlobal(
"fetch",
+2 -1
View File
@@ -1,4 +1,4 @@
import type { AgentEvent } from "@cline/core";
import { type AgentEvent, buildClineClientHeaders } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
@@ -37,6 +37,7 @@ async function fetchClineFreeModelIds(
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
headers: buildClineClientHeaders(),
signal: controller.signal,
});
if (!response.ok) return undefined;
+9
View File
@@ -1,5 +1,14 @@
# Cline Desktop Changelog
## 0.0.32
- Cline starts again after updating. In 0.0.31 the app could fail at launch with "desktop backend exited before publishing its endpoint" and never reach the workspace picker. Cline runs its own copy of the Cline Hub in the background, and the two halves had been built from different copies of the same code — so the Hub announced one build identity while the app expected another, the app shut down the very Hub it had just started, and startup timed out reporting "No compatible hub runtime is available". Both halves now load the same copy of that code, and the real compiled backend is now booted end-to-end in CI on every change so the two cannot drift apart again
## 0.0.31
- Cline can now work on a remote machine over SSH, with the app itself staying local. Add and test a host under **Settings → Remote**, then pick it from the environment selector beside the workspace picker on the welcome screen — saving or testing a profile does not activate it. The connection opens at the remote user's home directory; **Add project…** in the workspace picker browses that machine for a project, and **Local** disconnects. Agent tools, workspace discovery, Git metadata, and session persistence all run on the host while approvals and live session events return to the desktop, because only the authenticated Cline Hub protocol is forwarded through the tunnel. SSH config aliases work: leave **Port** blank to use the alias's own configuration, or enter one to override it. Recent and last-used workspaces are remembered separately per host. Host metadata lives at `~/.cline/data/settings/remote-environments.json` (mode `0600`) and records an identity-file path, never private-key contents; the host key must already be trusted by your SSH client, and unknown or changed keys are rejected. On first connect Cline uploads a self-contained helper to `~/.cline/remote/` — no `apt`, `npm`, root access, global CLI install, or public port — and caches it for faster reconnects, leaving any existing Cline CLI hub on that account running. Linux x64 and arm64 hosts are supported (32-bit Raspberry Pi operating systems are not; macOS hosts need a locally built helper via `CLINE_REMOTE_HELPER_BINARY`). File attachments and opening a remote file in a local editor are not available in remote sessions yet — text, images, file mentions and search, Git branch operations, session history, and remote agent tools are
- Sub-agents spawned in the same step now run at the same time instead of one after another, so delegating three independent pieces of work takes as long as the slowest one rather than the sum of all three. Tools that must run in order still do, and only back-to-back parallel calls overlap
## 0.0.30
- Windows updates no longer fail with "Error opening file for writing". The installer hook meant to stop the running backend never terminated anything: Tauri ships a 32-bit NSIS installer, so its PowerShell ran under WOW64, where `Get-Process` reports an empty path for every 64-bit process — the filter matched no sidecar on any x64 machine. The installer then wrote over an executable the detached Hub daemon still held open, which is why updating from 0.0.25 through 0.0.28 failed exactly as 0.0.24 did. The installer now asks the Windows Restart Manager which processes hold this install's files and shuts those down; that is bitness-independent, covers the connector processes the Hub spawns, and leaves a side-by-side Cline Beta alone. If Restart Manager itself fails you get a Retry/Cancel prompt rather than a half-replaced install
+60 -1
View File
@@ -124,7 +124,64 @@ agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
[`core shell-path.ts`](../../../sdk/packages/core/src/remote/shell-path.ts).
## SSH Remote Environments
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
profile does not activate it. From the welcome chat, open the environment
selector beside the workspace picker and choose the saved host; that selection
starts the SSH connection at the remote user's home directory. Choose **Add
project…** from the normal workspace selector to browse that machine and select
a project, or choose **Local** in the environment selector to disconnect. Recent
and last-used workspaces are remembered separately for each SSH host and for
the local machine.
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
configuration (including its configured port), or enter a port to override it.
The desktop keeps its webview and native integration local; only the
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
workspace discovery, Git metadata, and session persistence therefore run on the
SSH host, while approvals and live session events return to the desktop.
The shared `@cline/core` `RemoteEnvironmentService` owns this feature; other
clients can use the same service and `ClineCore` remote backend (see `sdk/DOC.md`).
Desktop owns the settings UI and packaged helper resource lookup.
The service stores host metadata at
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
identity-file path, never private-key contents. On first connect it uploads a
content-addressed, branch-matched, self-contained Hub helper under
`~/.cline/remote/`, binds the Hub to remote loopback, and forwards it to a
random local loopback port. Linux x64 and arm64 helpers are bundled by
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
supported. macOS SSH targets need a locally built helper passed through
`CLINE_REMOTE_HELPER_BINARY` until the bundled helpers are codesigned for
notarization. The helper includes its own runtime. It is copied once per matching desktop build and cached, with no
`apt`, `npm`, root access,
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
remote Hub but leaves the helper cached for a faster reconnect. The helper
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
MCP executables remain visible.
Each service instance uses its own discovery record, so an existing Cline CLI/Hub on the
same account is neither replaced nor stopped. Both Hub processes can coexist
while the desktop is connected; this isolation keeps the remote helper separate from the default CLI Hub.
The desktop currently leaves file attachments and opening a remote file in a local
editor disabled. Text, images, file mentions/search, Git branch operations,
session history, and remote agent tools are supported. The current desktop
provider access/API token is sent through the authenticated tunnel for the
session; reusable OAuth refresh credentials are not copied into remote provider
settings.
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
connection at the SSH user's home, starts an agent session in the test
workspace with the selected desktop provider, asks the agent to read
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
that its messages can be read back.
## Web Visual System
@@ -289,3 +346,5 @@ credentials, request headers, recorded audio, or transcript contents.
The sidecar mints a short-lived transcription token; the long-lived gateway
credential is never sent to the webview. Batch models such as
`openai/whisper-1` continue to transcribe after recording stops.
SSH requires an already-trusted host key. Before first connection, verify the server fingerprint through a trusted channel and enroll it with your SSH client. Unknown or changed keys are rejected.
+1 -1
View File
@@ -1,8 +1,8 @@
import { $ } from "bun";
const main = async () => {
await $`next build`.cwd("webview");
await $`bun run build:sidecar:bin`;
await $`next build`.cwd("webview");
};
main().catch((error: unknown) => {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.30",
"version": "0.0.32",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -32,6 +32,7 @@
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts --config vitest.config.ts",
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import { $ } from "bun";
import { telemetryDefineArgs } from "./telemetry-define-args";
@@ -38,22 +39,63 @@ const sidecarOutfile = (targetTriple: string): string => {
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
};
const buildSidecar = async (targetTriple: string): Promise<string> => {
const outfile = sidecarOutfile(targetTriple);
const buildSidecar = async (
targetTriple: string,
outfile = sidecarOutfile(targetTriple),
entrypoint = "./sidecar/index.ts",
minify = false,
): Promise<string> => {
const bunTarget = resolveBunCompileTarget(targetTriple);
// Telemetry config must be inlined into the compiled binary: a packaged
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
// without this the sidecar silently ships with telemetry disabled.
// Verify with `<binary> --telemetry-selfcheck` after building.
const defines = telemetryDefineArgs();
const optimizationArgs = minify ? ["--minify"] : [];
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
// launch directory before our entrypoint runs. Remote helpers are launched
// from an SSH user's home directory, so that behavior can both make the
// helper fail on an unrelated dotenv file and leak workspace credentials
// into the Hub process. Packaged binaries must depend only on their explicit
// process environment and compiled configuration.
const runtimeIsolationArgs = [
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
];
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
}
return outfile;
};
// SSH environments run the same Hub build as the desktop in a dedicated
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
// macOS helpers are deliberately not bundled: they are Mach-O files under
// Contents/Resources, which Tauri does not codesign, and any unsigned Mach-O
// in the bundle fails notarization. Shipping them needs a signing step first.
//
// On a Windows host, Bun fails to extract the downloaded Linux runtime these
// cross-compiles need ("Failed to extract executable for 'bun-linux-x64-…'").
// Bun skips the download when `$BUN_INSTALL_CACHE_DIR/bun-<target>-v<version>`
// already exists, so seed those two files from the @oven/bun-<target> npm
// packages first; desktop-publish.yml does exactly that in its Windows job.
const buildRemoteHelpers = async (): Promise<void> => {
for (const targetTriple of [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]) {
await buildSidecar(
targetTriple,
`./src-tauri/bin/remote-helpers/cline-remote-helper-${targetTriple}`,
"../../../sdk/packages/core/dist/remote/remote-helper-entry.js",
true,
);
}
};
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
// but expects sidecars (externalBin) to already be fat binaries named
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
@@ -67,13 +109,18 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
};
const main = async () => {
// All compiled helpers and the sidecar depend on fresh SDK package exports.
await $`bun run build:sdk`.cwd(
fileURLToPath(new URL("../../../../", import.meta.url)),
);
const targetTriple = await resolveTargetTriple();
await $`mkdir -p src-tauri/bin`;
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacSidecar();
return;
} else {
await buildSidecar(targetTriple);
}
await buildSidecar(targetTriple);
await buildRemoteHelpers();
};
main().catch((error: unknown) => {
@@ -0,0 +1,123 @@
import { expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import {
closeSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Exercise the actual compiled entrypoint: source-only tests miss mixed SDK
// build identities between the desktop client and its embedded Hub daemon.
test("compiled desktop backend publishes its endpoint with its own Hub", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-desktop-startup-"));
const discoveryPath = join(root, "hub.json");
const stdoutPath = join(root, "stdout.log");
const stderrPath = join(root, "stderr.log");
const stdout = openSync(stdoutPath, "w");
const stderr = openSync(stderrPath, "w");
let child: ReturnType<typeof Bun.spawn> | undefined;
let hubPid: number | undefined;
try {
const binary = process.env.CLINE_TEST_SIDECAR_BIN
? resolve(process.env.CLINE_TEST_SIDECAR_BIN)
: join(root, process.platform === "win32" ? "sidecar.exe" : "sidecar");
if (!process.env.CLINE_TEST_SIDECAR_BIN) {
const build = spawnSync(
process.execPath,
[
"build",
fileURLToPath(new URL("../sidecar/index.ts", import.meta.url)),
"--compile",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
"--outfile",
binary,
],
{ cwd: root, encoding: "utf8", timeout: 60_000 },
);
expect(build.status, build.stderr || String(build.error)).toBe(0);
}
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) => !/^(CLINE_|OTEL_|TELEMETRY_|ERROR_SERVICE_)/.test(key),
),
);
Object.assign(env, {
CLINE_DIR: root,
CLINE_DATA_DIR: join(root, "data"),
CLINE_HUB_DISCOVERY_PATH: discoveryPath,
});
// Bootstrap on an OS-assigned port using the same executable. Pin the
// desktop to that port so this test cannot touch a developer's real Hub,
// even if a regression makes it reject its own discovery record.
const bootstrap = spawnSync(
binary,
["--remote-hub-ensure", "--discovery-path", discoveryPath, "--cwd", root],
{ cwd: root, env, encoding: "utf8", timeout: 20_000 },
);
expect(bootstrap.status, bootstrap.stderr || String(bootstrap.error)).toBe(
0,
);
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
env.CLINE_HUB_PORT = String(hub.port);
child = Bun.spawn([binary], {
cwd: root,
env,
stdin: "ignore",
stdout,
stderr,
});
let endpoint: string | undefined;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
try {
const message = JSON.parse(line);
if (message.type === "ready") endpoint = message.endpoint;
} catch {
/* Ignore other output and incomplete lines. */
}
}
if (endpoint || child.exitCode !== null) break;
await Bun.sleep(50);
}
expect(
endpoint,
readFileSync(stderrPath, "utf8") || "Backend never became ready",
).toBeTruthy();
const health = await fetch(`${endpoint}/health`, {
signal: AbortSignal.timeout(5_000),
});
expect(health.ok).toBe(true);
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
} finally {
if (child && child.exitCode === null) {
child.kill();
await Promise.race([child.exited, Bun.sleep(6_000)]);
if (child.exitCode === null) child.kill("SIGKILL");
await child.exited;
}
// A failed bootstrap may still have published a discovery record.
try {
hubPid ??= JSON.parse(readFileSync(discoveryPath, "utf8")).pid;
if (hubPid) process.kill(hubPid, "SIGTERM");
} catch {
/* The isolated Hub may already have exited. */
}
closeSync(stdout);
closeSync(stderr);
rmSync(root, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 100,
});
}
}, 100_000);
@@ -0,0 +1,182 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ClineCore,
ProviderSettingsManager,
RemoteEnvironmentService,
RuntimeOAuthTokenManager,
resolveProviderApiKeyFromSettings,
SessionSource,
toProviderConfig,
} from "@cline/core";
const required = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
};
async function main(): Promise<void> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-"));
const service = new RemoteEnvironmentService({
profilesPath: join(temporaryDirectory, "remote-environments.json"),
helperBinaryPath: required("CLINE_SSH_TEST_HELPER"),
knownHostsPath: process.env.CLINE_SSH_TEST_KNOWN_HOSTS?.trim() || undefined,
commandTimeoutMs: 60_000,
uploadTimeoutMs: 5 * 60_000,
});
let core: ClineCore | undefined;
try {
const helperPath = required("CLINE_SSH_TEST_HELPER");
const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE");
const profile = await service.upsert({
name: "SSH proof host",
host: required("CLINE_SSH_TEST_HOST"),
user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined,
identityFile: required("CLINE_SSH_TEST_KEY"),
});
const connection = await service.connect(profile.id);
const marker = await service.run(profile.id, {
command: "sed",
args: ["-n", "1p", "REMOTE_MARKER.txt"],
cwd: workspaceRoot,
});
const providerSettings = new ProviderSettingsManager();
const stored = providerSettings.read();
const providerId = stored.lastUsedProvider;
if (!providerId)
throw new Error("No configured desktop provider is available");
const settings = providerSettings.getProviderSettings(providerId);
if (!settings)
throw new Error(`No settings found for provider ${providerId}`);
const modelId = settings.model || "meta/muse-spark-1.2";
const oauth = await new RuntimeOAuthTokenManager({
providerSettingsManager: providerSettings,
}).resolveProviderApiKey({ providerId });
const apiKey =
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(providerSettings, providerId);
if (!apiKey)
throw new Error(`No credential found for provider ${providerId}`);
const providerConfig = {
...toProviderConfig(
{
...(providerSettings.getProviderSettings(providerId) ?? settings),
model: modelId,
},
{ includeKnownModels: false },
),
};
delete providerConfig.refreshToken;
providerConfig.apiKey = apiKey;
providerConfig.accessToken = apiKey;
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const eventNames: string[] = [];
const unsubscribe = core.subscribe((event) => {
eventNames.push(event.type);
});
const started = await core.start({
config: {
providerId,
modelId,
apiKey,
providerConfig,
workspaceRoot,
cwd: workspaceRoot,
systemPrompt: "",
mode: "act",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
},
source: SessionSource.DESKTOP,
interactive: true,
toolPolicies: { "*": { autoApprove: true } },
});
const result = await core.send({
sessionId: started.sessionId,
prompt:
"Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.",
});
const sessions = await core.list(20, { hydrate: false });
const messages = await core.readMessages(started.sessionId);
unsubscribe();
await core.dispose("desktop_ssh_proof_reconnect");
core = undefined;
await service.disconnect(profile.id);
const reconnected = await service.connect(profile.id);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: reconnected.endpoint,
authToken: reconnected.authToken,
workspaceRoot: reconnected.workspaceRoot,
cwd: reconnected.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const sessionsAfterReconnect = await core.list(20, { hydrate: false });
const messagesAfterReconnect = await core.readMessages(started.sessionId);
const resultText = result?.text ?? "";
const report = {
connected: true,
remote: `${connection.platform}/${connection.arch}`,
connectionRoot: connection.workspaceRoot,
workspaceRoot: started.manifest.workspace_root,
sessionId: started.sessionId,
listContainsSession: sessions.some(
(session) => session.sessionId === started.sessionId,
),
messageCount: messages.length,
reconnected: true,
reconnectListContainsSession: sessionsAfterReconnect.some(
(session) => session.sessionId === started.sessionId,
),
reconnectMessageCount: messagesAfterReconnect.length,
helperBytes: (await stat(helperPath)).size,
sshMarker: marker.stdout.trim(),
agentText: resultText,
agentObservedMarker: resultText.includes("remote workspace proof"),
eventNames: [...new Set(eventNames)],
};
if (
report.sshMarker !== "remote workspace proof" ||
!report.agentObservedMarker ||
!report.listContainsSession ||
!report.reconnectListContainsSession ||
report.messageCount < 2 ||
report.reconnectMessageCount < 2 ||
!report.eventNames.includes("agent_event")
) {
throw new Error(`SSH proof failed: ${JSON.stringify(report)}`);
}
process.stdout.write(`${JSON.stringify(report)}\n`);
} finally {
await core?.dispose("desktop_ssh_proof_complete");
await service.dispose();
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -84,6 +84,42 @@ describe("rewriteDesktopTeamPrompt", () => {
}
});
});
function localRuntimeContext(
sessionManager: Record<string, unknown>,
options: { sessionIds?: string[]; workspaceRoot?: string } = {},
) {
const workspaceRoot = options.workspaceRoot ?? "/workspace";
return {
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local" as const,
workspaceRoot,
sessionManager,
hubClient: {
command: vi.fn(async () => undefined),
},
unsubscribeSessionEvents: () => {},
},
],
]),
sessionEnvironmentIds: new Map(
(options.sessionIds ?? []).map((sessionId) => [sessionId, "local"]),
),
activeEnvironmentId: "local",
remoteEnvironments: null,
localWorkspaceRoot: workspaceRoot,
};
}
function localSessionManager(ctx: SidecarContext): Record<string, unknown> {
return ctx.runtimeBindings.get("local")?.sessionManager as unknown as Record<
string,
unknown
>;
}
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
@@ -238,7 +274,7 @@ describe("pathless session starts", () => {
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
...localRuntimeContext({ start }),
telemetryUser: {
distinctId: "account-1",
accountId: "account-1",
@@ -267,6 +303,7 @@ describe("pathless session starts", () => {
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
environmentId: "local",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
@@ -275,6 +312,60 @@ describe("pathless session starts", () => {
});
});
describe("environment-bound session attach", () => {
it("does not fall through to another host when the requested environment lacks the session", async () => {
const sessionId = "same-session-id";
const localGet = vi.fn(async () => ({
sessionId,
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/local/project",
workspaceRoot: "/local/project",
}));
const remoteGet = vi.fn(async () => undefined);
const ctx = {
liveSessions: new Map(),
sessionEnvironmentIds: new Map([[sessionId, "local"]]),
activeEnvironmentId: "local",
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local",
workspaceRoot: "/local/project",
sessionManager: { get: localGet },
hubClient: { command: vi.fn() },
unsubscribeSessionEvents: () => {},
},
],
[
"pi-host",
{
environmentId: "pi-host",
kind: "ssh",
workspaceRoot: "/home/pi",
sessionManager: { get: remoteGet },
hubClient: { command: vi.fn() },
unsubscribeSessionEvents: () => {},
},
],
]),
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
config: { environmentId: "pi-host" },
}),
).rejects.toThrow(`Session ${sessionId} not found`);
expect(remoteGet).toHaveBeenCalledWith(sessionId);
expect(localGet).not.toHaveBeenCalled();
});
});
describe("session forks", () => {
it("restores the selected workspace checkpoint before forking for message editing", async () => {
const sourceSessionId = `source-fork-${Date.now()}`;
@@ -314,29 +405,32 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
},
},
},
})),
readMessages,
restore,
start,
},
})),
readMessages,
restore,
start,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -438,26 +532,29 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
},
},
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
{ sessionIds: [sourceSessionId, siblingSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -524,7 +621,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
...localRuntimeContext({
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
@@ -540,7 +637,7 @@ describe("session forks", () => {
readMessages,
restore,
start,
},
}),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -602,20 +699,23 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
pendingQuestions: new Map(),
@@ -666,7 +766,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: { restore },
...localRuntimeContext({ restore }, { sessionIds: [sourceSessionId] }),
} as unknown as SidecarContext;
await expect(
@@ -697,13 +797,16 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
{ sessionIds: [sourceSessionId] },
),
} as unknown as SidecarContext;
await expect(
@@ -747,15 +850,18 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
{ sessionIds: [sourceSessionId, siblingSessionId] },
),
} as unknown as SidecarContext;
await expect(
@@ -799,7 +905,7 @@ describe("session forks", () => {
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: { restore },
...localRuntimeContext({ restore }),
} as unknown as SidecarContext;
const restoreRequest = {
action: "restore_checkpoint" as const,
@@ -862,7 +968,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(["/workspace/project"]),
sessionManager: { send },
...localRuntimeContext({ send }, { sessionIds: [sessionId] }),
} as unknown as SidecarContext;
await expect(
@@ -922,17 +1028,20 @@ describe("first-send connection updates", () => {
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
...localRuntimeContext(
{
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
},
},
},
{ sessionIds: [sessionId] },
),
} as unknown as SidecarContext;
return {
ctx,
@@ -1056,7 +1165,7 @@ describe("first-send connection updates", () => {
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
const manager = localSessionManager(ctx) as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
@@ -1188,15 +1297,13 @@ describe("first-send connection updates", () => {
if (!session) throw new Error("missing session");
const queuedMap = new Map([["pending_1", [queuedFile]]]);
session.queuedAttachmentFiles = queuedMap;
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}),
);
(localSessionManager(ctx) as { get?: unknown }).get = vi.fn(async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}));
await handleChatSessionCommand(ctx, {
action: "attach",
@@ -1640,18 +1747,20 @@ Follow the desktop send workflow instructions.`,
}),
);
const ctx = {
workspaceRoot: workspace,
liveSessions: new Map([[sessionId, session]]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
send,
pendingPrompts: {
list: vi.fn(async () => []),
update: updatePendingPrompt,
...localRuntimeContext(
{
send,
pendingPrompts: {
list: vi.fn(async () => []),
update: updatePendingPrompt,
},
},
},
{ sessionIds: [sessionId], workspaceRoot: workspace },
),
} as unknown as SidecarContext;
return { ctx, send, session, sessionId, updatePendingPrompt };
}
@@ -1806,11 +1915,11 @@ describe("mistake-limit prompt", () => {
streamIndices: new Map(),
pendingQuestions: new Map(),
liveSessions: new Map(),
sessionManager: {
...localRuntimeContext({
send: steer,
stop: vi.fn(async () => {}),
abort: vi.fn(async () => {}),
},
}),
} as unknown as SidecarContext;
const readQuestionRequest = () => {
const raw = send.mock.calls
@@ -2017,7 +2126,7 @@ describe("mistake-limit prompt", () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
if (failure === "rejected")
steer.mockRejectedValueOnce(new Error("Disconnected"));
else ctx.sessionManager = null;
else ctx.runtimeBindings.clear();
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
const waiting = Promise.all([
@@ -2267,7 +2376,7 @@ describe("mistake-limit prompt", () => {
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "start",
@@ -2292,7 +2401,7 @@ describe("queue steering routing", () => {
const ctx = {
liveSessions: new Map(),
wsClients: new Set(),
sessionManager: { pendingPrompts: { steerFirst, update } },
...localRuntimeContext({ pendingPrompts: { steerFirst, update } }),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "steer_prompt",
+346 -76
View File
@@ -11,14 +11,18 @@ import {
findCheckpointForRun,
getCoreBuiltinToolCatalog,
isSkillsToolAvailable,
ProviderSettingsManager,
projectSessionCompactionState,
RuntimeOAuthTokenManager,
readGlobalSettings,
readSessionCheckpointHistory,
resolveProviderApiKeyFromSettings,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
SessionSource,
splitCoreSessionConfig,
toProviderConfig,
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
@@ -38,6 +42,9 @@ import { createDesktopExtensionContext } from "./client-context";
import {
cancelSidecarMistakeQuestions,
emitChunk,
findSessionRuntimeBinding,
getEnvironmentContext,
getSessionRuntimeBinding,
nowMs,
requestSidecarAskQuestion,
sendEvent,
@@ -49,6 +56,7 @@ import type {
JsonRecord,
LiveSession,
PromptInQueue,
SessionRuntimeBinding,
SidecarContext,
} from "./types";
@@ -368,6 +376,7 @@ function createLiveSession(
overrides?: Partial<LiveSession>,
): LiveSession {
return {
environmentId: overrides?.environmentId,
config,
messages: overrides?.messages ?? [],
promptsInQueue: overrides?.promptsInQueue ?? [],
@@ -514,7 +523,9 @@ export function createDesktopMistakeLimitPrompt(
.join(" ");
// Use the existing steering queue so the running model receives the
// guidance, including any instructions entered in the desktop prompt.
const manager = ctx.sessionManager;
const manager = ctx.runtimeBindings.get(
ctx.sessionEnvironmentIds.get(sessionId) ?? "local",
)?.sessionManager;
try {
if (!manager) throw new Error("Desktop session manager is unavailable");
const continuedThroughIteration = Math.max(
@@ -714,11 +725,24 @@ export function mergeSessionConfig(
const providerId =
readAliasedString(updates, "provider", "providerId") ??
readAliasedString(currentConfig, "provider", "providerId");
const previous = { ...currentConfig };
if (hasProviderChanged(currentConfig, updates)) {
for (const key of [
"apiKey",
"api_key",
"baseUrl",
"headers",
"providerConfig",
"accessToken",
"refreshToken",
])
delete previous[key];
}
const modelId =
readAliasedString(updates, "model", "modelId") ??
readAliasedString(currentConfig, "model", "modelId");
return {
...currentConfig,
...previous,
...updates,
...(providerId ? { provider: providerId, providerId } : {}),
...(modelId ? { model: modelId, modelId } : {}),
@@ -832,9 +856,99 @@ function applyPendingPrompts(
}));
}
function getSessionManager(ctx: SidecarContext): ClineCore {
if (!ctx.sessionManager) throw new Error("Session manager not initialized");
return ctx.sessionManager;
function readEnvironmentId(config: JsonRecord | undefined): string | undefined {
const value = config?.environmentId ?? config?.environment_id;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readExplicitSystemPrompt(config: JsonRecord): string {
const value = config.systemPrompt ?? config.system_prompt;
return typeof value === "string" ? value : "";
}
/**
* A remote Hub intentionally has its own HOME and provider settings. Send the
* selected desktop provider configuration over the authenticated SSH tunnel so
* a normal signed-in desktop session can make model requests on the host. The
* refresh token is omitted: v0 uses the current access/API token and never
* writes the desktop's reusable OAuth credential to the remote settings file.
*/
async function withRemoteProviderCredentials(
config: JsonRecord,
): Promise<JsonRecord> {
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (!providerId) return config;
const manager = new ProviderSettingsManager();
const settings = manager.getProviderSettings(providerId);
if (!settings) return config;
const modelId = String(
config.model ?? config.modelId ?? settings.model ?? "",
).trim();
const explicitProviderConfig =
config.providerConfig && typeof config.providerConfig === "object"
? (config.providerConfig as JsonRecord)
: undefined;
const explicitApiKey =
[
config.apiKey,
config.api_key,
explicitProviderConfig?.apiKey,
explicitProviderConfig?.accessToken,
]
.find(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
?.trim() ?? "";
const oauth = explicitApiKey
? null
: await new RuntimeOAuthTokenManager({
providerSettingsManager: manager,
}).resolveProviderApiKey({ providerId });
// Refresh can replace access tokens, account IDs, and provider metadata.
const refreshedSettings = manager.getProviderSettings(providerId) ?? settings;
const storedConfig = {
...toProviderConfig(
{ ...refreshedSettings, ...(modelId ? { model: modelId } : {}) },
{ includeKnownModels: false },
),
};
const apiKey =
explicitApiKey ||
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(manager, providerId) ||
String(storedConfig.apiKey ?? "").trim();
const providerConfig = {
...storedConfig,
...explicitProviderConfig,
providerId,
...(modelId ? { modelId } : {}),
...(apiKey ? { apiKey, accessToken: apiKey } : {}),
};
delete providerConfig.refreshToken;
return {
...config,
...(apiKey ? { apiKey } : {}),
...(!config.baseUrl && storedConfig.baseUrl
? { baseUrl: storedConfig.baseUrl }
: {}),
...(!config.headers && storedConfig.headers
? { headers: storedConfig.headers }
: {}),
providerConfig,
};
}
function getSessionManager(
ctx: SidecarContext,
sessionId?: string,
config?: JsonRecord,
): ClineCore {
return getSessionRuntimeBinding(ctx, sessionId, readEnvironmentId(config))
.sessionManager;
}
// ---------------------------------------------------------------------------
@@ -846,23 +960,39 @@ async function handleStart(
request: ChatSessionCommandRequest,
): Promise<unknown> {
if (!request.config) throw new Error("config is required");
const manager = getSessionManager(ctx);
const systemPrompt = await resolveSystemPrompt(request.config);
const binding = getSessionRuntimeBinding(
ctx,
undefined,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const config =
binding.kind === "ssh"
? await withRemoteProviderCredentials(request.config)
: request.config;
// Workspace discovery must happen where the files live. Local desktop
// sessions keep the eager prompt path; SSH sessions leave a blank prompt for
// the remote Hub's LocalRuntimeHost bootstrap to compose from remote metadata.
const systemPrompt =
binding.kind === "ssh"
? readExplicitSystemPrompt(config)
: await resolveSystemPrompt(config);
const requestedSessionId = String(
request.config.sessionId ?? request.config.session_id ?? "",
config.sessionId ?? config.session_id ?? "",
).trim();
const initialMessages =
Array.isArray(request.config.initialMessages) &&
request.config.initialMessages.length > 0
? request.config.initialMessages
Array.isArray(config.initialMessages) && config.initialMessages.length > 0
? config.initialMessages
: requestedSessionId
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
? binding.kind === "ssh"
? await manager.readMessages(requestedSessionId)
: (readPersistedChatMessages(requestedSessionId) ?? undefined)
: undefined;
// Resolved once start() returns; the mistake-limit prompt reads it lazily.
let startedSessionId = requestedSessionId;
const coreConfig: JsonRecord = {
...buildCoreSessionConfig(
request.config,
config,
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => startedSessionId),
),
@@ -884,7 +1014,15 @@ async function handleStart(
source: SessionSource.DESKTOP,
interactive: true,
...(initialMessages ? { initialMessages } : {}),
toolPolicies: resolveToolPolicies(request.config),
toolPolicies: resolveToolPolicies(config),
sessionMetadata:
binding.kind === "ssh"
? {
remoteEnvironmentId: binding.environmentId,
remoteEnvironmentName: binding.remote?.profile.name,
remoteHost: binding.remote?.profile.host,
}
: undefined,
});
const sessionId = startResult.sessionId;
startedSessionId = sessionId;
@@ -892,20 +1030,33 @@ async function handleStart(
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
...request.config,
cwd,
workspaceRoot,
environmentId: binding.environmentId,
},
{
environmentId: binding.environmentId,
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
title:
requestedSessionId && binding.kind === "local"
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId, cwd, workspaceRoot };
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return {
sessionId,
cwd,
workspaceRoot,
environmentId: binding.environmentId,
};
}
async function handleAttach(
@@ -917,7 +1068,18 @@ async function handleAttach(
throw new Error("sessionId is required");
}
const manager = getSessionManager(ctx);
const preferredEnvironmentId = readEnvironmentId(request.config);
// An explicit environment is a hard routing boundary. Falling through to
// another connected host can attach a same-id session from the wrong machine.
const binding = preferredEnvironmentId
? getSessionRuntimeBinding(ctx, sessionId, preferredEnvironmentId)
: await findSessionRuntimeBinding(ctx, sessionId);
if (!binding) {
throw new Error(
`Session ${sessionId} not found in a connected environment`,
);
}
const manager = binding.sessionManager;
const session = await manager.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
@@ -928,13 +1090,12 @@ async function handleAttach(
? (session.metadata as JsonRecord)
: undefined;
const existing = ctx.liveSessions.get(sessionId);
if (ctx.hubClient) {
await ctx.hubClient.command("session.attach", { sessionId }, sessionId);
}
const attachedConfig: JsonRecord = {
await binding.hubClient.command("session.attach", { sessionId }, sessionId);
const baseAttachedConfig: JsonRecord = {
...(existing?.config ?? {}),
...(request.config ?? {}),
sessionId,
environmentId: binding.environmentId,
provider: session.provider || existing?.config.provider || "",
model: session.model || existing?.config.model || "",
cwd:
@@ -950,7 +1111,8 @@ async function handleAttach(
};
ctx.liveSessions.set(
sessionId,
createLiveSession(attachedConfig, {
createLiveSession(baseAttachedConfig, {
environmentId: binding.environmentId,
messages: existing?.messages ?? [],
promptsInQueue: existing?.promptsInQueue ?? [],
status: session.status,
@@ -970,9 +1132,11 @@ async function handleAttach(
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return {
sessionId,
environmentId: binding.environmentId,
status: session.status,
provider: session.provider,
model: session.model,
@@ -1030,11 +1194,20 @@ async function startRebuiltSession(
async function rebuildSessionForProviderChange(
ctx: SidecarContext,
manager: ClineCore,
binding: SessionRuntimeBinding,
sessionId: string,
previousConfig: JsonRecord,
nextConfig: JsonRecord,
): Promise<void> {
const manager = binding.sessionManager;
const effectivePreviousConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(previousConfig)
: previousConfig;
const effectiveNextConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(nextConfig)
: nextConfig;
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
await Promise.all([
manager.readMessages(sessionId),
@@ -1046,8 +1219,12 @@ async function rebuildSessionForProviderChange(
});
return undefined;
}),
resolveSystemPrompt(previousConfig),
resolveSystemPrompt(nextConfig),
binding.kind === "ssh"
? readExplicitSystemPrompt(effectivePreviousConfig)
: resolveSystemPrompt(effectivePreviousConfig),
binding.kind === "ssh"
? readExplicitSystemPrompt(effectiveNextConfig)
: resolveSystemPrompt(effectiveNextConfig),
]);
cancelSidecarMistakeQuestions(ctx, sessionId, "Session provider changed");
@@ -1058,7 +1235,7 @@ async function rebuildSessionForProviderChange(
manager,
ctx,
sessionId,
nextConfig,
effectiveNextConfig,
nextSystemPrompt,
messages,
compactionState,
@@ -1069,7 +1246,7 @@ async function rebuildSessionForProviderChange(
// persistence failure cannot leave runtime and cached state diverged.
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
buildSessionConnectionUpdate(effectiveNextConfig),
);
} catch (replacementError) {
try {
@@ -1080,14 +1257,14 @@ async function rebuildSessionForProviderChange(
manager,
ctx,
sessionId,
previousConfig,
effectivePreviousConfig,
previousSystemPrompt,
messages,
compactionState,
);
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(previousConfig),
buildSessionConnectionUpdate(effectivePreviousConfig),
);
} catch (rollbackError) {
throw new AggregateError(
@@ -1112,8 +1289,13 @@ async function handleSend(
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
const binding = getSessionRuntimeBinding(
ctx,
sessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const lockedWorkspaceKey = workspacePathKey(
session?.config ?? request.config,
);
@@ -1128,19 +1310,23 @@ async function handleSend(
}
// Dispatch the expanded or rewritten instructions, but keep the raw
// `/command` token as the session's display prompt.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
prompt,
request.config?.mode ?? session?.config?.mode,
);
const runtimePrompt =
binding.kind === "ssh"
? prompt
: await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(session?.config ?? request.config) ??
ctx.localWorkspaceRoot,
prompt,
request.config?.mode ?? session?.config?.mode,
);
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
}
const nextConfig = request.config
? mergeSessionConfig(session?.config ?? {}, request.config)
: undefined;
: session?.config;
const providerChanged = Boolean(
session &&
request.config &&
@@ -1163,23 +1349,28 @@ async function handleSend(
}
}
try {
if (request.config && nextConfig) {
if ((request.config || binding.kind === "ssh") && nextConfig) {
if (providerChanged && session) {
await rebuildSessionForProviderChange(
ctx,
manager,
binding,
sessionId,
session.config,
nextConfig,
);
} else if (
binding.kind === "ssh" ||
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, nextConfig)
) {
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
buildSessionConnectionUpdate(
binding.kind === "ssh"
? await withRemoteProviderCredentials(nextConfig)
: nextConfig,
),
);
}
if (session) {
@@ -1190,6 +1381,14 @@ async function handleSend(
}
}
if (
binding.kind === "ssh" &&
(request.attachments?.userFiles?.length ?? 0) > 0
) {
throw new Error(
"File attachments are not available in the SSH proof of concept yet. Images and text prompts are supported.",
);
}
const userFiles = materializeUserFiles(
sessionId,
request.attachments?.userFiles,
@@ -1333,7 +1532,7 @@ async function handleStop(
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
cancelSidecarMistakeQuestions(ctx, sessionId, "Session stopped");
await getSessionManager(ctx).stop(sessionId);
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1349,7 +1548,10 @@ async function handleAbort(
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
cancelSidecarMistakeQuestions(ctx, sessionId, "Run aborted");
await getSessionManager(ctx).abort(sessionId, "user_abort");
await getSessionManager(ctx, sessionId, request.config).abort(
sessionId,
"user_abort",
);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1371,7 +1573,7 @@ async function handleFork(
) {
throw new Error("forkBeforeRunCount must be a positive integer");
}
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sourceSessionId, request.config);
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
if (
forkBeforeRunCount !== undefined &&
@@ -1423,10 +1625,18 @@ async function handleForkUnlocked(
sourceSession: SessionRecord | undefined,
restoreWorkspacePath?: string,
): Promise<unknown> {
const manager = getSessionManager(ctx);
const binding = getSessionRuntimeBinding(
ctx,
sourceSessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const sourceMessages =
readPersistedChatMessages(sourceSessionId) ??
ctx.liveSessions.get(sourceSessionId)?.messages;
binding.kind === "ssh"
? await manager.readMessages(sourceSessionId)
: (readPersistedChatMessages(sourceSessionId) ??
ctx.liveSessions.get(sourceSessionId)?.messages);
if (!sourceMessages?.length) {
throw new Error(`No messages found for session ${sourceSessionId}`);
}
@@ -1434,12 +1644,16 @@ async function handleForkUnlocked(
const sourceMetadata =
(sourceSession?.metadata && typeof sourceSession.metadata === "object"
? (sourceSession.metadata as JsonRecord)
: undefined) ?? readSessionMetadata(sourceSessionId);
: undefined) ??
(binding.kind === "local"
? readSessionMetadata(sourceSessionId)
: undefined);
const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config;
const forkConfig: JsonRecord = {
const baseForkConfig: JsonRecord = {
...(liveConfig ?? {}),
...(request.config ?? {}),
sessionId: undefined,
environmentId: binding.environmentId,
provider:
sourceSession?.provider ||
liveConfig?.provider ||
@@ -1469,6 +1683,10 @@ async function handleForkUnlocked(
request.config?.cwd ||
"",
};
const forkConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(baseForkConfig)
: baseForkConfig;
const checkpointMetadata =
sourceMetadata?.checkpoint !== undefined
? { checkpoints: sourceMetadata.checkpoint }
@@ -1489,8 +1707,10 @@ async function handleForkUnlocked(
...checkpointMetadata,
},
};
const systemPrompt = await resolveSystemPrompt(forkConfig);
// Assigned below once the forked session exists; read lazily by the prompt.
const systemPrompt =
binding.kind === "ssh"
? readExplicitSystemPrompt(forkConfig)
: await resolveSystemPrompt(forkConfig);
let newSessionId = "";
const startInput = {
...splitCoreSessionConfig(
@@ -1568,13 +1788,20 @@ async function handleForkUnlocked(
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
createLiveSession(forkConfig, {
createLiveSession(baseForkConfig, {
environmentId: binding.environmentId,
messages: forkMessages,
prompt: derivePromptFromMessages(forkMessages),
title: readSessionMetadataTitle(sourceSessionId),
title:
binding.kind === "local"
? readSessionMetadataTitle(sourceSessionId)
: typeof sourceMetadata?.title === "string"
? sourceMetadata.title
: undefined,
status: "idle",
}),
);
ctx.sessionEnvironmentIds.set(newSessionId, binding.environmentId);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, newSessionId);
return {
@@ -1597,10 +1824,11 @@ async function handleReset(
session?.status === "running" ||
session?.status === "stopping"
) {
await getSessionManager(ctx).stop(sessionId);
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
ctx.sessionEnvironmentIds.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
return { sessionId: request.sessionId, ok: true };
@@ -1619,14 +1847,25 @@ async function handleRestoreCheckpoint(
runCount < 1
)
throw new Error("checkpointRunCount must be a positive integer");
const config = request.config;
if (!config) throw new Error("config is required to restore a checkpoint");
const requestedConfig = request.config;
if (!requestedConfig)
throw new Error("config is required to restore a checkpoint");
const cwd =
(typeof config.cwd === "string" && config.cwd.trim()) ||
(typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) ||
(typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) ||
(typeof requestedConfig.workspaceRoot === "string" &&
requestedConfig.workspaceRoot.trim()) ||
"";
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
const manager = getSessionManager(ctx);
const binding = getSessionRuntimeBinding(
ctx,
sourceSessionId,
readEnvironmentId(requestedConfig),
);
const manager = binding.sessionManager;
const config =
binding.kind === "ssh"
? await withRemoteProviderCredentials(requestedConfig)
: requestedConfig;
return withWorkspaceRestoreLock(ctx, cwd, async () => {
// Updated once restore() returns; read lazily by the mistake-limit prompt.
let restoredSessionId = sourceSessionId;
@@ -1640,7 +1879,10 @@ async function handleRestoreCheckpoint(
buildCoreSessionConfig(
{
...config,
systemPrompt: await resolveSystemPrompt(config),
systemPrompt:
binding.kind === "ssh"
? readExplicitSystemPrompt(config)
: await resolveSystemPrompt(config),
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => restoredSessionId),
@@ -1669,7 +1911,8 @@ async function handleRestoreCheckpoint(
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
createLiveSession(config, {
createLiveSession(requestedConfig, {
environmentId: binding.environmentId,
messages: restoredMessages,
prompt: derivePromptFromMessages(restoredMessages),
title: readSessionMetadataTitle(sourceSessionId),
@@ -1680,7 +1923,10 @@ async function handleRestoreCheckpoint(
// transcript describing the discarded turns, and read_session_messages
// prefers that file over the live session. Write the trimmed history so
// the transcript matches the workspace the restore just rolled back to.
persistSessionMessages(sessionId, restoredMessages);
if (binding.kind === "local") {
persistSessionMessages(sessionId, restoredMessages);
}
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
return {
@@ -1696,9 +1942,11 @@ async function handlePendingPrompts(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompts = await getSessionManager(ctx).pendingPrompts.list({
const prompts = await getSessionManager(
ctx,
sessionId,
});
request.config,
).pendingPrompts.list({ sessionId });
return {
sessionId,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
@@ -1714,7 +1962,7 @@ async function handleSteerPrompt(
if (!sessionId) throw new Error("sessionId is required");
if (request.promptId !== undefined && !promptId)
throw new Error("promptId cannot be empty");
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sessionId, request.config);
const result = promptId
? await manager.pendingPrompts.update({
sessionId,
@@ -1742,16 +1990,24 @@ async function handleUpdatePendingPrompt(
if (!prompt) {
throw new Error("prompt is required");
}
const manager = getSessionManager(ctx);
const binding = getSessionRuntimeBinding(
ctx,
sessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const sessionConfig = ctx.liveSessions.get(sessionId)?.config;
// Queued prompts are delivered by the runtime without another pass
// through handleSend, so resolve slash commands here too.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
prompt,
sessionConfig?.mode,
);
const runtimePrompt =
binding.kind === "ssh"
? prompt
: await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(sessionConfig) ?? ctx.localWorkspaceRoot,
prompt,
sessionConfig?.mode,
);
const result = await manager.pendingPrompts.update({
sessionId,
promptId,
@@ -1774,7 +2030,7 @@ async function handleRemovePendingPrompt(
if (!sessionId || !promptId) {
throw new Error("sessionId and promptId are required");
}
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sessionId, request.config);
const result = await manager.pendingPrompts.delete({
sessionId,
promptId,
@@ -1819,5 +2075,19 @@ export async function handleChatSessionCommand(
): Promise<unknown> {
const handler = ACTION_HANDLERS[request.action];
if (!handler) throw new Error("unsupported action");
return handler(ctx, request);
const explicitEnvironment = readEnvironmentId(request.config);
const binding =
!explicitEnvironment && request.sessionId
? await findSessionRuntimeBinding(ctx, request.sessionId)
: undefined;
return handler(
getEnvironmentContext(
ctx,
explicitEnvironment ??
binding?.environmentId ??
ctx.activeEnvironmentId ??
"local",
),
request,
);
}
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,11 @@ function createContext(): { ctx: SidecarContext } {
]),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
runtimeBindings: new Map(),
sessionEnvironmentIds: new Map(),
activeEnvironmentId: "local",
remoteEnvironments: null,
localWorkspaceRoot: "/local/workspace",
cloudSessionManager: null,
hubClient: null,
workspaceRoot: "/local/workspace",
@@ -0,0 +1,622 @@
import { HubCommandError, HubTransportError } from "@cline/core";
import type { HubEventEnvelope } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import {
CloudSessionApi,
CloudSessionManager,
type CloudSessionRecord,
} from "./cloud-sessions";
import { createSidecarContext } from "./context";
import type { LiveSession } from "./types";
async function createFixture() {
const ctx = createSidecarContext("/workspace");
const live: LiveSession = {
config: { executionTarget: "cloud", model: "test-model" },
messages: [],
promptsInQueue: [],
busy: false,
status: "idle",
startedAt: 1,
};
ctx.liveSessions.set("ses-outer", live);
const remote: CloudSessionRecord = {
id: "ses-outer",
status: "ready",
sandboxUrl: "https://pod.example/hub",
repoContext: { repoUrl: "https://github.com/cline/test" },
metadata: { modelId: "test-model" },
createdAt: "2026-08-05T10:00:00.000Z",
updatedAt: "2026-08-05T10:01:00.000Z",
};
const options = {
apiBaseUrl: "https://api.example",
getAuthToken: async () => "token",
};
const api = new CloudSessionApi({
...options,
appBaseUrl: "https://app.example",
});
vi.spyOn(api, "list").mockResolvedValue([remote]);
const updateTitle = vi.spyOn(api, "updateTitle").mockResolvedValue(remote);
const manager = new CloudSessionManager(ctx, { ...options, api });
const replies: Record<string, Record<string, unknown>> = {
"session.get": { session: { status: "idle" } },
"session.messages": { messages: [] },
"session.pending_prompts": { prompts: [] },
};
const reply = (name: string) => ({
version: "v1" as const,
ok: true as const,
payload: replies[name] ?? {},
});
const command = vi.fn(
async (
name: string,
_payload?: unknown,
_sessionId?: string,
_options?: unknown,
) => reply(name),
);
const connection: Awaited<
ReturnType<CloudSessionManager["ensureConnection"]>
> = {
remote,
innerSessionId: "inner-1",
client: {
command,
connect: vi.fn(),
dispose: vi.fn(),
getClientId: () => "test-client",
subscribe: () => vi.fn(),
},
bufferedEvents: [],
bufferedEventsDropped: 0,
transcriptKnown: false,
seenEventIds: new Set(),
unsubscribe: vi.fn(),
};
const ensureAttached = vi.fn(async () => {});
const forwardEvent = vi.fn();
// Stub transport ownership; exercise the real interaction logic.
Object.assign(manager, {
ensureConnection: async () => connection,
ensureAttached,
forwardEvent,
applySessionModel: vi.fn(),
refreshKnownSession: async () => undefined,
loadArchivedMessages: async () => null,
});
await manager.list();
return {
ctx,
manager,
live,
connection,
command,
replies,
reply,
ensureAttached,
forwardEvent,
updateTitle,
};
}
const transportError = () =>
new HubTransportError("hub_connection_closed", "socket closed");
describe("CloudSessionManager state", () => {
it("keeps a pending send cancelled when deletion clears its abort token", async () => {
const { manager, connection, ensureAttached, command } =
await createFixture();
manager["connections"].set("ses-outer", connection);
vi.spyOn(manager["options"].api, "delete").mockResolvedValue(undefined);
let releaseAttach!: () => void;
ensureAttached.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseAttach = resolve;
}),
);
const result = manager
.send("ses-outer", "cancel this")
.catch((error: unknown) => error);
await vi.waitFor(() => expect(releaseAttach).toBeDefined());
await manager.abort("ses-outer");
await manager.delete("ses-outer");
releaseAttach();
expect(await result).toMatchObject({
message: "Cloud session prompt cancelled",
});
expect(connection.disposed).toBe(true);
expect(connection.client.dispose).toHaveBeenCalledOnce();
expect(
command.mock.calls.some(([name]) => name === "session.send_input"),
).toBe(false);
});
it("does not dispatch a pending send after abort and allows a later send", async () => {
const { manager, ensureAttached, command } = await createFixture();
let releaseAttach!: () => void;
ensureAttached.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseAttach = resolve;
}),
);
const sending = manager.send("ses-outer", "cancel this");
const result = sending.catch((error: unknown) => error);
await vi.waitFor(() => expect(releaseAttach).toBeDefined());
await manager.abort("ses-outer");
releaseAttach();
expect(await result).toBeInstanceOf(Error);
expect(
command.mock.calls.some(([name]) => name === "session.send_input"),
).toBe(false);
await manager.send("ses-outer", "send this instead");
expect(
command.mock.calls.filter(([name]) => name === "session.send_input"),
).toHaveLength(1);
});
it("refreshes the completion time for a later turn completed while disconnected", async () => {
const { manager, live, replies } = await createFixture();
live.status = "running";
live.endedAt = 1;
replies["session.get"] = { session: { status: "completed" } };
await manager.readMessages("ses-outer");
expect(live.endedAt).toBeGreaterThan(1);
});
it("rejects malformed queue command replies instead of clearing the queue", async () => {
const { manager, live, replies } = await createFixture();
replies["session.pending_prompts"] = {
prompts: [
{
id: "q-1",
prompt: "queued",
userImages: ["data:image/png;base64,AQID"],
},
],
};
await manager.pendingPrompts("ses-outer");
expect(live.promptsInQueue).toMatchObject([
{ id: "q-1", userImages: ["data:image/png;base64,AQID"] },
]);
const previous = live.promptsInQueue;
replies["session.pending_prompts"] = {};
await expect(manager.pendingPrompts("ses-outer")).rejects.toThrow(
"invalid pending-prompts snapshot",
);
expect(live.promptsInQueue).toBe(previous);
});
it("rejects a queue snapshot returned after its connection was disposed", async () => {
const { manager, connection, command, reply, replies, live } =
await createFixture();
manager["connections"].set("ses-outer", connection);
const blocked = Promise.withResolvers<void>();
const reached = Promise.withResolvers<void>();
const unregister = Promise.withResolvers<void>();
vi.mocked(connection.client.dispose).mockReturnValue(unregister.promise);
command.mockImplementationOnce(async (name) => {
reached.resolve();
await blocked.promise;
return reply(name);
});
replies["session.pending_prompts"] = {
prompts: [{ id: "q-1", prompt: "stale queued prompt" }],
};
const result = manager.pendingPrompts("ses-outer").catch((error) => error);
await reached.promise;
const disposing = manager["disposeConnection"]("ses-outer");
blocked.resolve();
const snapshot = await result;
unregister.resolve();
await disposing;
expect(snapshot).toMatchObject({
message: "Cloud session connection was disposed",
});
expect(live.promptsInQueue).toEqual([]);
});
it("queues one rerun when a second sync overlaps the active snapshot", async () => {
const { manager, command, reply } = await createFixture();
const blocked = Promise.withResolvers<void>();
const reached = Promise.withResolvers<void>();
let reads = 0;
command.mockImplementation(async (name) => {
if (name === "session.messages" && ++reads === 1) {
reached.resolve();
await blocked.promise;
}
return reply(name);
});
const first = manager.readMessages("ses-outer");
await reached.promise;
const second = manager.readMessages("ses-outer");
blocked.resolve();
await Promise.all([first, second]);
expect(reads).toBe(2);
});
it("retains the prior transcript and releases buffered events when a snapshot fails", async () => {
const {
ctx,
manager,
live,
connection,
command,
reply,
replies,
forwardEvent,
} = await createFixture();
const send = vi.fn();
ctx.wsClients.add({ send });
const previous = [{ role: "assistant" as const, content: "keep me" }];
live.messages = previous;
const event: HubEventEnvelope = {
version: "v1",
event: "assistant.delta",
sessionId: "inner-1",
payload: { text: "still live" },
};
replies["session.messages"] = { messages: "invalid" };
command.mockImplementation(async (name) => {
if (name === "session.messages") connection.bufferedEvents.push(event);
return reply(name);
});
await expect(manager.readMessages("ses-outer")).rejects.toThrow(
/invalid transcript snapshot/i,
);
expect(live.messages).toBe(previous);
expect(forwardEvent).toHaveBeenCalledExactlyOnceWith(
"ses-outer",
connection,
event,
);
expect(connection.bufferingEvents).toBe(false);
expect(
send.mock.calls.map(([message]) => JSON.parse(message).event.name),
).toContain("cloud_session_sync_failed");
});
it("queues an implicit send when a cold session is already running", async () => {
const { manager, command, replies } = await createFixture();
replies["session.get"] = { session: { status: "running" } };
await expect(
manager.send("ses-outer", "Run this next"),
).resolves.toMatchObject({ ok: true, queued: true });
expect(command).toHaveBeenCalledWith(
"session.send_input",
{ prompt: "Run this next", delivery: "queue" },
"inner-1",
{ timeoutMs: 30_000 },
);
});
it("reports a runtime-queued idle send and preserves an accepted message", async () => {
const { manager, connection, command, live, reply } = await createFixture();
connection.transcriptKnown = true;
const accepted = { role: "user" as const, content: "Run this next" };
command.mockImplementation(async (name) => {
if (name === "session.send_input") {
live.messages = [...live.messages, accepted];
live.busy = false;
live.status = "completed";
}
return reply(name);
});
const result = await manager.send("ses-outer", "Run this next");
expect(result).toMatchObject({ ok: true, queued: true });
expect(command).toHaveBeenCalledWith(
"session.send_input",
expect.objectContaining({ prompt: "Run this next", delivery: undefined }),
"inner-1",
expect.anything(),
);
expect(live.messages).toEqual([accepted]);
expect(live).toMatchObject({ busy: false, status: "completed" });
});
it("does not mark a send queued when runtime returns a normal result", async () => {
const { manager, connection, replies } = await createFixture();
connection.transcriptKnown = true;
replies["session.send_input"] = { result: { text: "done" } };
const result = await manager.send("ses-outer", "Run now");
expect(result).toMatchObject({ ok: true });
expect(result.queued).toBeUndefined();
});
it("keeps an explicit steer reply distinct from a queued successor", async () => {
const { manager, connection } = await createFixture();
connection.transcriptKnown = true;
const result = await manager.send("ses-outer", "Steer now", "steer");
expect(result.queued).toBeUndefined();
});
it.each([
false,
true,
])("does not let automatic title writing overwrite an explicit rename (auto fails: %s)", async (autoFails) => {
const { manager, connection, updateTitle } = await createFixture();
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
let serverTitle = "";
let calls = 0;
updateTitle.mockImplementation(async (_sessionId, title) => {
calls += 1;
if (calls === 1) {
started.resolve();
await release.promise;
if (autoFails) throw new Error("automatic title failed");
}
serverTitle = title;
return connection.remote;
});
const sending = manager.send("ses-outer", "Derived title");
await started.promise;
const renaming = manager.updateTitle("ses-outer", "Renamed");
expect(updateTitle).toHaveBeenCalledTimes(1);
release.resolve();
await Promise.all([sending, renaming]);
expect(serverTitle).toBe("Renamed");
});
it.each([
false,
true,
])("skips automatic naming while a manual rename is pending (manual fails: %s)", async (manualFails) => {
const { manager, connection, live, updateTitle } = await createFixture();
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
let calls = 0;
let serverTitle = "";
updateTitle.mockImplementation(async (_sessionId, title) => {
calls += 1;
if (calls === 1) {
started.resolve();
await release.promise;
if (manualFails) throw new Error("manual title failed");
}
serverTitle = title;
return connection.remote;
});
const manual = manager
.updateTitle("ses-outer", "Renamed")
.catch((error: unknown) => error);
await started.promise;
await manager.send("ses-outer", "Derived title");
expect(updateTitle).toHaveBeenCalledTimes(1);
expect(live.title).toBeUndefined();
release.resolve();
const manualOutcome = await manual;
if (manualFails) {
expect(manualOutcome).toBeInstanceOf(Error);
} else {
expect(manualOutcome).toBeUndefined();
}
if (manualFails) {
await manager.updateTitle("ses-outer", "Renamed again");
}
expect(serverTitle).toBe(manualFails ? "Renamed again" : "Renamed");
expect(updateTitle).toHaveBeenCalledTimes(manualFails ? 2 : 1);
});
it.each([
false,
true,
])("orders overlapping manual renames (first fails: %s)", async (firstFails) => {
const { manager, connection, updateTitle } = await createFixture();
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const savedTitles: string[] = [];
updateTitle.mockImplementation(async (_sessionId, title) => {
if (title === "First") {
started.resolve();
await release.promise;
if (firstFails) throw new Error("first rename failed");
}
savedTitles.push(title);
return connection.remote;
});
const first = manager
.updateTitle("ses-outer", "First")
.catch((error: unknown) => error);
await started.promise;
const second = manager.updateTitle("ses-outer", "Second");
try {
await Promise.resolve();
expect(updateTitle).toHaveBeenCalledTimes(1);
} finally {
release.resolve();
}
const [firstOutcome] = await Promise.all([first, second]);
if (firstFails) expect(firstOutcome).toBeInstanceOf(Error);
else expect(firstOutcome).toBeUndefined();
expect(savedTitles).toEqual(firstFails ? ["Second"] : ["First", "Second"]);
});
it("does not confirm a lost duplicate prompt against an earlier delivery", async () => {
const { manager, command, replies, live } = await createFixture();
replies["session.send_input"] = { result: {} };
await manager.send("ses-outer", "yes");
live.busy = false;
replies["session.messages"] = {
messages: [
{ role: "user", content: '<user_input mode="act">yes</user_input>' },
],
};
command.mockRejectedValueOnce(transportError());
await expect(manager.send("ses-outer", "yes")).rejects.toThrow(
/could not confirm.*before resending/,
);
});
it("does not confirm two queued sends from one matching recovered prompt", async () => {
const { manager, command, replies, reply } = await createFixture();
await manager.readMessages("ses-outer");
const blocked = Promise.withResolvers<void>();
const reached = Promise.withResolvers<void>();
let sends = 0;
command.mockImplementation(async (name) => {
if (name === "session.send_input") {
if (++sends === 2) reached.resolve();
await blocked.promise;
throw transportError();
}
return reply(name);
});
const results = Promise.allSettled([
manager.send("ses-outer", "same prompt", "queue"),
manager.send("ses-outer", "same prompt", "queue"),
]);
await reached.promise;
// Only one command was accepted by the Hub; neither reply reached this client.
replies["session.pending_prompts"] = {
prompts: [{ id: "accepted", prompt: "same prompt", delivery: "queue" }],
};
blocked.resolve();
for (const result of await results) {
expect(result.status).toBe("rejected");
if (result.status === "rejected") {
expect(result.reason.message).toMatch(
/could not confirm.*before resending/,
);
}
}
expect(sends).toBe(2);
});
it("reattaches after a transport failure without retrying the prompt", async () => {
const { manager, command, replies, ensureAttached } = await createFixture();
await manager.readMessages("ses-outer");
command.mockClear();
ensureAttached.mockClear();
replies["session.messages"] = {
messages: [
{
role: "user",
content: '<user_input mode="act">Do this once</user_input>',
},
],
};
replies["session.get"] = { session: { status: "running" } };
command.mockRejectedValueOnce(transportError());
await expect(
manager.send("ses-outer", "Do this once"),
).resolves.toMatchObject({
ok: true,
recoveredAfterDisconnect: true,
status: "running",
});
expect(command.mock.calls.map(([name]) => name)).toEqual([
"session.send_input",
"session.get",
"session.messages",
"session.pending_prompts",
]);
expect(ensureAttached).toHaveBeenCalledTimes(2);
});
it("keeps a direct send uncertain when transport recovery cannot find the prompt", async () => {
const { manager, command } = await createFixture();
await manager.readMessages("ses-outer");
command.mockRejectedValueOnce(transportError());
await expect(manager.send("ses-outer", "Lost prompt")).rejects.toThrow(
/could not confirm.*before resending/,
);
expect(
command.mock.calls.filter(([name]) => name === "session.send_input"),
).toHaveLength(1);
});
it("keeps a lost steer reply uncertain even with matching recovery events", async () => {
const { manager, command, connection, reply } = await createFixture();
await manager.readMessages("ses-outer");
command
.mockImplementation(async (name) => {
if (name === "session.messages")
connection.bufferedEvents.push({
version: "v1",
event: "session.pending_prompt_submitted",
sessionId: "inner-1",
payload: {
prompt: {
id: "steer-1",
prompt: "Steer accepted",
delivery: "steer",
attachmentCount: 0,
},
},
});
return reply(name);
})
.mockRejectedValueOnce(transportError());
await expect(
manager.send("ses-outer", "Steer accepted", "steer"),
).rejects.toThrow(/could not confirm.*before resending/);
expect(
command.mock.calls.filter(([name]) => name === "session.send_input"),
).toHaveLength(1);
});
it.each([
"transport",
"timeout",
])("keeps a queued %s failure uncertain despite a matching snapshot", async (failure) => {
const { manager, command, replies } = await createFixture();
await manager.readMessages("ses-outer");
replies["session.pending_prompts"] = {
prompts: [
{
id: "q-1",
prompt: "Queued during disconnect",
delivery: "queue",
attachmentCount: 0,
},
],
};
command.mockRejectedValueOnce(
failure === "timeout"
? new HubCommandError(
"session.send_input",
"hub_command_timeout",
"timed out",
)
: transportError(),
);
await expect(
manager.send("ses-outer", "Queued during disconnect", "queue"),
).rejects.toThrow(/could not confirm.*before resending/);
expect(
command.mock.calls.filter(([name]) => name === "session.send_input"),
).toHaveLength(1);
});
it("names the session from the first prompt and supports rename", async () => {
const { manager, live, updateTitle } = await createFixture();
await manager.send(
"ses-outer",
"Fix the login bug\nwith more detail below",
);
expect(updateTitle).toHaveBeenCalledExactlyOnceWith(
"ses-outer",
"Fix the login bug",
);
expect(live.title).toBe("Fix the login bug");
await manager.send("ses-outer", "another prompt");
expect(updateTitle).toHaveBeenCalledTimes(1);
await manager.updateTitle("ses-outer", "Renamed");
expect(updateTitle).toHaveBeenLastCalledWith("ses-outer", "Renamed");
expect(live.title).toBe("Renamed");
});
});
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ vi.mock("@cline/core", async () => {
function createContext(): SidecarContext {
return {
workspaceRoot: "/workspace",
localWorkspaceRoot: "/workspace",
wsClients: new Set(),
hubBuildMismatch: {
url: "ws://127.0.0.1:25463/hub",
File diff suppressed because it is too large Load Diff
@@ -194,7 +194,7 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(hubClient).toBeDefined();
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
@@ -230,8 +230,14 @@ describe("Code sidecar runtime capabilities", () => {
];
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
const list = vi.fn(async () => []);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
ctx.runtimeBindings.set("local", {
environmentId: "local",
kind: "local",
workspaceRoot: "/workspace/project",
hubClient: { command } as never,
sessionManager: { list } as never,
unsubscribeSessionEvents: () => {},
});
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
@@ -269,8 +275,14 @@ describe("Code sidecar runtime capabilities", () => {
metadata: { title: oversizedPrompt },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
ctx.runtimeBindings.set("local", {
environmentId: "local",
kind: "local",
workspaceRoot: "/workspace/project",
hubClient: { command } as never,
sessionManager: { list } as never,
unsubscribeSessionEvents: () => {},
});
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
@@ -304,8 +316,14 @@ describe("Code sidecar runtime capabilities", () => {
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
ctx.runtimeBindings.set("local", {
environmentId: "local",
kind: "local",
workspaceRoot: "/workspace/project",
hubClient: { command } as never,
sessionManager: { list } as never,
unsubscribeSessionEvents: () => {},
});
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
@@ -339,8 +357,14 @@ describe("Code sidecar runtime capabilities", () => {
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
ctx.runtimeBindings.set("local", {
environmentId: "local",
kind: "local",
workspaceRoot: "/workspace/project",
hubClient: { command } as never,
sessionManager: { list } as never,
unsubscribeSessionEvents: () => {},
});
const pending = handleCommand(ctx, "search_sessions", {
query: "generate",
@@ -453,6 +477,24 @@ describe("Code sidecar runtime capabilities", () => {
});
});
it("preserves the reflected transcript marker on queued lifecycle chunks", async () => {
const { serializeQueuedPromptStart } = await import("./context");
expect(
JSON.parse(
serializeQueuedPromptStart({
promptId: "q-1",
prompt: "Continue",
transcriptReflected: true,
}),
),
).toEqual({
promptId: "q-1",
prompt: "Continue",
attachmentCount: 0,
transcriptReflected: true,
});
});
it("announces a queued prompt start once when drain emits both queue events", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -583,6 +625,7 @@ describe("Code sidecar runtime capabilities", () => {
events.find((message) => message.event.name === "prompts_in_queue_state")
?.event.payload,
).toEqual({
environmentId: "local",
sessionId: "session-1",
items: [
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
@@ -748,7 +791,7 @@ describe("Code sidecar runtime capabilities", () => {
expect.objectContaining({
event: expect.objectContaining({
name: "ask_question_answered",
payload: { requestId },
payload: { requestId, environmentId: "local" },
}),
}),
);
@@ -1245,6 +1288,7 @@ describe("Code sidecar runtime capabilities", () => {
event: {
name: "task.created",
payload: {
environmentId: "local",
taskId: "task-1",
status: "pending_approval",
},
@@ -1273,6 +1317,7 @@ describe("Code sidecar runtime capabilities", () => {
event: {
name: "settings.changed",
payload: {
environmentId: "local",
types: ["plugins", "skills", "mcp"],
},
},
@@ -1348,9 +1393,11 @@ describe("Chat chunk pipe selection", () => {
status: "running",
attachedViaHub: true,
});
ctx.sessionManager = {
hasSessionSubscription: (id: string) => coreSubscriptions.has(id),
} as never;
ctx.runtimeBindings.set("local", {
sessionManager: {
hasSessionSubscription: (id: string) => coreSubscriptions.has(id),
},
} as never);
return ctx;
}
+335 -74
View File
@@ -36,9 +36,11 @@ import type {
PendingAskQuestion,
PendingToolApproval,
PromptInQueue,
SessionRuntimeBinding,
SidecarContext,
SidecarWebSocketClient,
} from "./types";
import { LOCAL_ENVIRONMENT_ID } from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
@@ -51,6 +53,73 @@ const approvalReadinessUpdates = new WeakMap<SidecarContext, Promise<void>>();
// Helpers — WebSocket broadcast
// ---------------------------------------------------------------------------
// Session state belongs to a runtime environment, not to a globally unique ID.
const environmentContexts = new WeakMap<
SidecarContext,
Map<string, SidecarContext>
>();
const contextOwners = new WeakMap<SidecarContext, SidecarContext>();
export function getEnvironmentContext(
ctx: SidecarContext,
environmentId: string,
): SidecarContext {
const owner = contextOwners.get(ctx) ?? ctx;
let contexts = environmentContexts.get(owner);
if (!contexts) {
contexts = new Map();
environmentContexts.set(owner, contexts);
}
const existing = contexts.get(environmentId);
if (existing) return existing;
const local = environmentId === LOCAL_ENVIRONMENT_ID;
// Shared services are inherited so later initialization remains visible;
// session state and event identity are owned by this environment.
const scoped: SidecarContext = Object.assign(Object.create(owner), {
activeEnvironmentId: environmentId,
liveSessions: local ? owner.liveSessions : new Map(),
streamIndices: local ? owner.streamIndices : new Map(),
sessionEnvironmentIds: local ? owner.sessionEnvironmentIds : new Map(),
restoringWorkspacePaths: local ? owner.restoringWorkspacePaths : new Set(),
pendingApprovals: local ? owner.pendingApprovals : new Map(),
pendingQuestions: local ? owner.pendingQuestions : new Map(),
});
contextOwners.set(scoped, owner);
contexts.set(environmentId, scoped);
return scoped;
}
export function getEnvironmentContexts(ctx: SidecarContext): SidecarContext[] {
const owner = contextOwners.get(ctx) ?? ctx;
getEnvironmentContext(owner, LOCAL_ENVIRONMENT_ID);
return [...(environmentContexts.get(owner)?.values() ?? [])];
}
function clearEnvironmentSessions(ctx: SidecarContext, reason: string): void {
for (const [id, session] of ctx.liveSessions)
discardAllTrackedAttachments(id, session);
ctx.liveSessions.clear();
ctx.streamIndices.clear();
ctx.sessionEnvironmentIds.clear();
ctx.restoringWorkspacePaths.clear();
for (const pending of ctx.pendingApprovals.values())
pending.resolve({ approved: false, reason });
ctx.pendingApprovals.clear();
for (const pending of ctx.pendingQuestions.values()) {
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.reject(new Error(reason));
}
ctx.pendingQuestions.clear();
}
function sessionEventPayload(ctx: SidecarContext, payload: unknown): unknown {
return payload && typeof payload === "object"
? {
environmentId: ctx.activeEnvironmentId ?? LOCAL_ENVIRONMENT_ID,
...payload,
}
: payload;
}
function nowMs(): number {
return Date.now();
}
@@ -63,7 +132,7 @@ export function encodeSidecarEvent(name: string, payload: unknown): string {
}
function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
const encoded = encodeSidecarEvent(name, payload);
const encoded = encodeSidecarEvent(name, sessionEventPayload(ctx, payload));
for (const client of ctx.wsClients) {
try {
client.send(encoded);
@@ -84,7 +153,7 @@ export function sendEventToClient(
payload: unknown,
): boolean {
try {
client.send(encodeSidecarEvent(name, payload));
client.send(encodeSidecarEvent(name, sessionEventPayload(ctx, payload)));
return true;
} catch {
ctx.wsClients.delete(client);
@@ -100,13 +169,15 @@ export function cancelSidecarToolApprovalsForOwner(
ctx: SidecarContext,
owner: SidecarWebSocketClient,
): void {
for (const [requestId, pending] of ctx.pendingApprovals) {
if (pending.owner !== owner) continue;
ctx.pendingApprovals.delete(requestId);
pending.resolve({
approved: false,
reason: "Desktop approval surface disconnected",
});
for (const scoped of getEnvironmentContexts(ctx)) {
for (const [requestId, pending] of scoped.pendingApprovals) {
if (pending.owner !== owner) continue;
scoped.pendingApprovals.delete(requestId);
pending.resolve({
approved: false,
reason: "Desktop approval surface disconnected",
});
}
}
}
@@ -117,21 +188,21 @@ export function syncSidecarApprovalReadiness(
const update = previous
.catch(() => undefined)
.then(async () => {
const hubClient = ctx.hubClient;
if (!hubClient) return;
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
for (const { hubClient } of ctx.runtimeBindings.values()) {
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
}
});
approvalReadinessUpdates.set(ctx, update);
return update.finally(() => {
@@ -178,7 +249,8 @@ function emitChunk(
chunk: string,
): void {
const ts = nowMs();
appendSessionChunk(sessionId, stream, chunk, ts);
if (ctx.activeEnvironmentId === LOCAL_ENVIRONMENT_ID)
appendSessionChunk(sessionId, stream, chunk, ts);
const nextIndex = (ctx.streamIndices.get(sessionId) ?? 0) + 1;
ctx.streamIndices.set(sessionId, nextIndex);
sendEvent(ctx, "chat_event", {
@@ -235,16 +307,18 @@ export function serializeQueuedPromptStart(input: {
prompt: string;
attachmentCount?: number;
userImages?: string[];
transcriptReflected?: boolean;
}): string {
return JSON.stringify({
promptId: input.promptId,
prompt: input.prompt,
attachmentCount: input.attachmentCount ?? 0,
userImages: input.userImages,
...(input.transcriptReflected ? { transcriptReflected: true } : {}),
});
}
function sendPromptsInQueueSnapshot(
export function sendPromptsInQueueSnapshot(
ctx: SidecarContext,
sessionId: string,
): void {
@@ -437,6 +511,7 @@ function emitQueuedPromptStart(
prompt: string;
attachmentCount: number;
userImages?: string[];
transcriptReflected?: boolean;
},
): void {
if (session) {
@@ -585,13 +660,15 @@ export function createSidecarContext(
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot,
runtimeBindings: new Map(),
sessionEnvironmentIds: new Map(),
activeEnvironmentId: LOCAL_ENVIRONMENT_ID,
remoteEnvironments: null,
localWorkspaceRoot: workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
telemetryUser: observability.telemetryUser,
unsubscribeSessionEvents: null,
cloudSessionManager: null,
hubBuildMismatch: null,
};
}
@@ -602,13 +679,8 @@ export async function disposeSidecarContext(
): Promise<void> {
const cleanup: Array<Promise<unknown>> = [];
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
}
ctx.liveSessions.clear();
for (const scoped of getEnvironmentContexts(ctx))
clearEnvironmentSessions(scoped, reason);
for (const client of ctx.wsClients) {
try {
@@ -618,26 +690,16 @@ export async function disposeSidecarContext(
}
}
ctx.wsClients.clear();
for (const pending of ctx.pendingApprovals.values()) {
pending.resolve({ approved: false, reason });
for (const binding of ctx.runtimeBindings.values()) {
binding.unsubscribeSessionEvents();
cleanup.push(binding.hubClient.dispose());
cleanup.push(binding.sessionManager.dispose(reason));
}
ctx.pendingApprovals.clear();
for (const pending of ctx.pendingQuestions.values()) {
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.reject(new Error(reason));
}
ctx.pendingQuestions.clear();
const hubClient = ctx.hubClient;
ctx.hubClient = null;
if (hubClient) {
cleanup.push(hubClient.dispose());
}
const sessionManager = ctx.sessionManager;
ctx.sessionManager = null;
if (sessionManager) {
cleanup.push(sessionManager.dispose(reason));
ctx.runtimeBindings.clear();
ctx.sessionEnvironmentIds.clear();
if (ctx.remoteEnvironments) {
cleanup.push(ctx.remoteEnvironments.dispose());
ctx.remoteEnvironments = null;
}
// Shuts down the PostHog client the feature flags service owns, flushing
@@ -819,6 +881,7 @@ export function handleHubLiveEvent(
event: {
event: string;
sessionId?: string;
sequence?: number;
payload?: Record<string, unknown>;
},
): void {
@@ -853,13 +916,33 @@ export function handleHubLiveEvent(
if (!session?.attachedViaHub) {
return;
}
const projectsStatus =
event.event === "run.started" ||
event.event === "session.attached" ||
event.event === "session.updated" ||
event.event === "run.completed" ||
event.event === "run.failed" ||
event.event === "run.aborted";
if (projectsStatus && typeof event.sequence === "number") {
if (
session.lastHubStatusSequence !== undefined &&
event.sequence < session.lastHubStatusSequence
) {
return;
}
session.lastHubStatusSequence = event.sequence;
}
// The observer client and ClineCore's own hub client are separate sockets
// that both receive this session's events. This projection only exists for
// sessions ClineCore is not subscribed to (it subscribes as a side effect
// of start/send/pending_prompts and unsubscribes on stop); once it is,
// `handleCoreSessionEvent` carries everything below and a second copy here
// would double every delta, tool row, and status change.
if (ctx.sessionManager?.hasSessionSubscription(sessionId)) {
if (
ctx.runtimeBindings
.get(ctx.activeEnvironmentId ?? LOCAL_ENVIRONMENT_ID)
?.sessionManager.hasSessionSubscription(sessionId)
) {
return;
}
@@ -970,6 +1053,15 @@ export function handleHubLiveEvent(
: event.event === "run.started"
? "running"
: session.status;
if (
event.event === "session.updated" &&
event.sequence === undefined &&
status === "running" &&
session.endedAt !== undefined &&
!session.busy
) {
return;
}
session.status = status;
session.busy = status === "running";
sendEvent(ctx, "chat_session_status", { sessionId, status });
@@ -1049,7 +1141,7 @@ async function handleHubApprovalRequest(
? (event.payload.policy as ToolApprovalRequest["policy"])
: { autoApprove: false },
});
const client = ctx.hubClient;
const client = getSessionRuntimeBinding(ctx, sessionId).hubClient;
if (!client)
throw new Error("Hub client disconnected before approval response");
await client.command(
@@ -1070,7 +1162,9 @@ export async function initializeSessionManager(
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
capabilities: createSidecarRuntimeCapabilities(
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
),
logger: ctx.logger,
telemetry: ctx.telemetry,
featureFlags: getDesktopFeatureFlagsService({
@@ -1079,8 +1173,8 @@ export async function initializeSessionManager(
}),
hub: {
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
},
@@ -1088,27 +1182,193 @@ export async function initializeSessionManager(
// Subscribe to all session events and relay them to WS clients
const unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
handleCoreSessionEvent(ctx, event);
handleCoreSessionEvent(
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
event,
);
});
let hubClient: NodeHubClient;
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
hubClient = await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.unsubscribeSessionEvents = unsubscribe;
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
environmentId: LOCAL_ENVIRONMENT_ID,
kind: "local",
workspaceRoot: ctx.localWorkspaceRoot,
sessionManager,
hubClient,
unsubscribeSessionEvents: unsubscribe,
});
await syncSidecarApprovalReadiness(ctx);
}
export function getRuntimeBinding(
ctx: SidecarContext,
environmentId = ctx.activeEnvironmentId,
): SessionRuntimeBinding {
const binding = ctx.runtimeBindings.get(environmentId);
if (!binding) {
throw new Error(`Environment ${environmentId} is not connected.`);
}
return binding;
}
export function getSessionRuntimeBinding(
ctx: SidecarContext,
sessionId?: string,
requestedEnvironmentId?: string,
): SessionRuntimeBinding {
const environmentId =
requestedEnvironmentId?.trim() ||
(sessionId ? ctx.liveSessions.get(sessionId)?.environmentId : undefined) ||
(sessionId ? ctx.sessionEnvironmentIds.get(sessionId) : undefined) ||
ctx.activeEnvironmentId;
return getRuntimeBinding(ctx, environmentId);
}
export async function findSessionRuntimeBinding(
ctx: SidecarContext,
sessionId: string,
preferredEnvironmentId?: string,
): Promise<SessionRuntimeBinding | undefined> {
if (preferredEnvironmentId?.trim())
return getRuntimeBinding(ctx, preferredEnvironmentId.trim());
const matches: SessionRuntimeBinding[] = [];
for (const binding of ctx.runtimeBindings.values()) {
try {
if (await binding.sessionManager.get(sessionId)) matches.push(binding);
} catch {
// Other connected runtimes remain readable.
}
}
if (matches.length > 1)
throw new Error(
`Session ${sessionId} exists in multiple environments; environmentId is required.`,
);
return matches[0];
}
async function disposeRuntimeBinding(
binding: SessionRuntimeBinding,
reason: string,
): Promise<void> {
try {
binding.unsubscribeSessionEvents();
} catch {
// Continue disposing the Hub clients even if an event source has already
// torn down its subscription.
}
await Promise.allSettled([
binding.hubClient.dispose(),
binding.sessionManager.dispose(reason),
]);
}
export async function connectRemoteSessionRuntime(
ctx: SidecarContext,
connection: NonNullable<SessionRuntimeBinding["remote"]>,
): Promise<SessionRuntimeBinding> {
const environmentId = connection.profile.id;
const existing = ctx.runtimeBindings.get(environmentId);
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
capabilities: createSidecarRuntimeCapabilities(
getEnvironmentContext(ctx, environmentId),
),
logger: ctx.logger,
telemetry: ctx.telemetry,
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
displayName: `Code App (${connection.profile.name})`,
},
});
let unsubscribe: (() => void) | undefined;
let hubClient: NodeHubClient | undefined;
try {
unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
handleCoreSessionEvent(getEnvironmentContext(ctx, environmentId), event);
});
hubClient = new NodeHubClient({
url: connection.endpoint,
authToken: connection.authToken,
clientType: "code-sidecar-ssh-observer",
displayName: `Code App observer (${connection.profile.name})`,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) =>
handleHubLiveEvent(getEnvironmentContext(ctx, environmentId), event),
);
} catch (error) {
try {
unsubscribe?.();
} catch {
// Best effort; the failed runtime still needs to be disposed below.
}
const disposals: Promise<unknown>[] = [
sessionManager.dispose("code_sidecar_remote_initialization_failed"),
];
if (hubClient) disposals.push(hubClient.dispose());
await Promise.allSettled(disposals);
throw error;
}
const binding: SessionRuntimeBinding = {
environmentId,
kind: "ssh",
workspaceRoot: connection.workspaceRoot,
sessionManager,
hubClient,
unsubscribeSessionEvents: unsubscribe,
remote: connection,
};
ctx.runtimeBindings.set(environmentId, binding);
await syncSidecarApprovalReadiness(ctx);
ctx.activeEnvironmentId = environmentId;
if (existing) {
await disposeRuntimeBinding(existing, "code_sidecar_remote_reconnect");
}
return binding;
}
export async function disconnectRemoteSessionRuntime(
ctx: SidecarContext,
environmentId: string,
): Promise<void> {
const binding = ctx.runtimeBindings.get(environmentId);
const owner = contextOwners.get(ctx) ?? ctx;
const scoped = environmentContexts.get(owner)?.get(environmentId);
if (scoped)
clearEnvironmentSessions(scoped, "Remote environment disconnected");
environmentContexts.get(owner)?.delete(environmentId);
if (binding?.kind === "ssh") {
ctx.runtimeBindings.delete(environmentId);
await disposeRuntimeBinding(binding, "code_sidecar_remote_disconnect");
}
if (ctx.activeEnvironmentId === environmentId) {
ctx.activeEnvironmentId = LOCAL_ENVIRONMENT_ID;
}
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
const existing = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
if (existing) {
return existing;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
@@ -1120,8 +1380,8 @@ export async function ensureSharedHubClient(
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
@@ -1131,16 +1391,17 @@ export async function ensureSharedHubClient(
url,
clientType: "code-sidecar-observer",
displayName: "Cline Desktop observer",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
handleHubLiveEvent(
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
event,
);
});
ctx.hubClient = client;
await syncSidecarApprovalReadiness(ctx);
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
+13 -3
View File
@@ -2,17 +2,21 @@ import { homedir } from "node:os";
import {
checkManagedHubBuildMismatch,
createClineTelemetryServiceConfig,
ensureLoginShellPath,
readGlobalSettings,
setHomeDirIfUnset,
setModelToolEnabledGlobally,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper";
import {
captureSdkError,
claimHubDaemonProcess,
disableCurrentDirectoryExecutableSearch,
setClineClientIdentity,
} from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
import { configureConnectorCliLaunch } from "./connectors";
import {
broadcastEvent,
@@ -23,7 +27,6 @@ import {
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { ensureLoginShellPath } from "./shell-path";
import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
@@ -236,13 +239,20 @@ async function runEntrypoint(): Promise<void> {
runTelemetrySelfcheck();
return;
}
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
disableCurrentDirectoryExecutableSearch();
// Claim rather than read: consuming the sentinel keeps daemon-hosted sessions
// from handing it to every process they spawn.
// Claim the Hub daemon sentinel here, not in the shared remote helper: its
// daemon import resolves to the dist build of @cline/core while this bundle
// resolves the source build, and a daemon from the other copy publishes a
// different build id, so the sidecar would retire its own Hub on launch.
if (claimHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
if (await runRemoteHelperEntrypoint()) {
return;
}
await main();
}
+2 -14
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { handleCommand } from "./commands";
import { createSidecarContext } from "./context";
import {
buildMcpServersResponse,
shouldProbeMcpServerAfterUpsert,
@@ -10,20 +11,7 @@ import {
import type { JsonRecord, SidecarContext } from "./types";
function createContext(workspaceRoot: string): SidecarContext {
return {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
bootId: "test-boot",
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot,
unsubscribeSessionEvents: null,
hubBuildMismatch: null,
};
return createSidecarContext(workspaceRoot);
}
describe("desktop MCP settings", () => {
@@ -0,0 +1,950 @@
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
RemoteEnvironmentConnection,
RemoteEnvironmentProfile,
RemoteEnvironmentService,
RemoteEnvironmentStatus,
} from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionRuntimeBinding, SidecarContext } from "./types";
const coreCreateMock = vi.hoisted(() => vi.fn());
const hubClientConstructorMock = vi.hoisted(() => vi.fn());
const hubConnectMock = vi.hoisted(() => vi.fn());
const hubSubscribeMock = vi.hoisted(() => vi.fn());
const hubDisposeMock = vi.hoisted(() => vi.fn());
const sessionStoreGetMock = vi.hoisted(() => vi.fn());
const sessionStoreDeleteMock = vi.hoisted(() => vi.fn());
const sessionStoreRunMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ClineCore: {
create: coreCreateMock,
},
SqliteSessionStore: class {
public get(sessionId: string): unknown {
return sessionStoreGetMock(sessionId);
}
public delete(sessionId: string, cascade?: boolean): boolean {
return sessionStoreDeleteMock(sessionId, cascade);
}
public run(sql: string, params?: unknown[]): void {
sessionStoreRunMock(sql, params);
}
},
NodeHubClient: class {
public async updateCapabilities(): Promise<void> {}
public constructor(options: unknown) {
hubClientConstructorMock(options);
}
public connect(): Promise<void> {
return hubConnectMock();
}
public subscribe(listener: unknown): () => void {
return hubSubscribeMock(listener);
}
public dispose(): Promise<void> {
return hubDisposeMock();
}
},
};
});
const profile: RemoteEnvironmentProfile = {
id: "remote-1",
name: "Build box",
host: "build.example.com",
user: "alice",
port: 2222,
createdAt: "2026-08-06T12:00:00.000Z",
updatedAt: "2026-08-06T12:00:00.000Z",
};
const connection: RemoteEnvironmentConnection = {
profile,
profileId: profile.id,
state: "connected",
endpoint: "ws://127.0.0.1:40123/hub",
authToken: "remote-hub-token",
workspaceRoot: "/home/alice",
homeDir: "/home/alice",
platform: "linux",
arch: "arm64",
remoteHubUrl: "ws://127.0.0.1:25463/hub",
localPort: 40123,
connectedAt: "2026-08-06T12:01:00.000Z",
};
const secondProfile: RemoteEnvironmentProfile = {
...profile,
id: "remote-2",
name: "Test box",
host: "test.example.com",
updatedAt: "2026-08-06T12:02:00.000Z",
};
const secondConnection: RemoteEnvironmentConnection = {
...connection,
profile: secondProfile,
profileId: secondProfile.id,
endpoint: "ws://127.0.0.1:40124/hub",
authToken: "second-remote-hub-token",
workspaceRoot: "/home/tester",
homeDir: "/home/tester",
remoteHubUrl: "ws://127.0.0.1:25464/hub",
localPort: 40124,
connectedAt: "2026-08-06T12:03:00.000Z",
};
type FakeService = {
service: RemoteEnvironmentService;
list: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
test: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
run: ReturnType<typeof vi.fn>;
};
function createFakeService(
availableConnections: RemoteEnvironmentConnection[] = [connection],
): FakeService {
const profiles = availableConnections.map((item) => item.profile);
const availableById = new Map(
availableConnections.map((item) => [item.profileId, item]),
);
const connectedById = new Map<string, RemoteEnvironmentConnection>();
let activeProfileId: string | undefined;
const list = vi.fn(async () => profiles);
const upsert = vi.fn(async () => profile);
const test = vi.fn(
async (): Promise<RemoteEnvironmentStatus> => ({
profileId: profile.id,
state: "available",
updatedAt: "2026-08-06T12:00:30.000Z",
message: "SSH connection succeeded",
remotePlatform: "linux",
remoteArch: "arm64",
}),
);
const connect = vi.fn(async (id: string) => {
const next = availableById.get(id);
if (!next) throw new Error(`Unknown fake remote environment: ${id}`);
connectedById.set(id, next);
activeProfileId = id;
return next;
});
const disconnect = vi.fn(async (id?: string) => {
const targetId = id ?? activeProfileId;
if (!targetId) return false;
const deleted = connectedById.delete(targetId);
if (activeProfileId === targetId) activeProfileId = undefined;
return deleted;
});
const deleteProfile = vi.fn(async () => true);
const run = vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
const service = {
list,
upsert,
test,
connect,
disconnect,
delete: deleteProfile,
run,
getActive: vi.fn(() =>
activeProfileId ? connectedById.get(activeProfileId) : undefined,
),
getConnection: vi.fn((id: string) => connectedById.get(id)),
activateConnection: vi.fn((id: string) => {
if (!connectedById.has(id)) return false;
activeProfileId = id;
return true;
}),
getStatuses: vi.fn(() => []),
} as unknown as RemoteEnvironmentService;
return {
service,
list,
upsert,
test,
connect,
disconnect,
delete: deleteProfile,
run,
};
}
function createManager() {
const unsubscribe = vi.fn();
const manager = {
subscribe: vi.fn(() => unsubscribe),
dispose: vi.fn(async () => undefined),
};
return { manager, unsubscribe };
}
function attachEventRecorder(ctx: SidecarContext): ReturnType<typeof vi.fn> {
const send = vi.fn();
ctx.wsClients.add({ send });
return send;
}
function readEvent(send: ReturnType<typeof vi.fn>, index: number) {
return JSON.parse(String(send.mock.calls[index]?.[0]));
}
function createExistingRemoteBinding(
environmentId: string,
): SessionRuntimeBinding {
const sessionManager = {
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["sessionManager"] & {
dispose: ReturnType<typeof vi.fn>;
};
const hubClient = {
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["hubClient"] & {
dispose: ReturnType<typeof vi.fn>;
};
return {
environmentId,
kind: "ssh",
workspaceRoot: "/old/workspace",
sessionManager,
hubClient,
unsubscribeSessionEvents: vi.fn(),
};
}
describe("remote environment command routing", () => {
beforeEach(() => {
coreCreateMock.mockReset();
hubClientConstructorMock.mockReset();
hubConnectMock.mockReset();
hubSubscribeMock.mockReset();
hubDisposeMock.mockReset();
sessionStoreGetMock.mockReset();
sessionStoreDeleteMock.mockReset();
sessionStoreRunMock.mockReset();
hubConnectMock.mockResolvedValue(undefined);
hubSubscribeMock.mockReturnValue(() => undefined);
hubDisposeMock.mockResolvedValue(undefined);
sessionStoreGetMock.mockReturnValue(undefined);
sessionStoreDeleteMock.mockReturnValue(false);
});
it("routes proceed-while-running exclusively to the requested SSH Hub", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/local/project");
const remoteCommand = vi.fn(async () => ({
ok: true,
payload: { detachedCount: 1 },
}));
const localCommand = vi.fn();
ctx.runtimeBindings.set("local", {
...createExistingRemoteBinding("local"),
kind: "local",
hubClient: { command: localCommand },
} as unknown as SessionRuntimeBinding);
ctx.runtimeBindings.set(profile.id, {
...createExistingRemoteBinding(profile.id),
hubClient: { command: remoteCommand },
} as unknown as SessionRuntimeBinding);
await expect(
handleCommand(ctx, "proceed_while_running", {
environmentId: profile.id,
sessionId: "same-id",
toolCallId: "tool-1",
}),
).resolves.toEqual({ detachedCount: 1 });
expect(remoteCommand).toHaveBeenCalledWith(
"run.proceed_while_running",
{ sessionId: "same-id", toolCallId: "tool-1" },
"same-id",
);
expect(localCommand).not.toHaveBeenCalled();
await expect(
handleCommand(ctx, "proceed_while_running", {
environmentId: "disconnected",
sessionId: "same-id",
}),
).rejects.toThrow();
expect(localCommand).not.toHaveBeenCalled();
});
it("routes list, upsert, and SSH test commands through the configured service", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "list_remote_environments"),
).resolves.toEqual({
profiles: [profile],
activeEnvironmentId: "local",
activeProfileId: null,
statuses: [],
});
const input = {
id: profile.id,
name: "Build box renamed",
host: profile.host,
};
await expect(
handleCommand(ctx, "upsert_remote_environment", { profile: input }),
).resolves.toEqual({ profile });
expect(fake.upsert).toHaveBeenCalledWith(input);
expect(readEvent(send, 0)).toMatchObject({
event: { name: "remote_environment_profiles_changed" },
});
await expect(
handleCommand(ctx, "test_remote_environment", { id: ` ${profile.id} ` }),
).resolves.toEqual({
profile,
status: "passed",
message: "SSH connection succeeded",
remotePlatform: "linux",
remoteArch: "arm64",
});
expect(fake.test).toHaveBeenCalledWith(profile.id);
});
it("connects an authenticated remote runtime, records its binding, and disconnects it cleanly", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const { manager, unsubscribe } = createManager();
coreCreateMock.mockResolvedValue(manager);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "connect_remote_environment", {
id: profile.id,
}),
).resolves.toEqual({
profile,
status: "connected",
environmentId: profile.id,
activeEnvironmentId: profile.id,
activeProfileId: profile.id,
workspaceRoot: "/home/alice",
homeDir: "/home/alice",
remotePlatform: "linux",
remoteArch: "arm64",
});
expect(fake.connect).toHaveBeenCalledWith(profile.id);
expect(coreCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
displayName: "Code App (Build box)",
},
}),
);
expect(hubClientConstructorMock).toHaveBeenCalledWith({
url: connection.endpoint,
authToken: connection.authToken,
clientType: "code-sidecar-ssh-observer",
displayName: "Code App observer (Build box)",
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
});
expect(ctx.activeEnvironmentId).toBe(profile.id);
expect(ctx.runtimeBindings.get(profile.id)).toMatchObject({
environmentId: profile.id,
kind: "ssh",
workspaceRoot: connection.workspaceRoot,
remote: connection,
});
expect(readEvent(send, 0)).toEqual({
type: "event",
event: {
name: "remote_environment_changed",
payload: {
profile,
status: "connected",
environmentId: profile.id,
activeEnvironmentId: profile.id,
activeProfileId: profile.id,
workspaceRoot: "/home/alice",
homeDir: "/home/alice",
remotePlatform: "linux",
remoteArch: "arm64",
},
},
});
await expect(
handleCommand(ctx, "disconnect_remote_environment"),
).resolves.toEqual({
status: "disconnected",
disconnectedProfileId: profile.id,
activeEnvironmentId: "local",
activeProfileId: null,
});
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
expect(unsubscribe).toHaveBeenCalledOnce();
expect(manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_disconnect",
);
expect(hubDisposeMock).toHaveBeenCalledOnce();
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.activeEnvironmentId).toBe("local");
expect(readEvent(send, 1)).toEqual({
type: "event",
event: {
name: "remote_environment_changed",
payload: {
status: "disconnected",
activeProfileId: null,
activeEnvironmentId: "local",
environmentId: "local",
workspaceRoot: "/local/project",
},
},
});
});
it("rolls back the SSH tunnel and partial runtime when observer authentication fails", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const { manager, unsubscribe } = createManager();
coreCreateMock.mockResolvedValue(manager);
hubConnectMock.mockRejectedValue(new Error("remote auth rejected"));
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "connect_remote_environment", { id: profile.id }),
).rejects.toThrow("remote auth rejected");
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
expect(unsubscribe).toHaveBeenCalledOnce();
expect(manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_initialization_failed",
);
expect(hubDisposeMock).toHaveBeenCalledOnce();
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.activeEnvironmentId).toBe("local");
expect(send).not.toHaveBeenCalled();
});
it("preserves the previous environment when switching hosts fails", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService([connection, secondConnection]);
const firstRuntime = createManager();
const failedRuntime = createManager();
coreCreateMock
.mockResolvedValueOnce(firstRuntime.manager)
.mockResolvedValueOnce(failedRuntime.manager);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
await handleCommand(ctx, "connect_remote_environment", { id: profile.id });
const firstBinding = ctx.runtimeBindings.get(profile.id);
send.mockClear();
hubConnectMock.mockRejectedValueOnce(
new Error("second host auth rejected"),
);
await expect(
handleCommand(ctx, "connect_remote_environment", {
id: secondProfile.id,
}),
).rejects.toThrow("second host auth rejected");
expect(ctx.activeEnvironmentId).toBe(profile.id);
expect(ctx.runtimeBindings.get(profile.id)).toBe(firstBinding);
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(false);
expect(fake.service.getActive()?.profileId).toBe(profile.id);
expect(fake.disconnect).toHaveBeenCalledWith(secondProfile.id);
expect(fake.disconnect).not.toHaveBeenCalledWith(profile.id);
expect(firstRuntime.unsubscribe).not.toHaveBeenCalled();
expect(firstRuntime.manager.dispose).not.toHaveBeenCalled();
expect(failedRuntime.unsubscribe).toHaveBeenCalledOnce();
expect(failedRuntime.manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_initialization_failed",
);
expect(send).not.toHaveBeenCalled();
await expect(
handleCommand(ctx, "list_remote_environments"),
).resolves.toMatchObject({
activeEnvironmentId: profile.id,
activeProfileId: profile.id,
});
});
it("retires the previous runtime only after a host switch commits", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService([connection, secondConnection]);
const firstRuntime = createManager();
const secondRuntime = createManager();
coreCreateMock
.mockResolvedValueOnce(firstRuntime.manager)
.mockResolvedValueOnce(secondRuntime.manager);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
await handleCommand(ctx, "connect_remote_environment", { id: profile.id });
await expect(
handleCommand(ctx, "connect_remote_environment", {
id: secondProfile.id,
}),
).resolves.toMatchObject({
environmentId: secondProfile.id,
activeEnvironmentId: secondProfile.id,
activeProfileId: secondProfile.id,
});
expect(ctx.activeEnvironmentId).toBe(secondProfile.id);
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true);
expect(firstRuntime.unsubscribe).toHaveBeenCalledOnce();
expect(firstRuntime.manager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_disconnect",
);
expect(secondRuntime.manager.dispose).not.toHaveBeenCalled();
expect(fake.disconnect).toHaveBeenCalledWith(profile.id);
expect(fake.service.getActive()?.profileId).toBe(secondProfile.id);
});
it("disconnecting an inactive profile does not switch the active environment", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService([connection, secondConnection]);
await fake.service.connect(profile.id);
await fake.service.connect(secondProfile.id);
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
ctx.runtimeBindings.set(
profile.id,
createExistingRemoteBinding(profile.id),
);
ctx.runtimeBindings.set(
secondProfile.id,
createExistingRemoteBinding(secondProfile.id),
);
ctx.activeEnvironmentId = secondProfile.id;
const send = attachEventRecorder(ctx);
await expect(
handleCommand(ctx, "disconnect_remote_environment", { id: profile.id }),
).resolves.toEqual({
status: "disconnected",
disconnectedProfileId: profile.id,
activeEnvironmentId: secondProfile.id,
activeProfileId: secondProfile.id,
});
expect(ctx.activeEnvironmentId).toBe(secondProfile.id);
expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true);
expect(fake.service.getActive()?.profileId).toBe(secondProfile.id);
expect(send).not.toHaveBeenCalled();
});
it("deletes a profile only after removing its runtime binding", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
const send = attachEventRecorder(ctx);
const binding = createExistingRemoteBinding(profile.id);
ctx.runtimeBindings.set(profile.id, binding);
ctx.activeEnvironmentId = profile.id;
await expect(
handleCommand(ctx, "delete_remote_environment", { id: profile.id }),
).resolves.toEqual({
deleted: true,
activeEnvironmentId: "local",
activeProfileId: null,
});
expect(binding.unsubscribeSessionEvents).toHaveBeenCalledOnce();
expect(binding.sessionManager.dispose).toHaveBeenCalledWith(
"code_sidecar_remote_disconnect",
);
expect(binding.hubClient.dispose).toHaveBeenCalledOnce();
expect(fake.delete).toHaveBeenCalledWith(profile.id);
expect(
send.mock.calls.map((call) => JSON.parse(String(call[0])).event.name),
).toContain("remote_environment_profiles_changed");
expect(ctx.runtimeBindings.has(profile.id)).toBe(false);
expect(ctx.activeEnvironmentId).toBe("local");
expect(readEvent(send, 0)).toEqual({
type: "event",
event: {
name: "remote_environment_changed",
payload: {
status: "disconnected",
activeProfileId: null,
activeEnvironmentId: "local",
environmentId: "local",
workspaceRoot: "/local/project",
reason: "profile_deleted",
},
},
});
});
it("routes remote workspace browsing and operations to the explicitly selected directory", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const fake = createFakeService();
const ctx = createSidecarContext("/local/project");
ctx.remoteEnvironments = fake.service;
ctx.runtimeBindings.set(
profile.id,
createExistingRemoteBinding(profile.id),
);
expect(ctx.activeEnvironmentId).toBe("local");
fake.run.mockImplementation(async (_id, input) => {
if (input.command === "pwd") {
return { stdout: "/srv/code\n", stderr: "", exitCode: 0 };
}
if (input.command === "sh" && !input.args[1]?.includes("ls-files")) {
return {
stdout: "/srv/code/zeta\0/srv/code/project\0",
stderr: "",
exitCode: 0,
};
}
if (input.command === "sh" && input.args[1]?.includes("ls-files")) {
return {
stdout: "src/remote.ts\nREADME.md\n",
stderr: "",
exitCode: 0,
};
}
if (input.command === "git" && input.args[0] === "branch") {
return { stdout: "feature/ssh\n", stderr: "", exitCode: 0 };
}
return { stdout: "main\nfeature/ssh\n", stderr: "", exitCode: 0 };
});
await expect(
handleCommand(ctx, "list_workspace_directories", {
environmentId: profile.id,
path: "/srv/code",
}),
).resolves.toEqual({
environmentId: profile.id,
currentPath: "/srv/code",
parentPath: "/srv",
entries: [
{ name: "project", path: "/srv/code/project" },
{ name: "zeta", path: "/srv/code/zeta" },
],
truncated: false,
});
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "pwd",
args: ["-P"],
cwd: "/srv/code",
});
const listInvocation = fake.run.mock.calls.find(
([, input]) => input.command === "sh",
)?.[1];
expect(listInvocation).toMatchObject({
command: "sh",
args: [
"-c",
expect.stringContaining("find -L"),
"cline-list-workspace-directories",
"/srv/code",
],
});
expect(String(listInvocation?.args[1])).not.toContain("/srv/code");
await expect(
handleCommand(ctx, "validate_workspace_directory", {
environmentId: profile.id,
path: "/srv/code/project",
}),
).resolves.toEqual({ environmentId: profile.id, valid: true });
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "test",
args: ["-d", "/srv/code/project"],
});
await expect(
handleCommand(ctx, "search_workspace_files", {
environmentId: profile.id,
workspaceRoot: "/srv/code/project",
query: "remote",
}),
).resolves.toEqual(["src/remote.ts"]);
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "sh",
args: ["-c", expect.stringContaining("head -c 262144")],
cwd: "/srv/code/project",
});
await expect(
handleCommand(ctx, "get_git_branch", {
environmentId: profile.id,
cwd: "/srv/code/project",
}),
).resolves.toEqual({
environmentId: profile.id,
branch: "feature/ssh",
});
expect(fake.run).toHaveBeenCalledWith(profile.id, {
command: "git",
args: ["branch", "--show-current"],
cwd: "/srv/code/project",
});
});
it("bounds remote search output before transfer and drops a truncated filename", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const root = mkdtempSync(join(tmpdir(), "remote-search-limit-"));
try {
for (let i = 0; i < 2800; i++)
writeFileSync(join(root, `${i}-${"x".repeat(100)}.ts`), "");
const ctx = createSidecarContext(root);
const fake = createFakeService();
ctx.remoteEnvironments = fake.service;
ctx.runtimeBindings.set(
profile.id,
createExistingRemoteBinding(profile.id),
);
let transferredBytes = 0;
fake.run.mockImplementation(async (_id, input) => {
const stdout = execFileSync(input.command, input.args, {
cwd: input.cwd,
encoding: "utf8",
});
transferredBytes = Buffer.byteLength(stdout);
return { stdout, stderr: "", exitCode: 0 };
});
const result = (await handleCommand(ctx, "search_workspace_files", {
environmentId: profile.id,
workspaceRoot: root,
limit: 200,
})) as string[];
expect(transferredBytes).toBe(262144);
expect(result.length).toBeGreaterThan(0);
expect(result.length).toBeLessThanOrEqual(200);
expect(result.every((path) => path.endsWith(".ts"))).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("lists and bounds local workspace directories through the local binding", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-workspaces-"));
try {
for (let index = 0; index < 201; index += 1) {
mkdirSync(
join(temporaryRoot, `project-${String(index).padStart(3, "0")}`),
);
}
writeFileSync(join(temporaryRoot, "not-a-directory.txt"), "ignored");
const ctx = createSidecarContext("/local/project");
ctx.runtimeBindings.set("local", {
...createExistingRemoteBinding("local"),
kind: "local",
workspaceRoot: "/local/project",
});
const currentPath = realpathSync(temporaryRoot);
await expect(
handleCommand(ctx, "list_workspace_directories", {
environmentId: "local",
path: temporaryRoot,
}),
).resolves.toEqual({
environmentId: "local",
currentPath,
parentPath: realpathSync(tmpdir()),
entries: expect.arrayContaining([
{
name: "project-000",
path: join(currentPath, "project-000"),
},
]),
truncated: true,
});
const result = (await handleCommand(ctx, "list_workspace_directories", {
environmentId: "local",
path: temporaryRoot,
})) as { entries: unknown[] };
expect(result.entries).toHaveLength(200);
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
});
it("lists duplicate session IDs separately and rejects ambiguous routing", async () => {
const { handleCommand } = await import("./commands");
const {
createSidecarContext,
getEnvironmentContext,
findSessionRuntimeBinding,
emitChunk,
} = await import("./context");
const ctx = createSidecarContext("/local/project");
for (const environmentId of [profile.id, secondProfile.id]) {
const record = {
id: "same-id",
sessionId: "same-id",
status: "idle",
createdAt: "2026-09-14T00:00:00Z",
};
ctx.runtimeBindings.set(environmentId, {
...createExistingRemoteBinding(environmentId),
sessionManager: {
list: vi.fn(async () => [record]),
get: vi.fn(async () => record),
} as unknown as SessionRuntimeBinding["sessionManager"],
});
}
const sessions = (await handleCommand(
ctx,
"list_discovered_sessions",
{},
)) as Array<{ sessionId: string; environmentId: string }>;
expect(
sessions
.filter((session) => session.sessionId === "same-id")
.map((session) => session.environmentId)
.sort(),
).toEqual([profile.id, secondProfile.id]);
await expect(findSessionRuntimeBinding(ctx, "same-id")).rejects.toThrow(
"environmentId is required",
);
await expect(
findSessionRuntimeBinding(ctx, "same-id", secondProfile.id),
).resolves.toMatchObject({ environmentId: secondProfile.id });
const first = getEnvironmentContext(ctx, profile.id);
const second = getEnvironmentContext(ctx, secondProfile.id);
const send = attachEventRecorder(ctx);
emitChunk(first, "same-id", "chat_text", "first host");
emitChunk(second, "same-id", "chat_text", "second host");
expect(readEvent(send, 0).event.payload).toMatchObject({
sessionId: "same-id",
environmentId: profile.id,
index: 1,
chunk: "first host",
});
expect(readEvent(send, 1).event.payload).toMatchObject({
sessionId: "same-id",
environmentId: secondProfile.id,
index: 1,
chunk: "second host",
});
});
it("routes session reads, title updates, and deletes to the requested environment", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/local/project");
const readMessages = vi.fn(async () => [
{ role: "user", content: "remote session message" },
]);
const update = vi.fn(async () => ({ updated: true }));
const deleteSession = vi.fn(async () => true);
const sessionManager = {
readMessages,
update,
delete: deleteSession,
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["sessionManager"];
ctx.runtimeBindings.set(profile.id, {
...createExistingRemoteBinding(profile.id),
sessionManager,
});
expect(ctx.activeEnvironmentId).toBe("local");
await expect(
handleCommand(ctx, "read_session_messages", {
environmentId: profile.id,
sessionId: "remote-session",
}),
).resolves.toHaveLength(1);
expect(readMessages).toHaveBeenCalledWith("remote-session");
ctx.liveSessions.set("same-id", {
environmentId: "local",
config: {},
messages: [{ role: "user", content: "local-only message" }],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
});
readMessages.mockResolvedValueOnce([]);
await expect(
handleCommand(ctx, "read_session_messages", {
environmentId: profile.id,
sessionId: "same-id",
}),
).resolves.toEqual([]);
await expect(
handleCommand(ctx, "update_chat_session_title", {
environmentId: profile.id,
sessionId: "remote-session",
title: "Remote title",
}),
).resolves.toBe(true);
expect(update).toHaveBeenCalledWith("remote-session", {
title: "Remote title",
});
await expect(
handleCommand(ctx, "delete_chat_session", {
environmentId: profile.id,
sessionId: "remote-session",
}),
).resolves.toBe(true);
expect(deleteSession).toHaveBeenCalledWith("remote-session");
expect(sessionStoreDeleteMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { remoteHelperBinaryFilename } from "@cline/core";
import { expect, it } from "vitest";
import { resolveDesktopRemoteHelper } from "./remote-helper";
it("finds SSH helpers in the installed Windows resource layout", () => {
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
try {
const target = { platform: "linux", arch: "x64" } as const;
const directory = join(root, "bin", "remote-helpers");
mkdirSync(directory, { recursive: true });
const helper = join(directory, remoteHelperBinaryFilename(target));
writeFileSync(helper, "helper");
expect(
resolveDesktopRemoteHelper(target, {
execPath: join(root, "code-sidecar.exe"),
cwd: tmpdir(),
env: {},
}),
).toBe(helper);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("finds SSH helpers in the installed Linux resource layout", () => {
const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-"));
try {
const target = { platform: "linux", arch: "arm64" } as const;
const directory = join(
root,
"usr",
"lib",
"Cline Beta",
"bin",
"remote-helpers",
);
mkdirSync(directory, { recursive: true });
const helper = join(directory, remoteHelperBinaryFilename(target));
writeFileSync(helper, "helper");
expect(
resolveDesktopRemoteHelper(target, {
execPath: join(root, "usr", "bin", "code-sidecar"),
cwd: tmpdir(),
env: {},
}),
).toBe(helper);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,46 @@
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import {
type RemoteHelperTarget,
remoteHelperBinaryFilename,
} from "@cline/core";
// Tauri's Linux bundles (deb, rpm, AppImage) install binaries under `usr/bin`
// and resources under `usr/lib/<productName>`. The product name differs per
// release channel ("Cline", "Cline Beta"), so scan the sibling lib directory.
function linuxResourceCandidates(
executableDirectory: string,
relativePath: string,
): string[] {
const libDirectory = join(executableDirectory, "..", "lib");
try {
return readdirSync(libDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(libDirectory, entry.name, relativePath));
} catch {
return [];
}
}
export function resolveDesktopRemoteHelper(
target: RemoteHelperTarget,
options: { execPath?: string; cwd?: string; env?: NodeJS.ProcessEnv } = {},
): string | undefined {
const env = options.env ?? process.env;
if (env.CLINE_REMOTE_HELPER_BINARY) return env.CLINE_REMOTE_HELPER_BINARY;
const filename = remoteHelperBinaryFilename(target);
const executableDirectory = dirname(options.execPath ?? process.execPath);
const cwd = options.cwd ?? process.cwd();
const bundledPath = join("bin", "remote-helpers", filename);
return [
...(env.CLINE_REMOTE_HELPER_DIRECTORY
? [join(env.CLINE_REMOTE_HELPER_DIRECTORY, filename)]
: []),
join(executableDirectory, "remote-helpers", filename),
join(executableDirectory, bundledPath),
join(executableDirectory, "..", "Resources", bundledPath),
...linuxResourceCandidates(executableDirectory, bundledPath),
join(cwd, "src-tauri", bundledPath),
join(cwd, "apps", "examples", "desktop-app", "src-tauri", bundledPath),
].find(existsSync);
}
@@ -0,0 +1,266 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleChatSessionCommand } from "./chat-session";
import { createSidecarContext, getEnvironmentContext } from "./context";
import type { SessionRuntimeBinding } from "./types";
const mocks = vi.hoisted(() => ({
settings: new Map<string, Record<string, unknown>>(),
refresh: vi.fn(),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getProviderSettings(id: string) {
return mocks.settings.get(id);
}
},
RuntimeOAuthTokenManager: class {
resolveProviderApiKey(input: unknown) {
return mocks.refresh(input);
}
},
};
});
beforeEach(() => {
mocks.settings.clear();
mocks.settings.set("cline", {
provider: "cline",
model: "model-a",
auth: { accessToken: "expired", refreshToken: "private-refresh" },
});
mocks.settings.set("openai", {
provider: "openai",
model: "model-b",
apiKey: "openai-key",
baseUrl: "https://openai.example/v1",
});
mocks.refresh
.mockReset()
.mockImplementation(async ({ providerId }: { providerId: string }) => {
if (providerId !== "cline") return null;
return { apiKey: "fresh-token" };
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
function runtime() {
const ctx = createSidecarContext("/local/workspace");
const manager = {
start: vi.fn(async (input: { config?: { sessionId?: string } }) => ({
sessionId: input.config?.sessionId ?? "remote-session",
manifest: {
cwd: "/remote/workspace",
workspace_root: "/remote/workspace",
},
})),
get: vi.fn(async () => ({
sessionId: "shared-id",
provider: "cline",
model: "model-a",
cwd: "/remote/workspace",
metadata: { title: "Remote conversation" },
})),
updateSessionConnection: vi.fn(async () => undefined),
send: vi.fn(async () => ({ text: "done", messages: [] })),
readMessages: vi.fn(async () => [
{ role: "user", content: "remote message" },
]),
readSessionCompactionState: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
};
ctx.runtimeBindings.set("remote", {
environmentId: "remote",
kind: "ssh",
workspaceRoot: "/remote/workspace",
sessionManager: manager,
hubClient: { command: vi.fn(async () => undefined) },
} as unknown as SessionRuntimeBinding);
const config = {
environmentId: "remote",
provider: "cline",
model: "model-a",
cwd: "/remote/workspace",
};
return { ctx, manager, config };
}
describe("SSH session credentials and history", () => {
it("seeds reopened SSH sessions before sending a follow-up", async () => {
const { ctx, manager, config } = runtime();
const history = [
{ role: "user", content: "Remember my project" },
{ role: "assistant", content: "I remember" },
];
manager.readMessages.mockResolvedValueOnce(history);
await handleChatSessionCommand(ctx, {
action: "start",
config: { ...config, sessionId: "shared-id" },
});
expect(manager.start).toHaveBeenCalledWith(
expect.objectContaining({
initialMessages: history,
config: expect.objectContaining({ sessionId: "shared-id" }),
}),
);
expect(
getEnvironmentContext(ctx, "remote").liveSessions.get("shared-id")
?.messages,
).toEqual(history);
manager.send.mockResolvedValueOnce({
text: "done",
messages: [...history, { role: "user", content: "Continue" }],
} as never);
await handleChatSessionCommand(ctx, {
action: "send",
sessionId: "shared-id",
prompt: "Continue",
config,
});
await vi.waitFor(() =>
expect(
getEnvironmentContext(ctx, "remote").liveSessions.get("shared-id")
?.messages,
).toEqual([...history, { role: "user", content: "Continue" }]),
);
expect(manager.start).toHaveBeenCalledTimes(1);
});
it("does not start an empty replacement when remote history cannot be read", async () => {
const { ctx, manager, config } = runtime();
manager.readMessages.mockRejectedValueOnce(
new Error("History unavailable"),
);
await expect(
handleChatSessionCommand(ctx, {
action: "start",
config: { ...config, sessionId: "shared-id" },
}),
).rejects.toThrow("History unavailable");
expect(manager.start).not.toHaveBeenCalled();
});
it("uses refreshed tokens in both configs and refreshes again for the next send", async () => {
const { ctx, manager, config } = runtime();
await handleChatSessionCommand(ctx, { action: "start", config });
expect(manager.start).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
apiKey: "fresh-token",
providerConfig: expect.objectContaining({
apiKey: "fresh-token",
accessToken: "fresh-token",
}),
}),
}),
);
expect(JSON.stringify(manager.start.mock.calls)).not.toContain(
"private-refresh",
);
expect(
getEnvironmentContext(ctx, "remote").liveSessions.get("remote-session")
?.config.apiKey,
).toBeUndefined();
mocks.refresh.mockResolvedValueOnce({ apiKey: "newer-token" });
await handleChatSessionCommand(ctx, {
action: "send",
sessionId: "remote-session",
prompt: "hello",
config,
});
expect(manager.updateSessionConnection).toHaveBeenLastCalledWith(
"remote-session",
expect.objectContaining({
apiKey: "newer-token",
providerConfig: expect.objectContaining({
apiKey: "newer-token",
accessToken: "newer-token",
}),
}),
);
});
it("drops the previous provider's credentials, headers, and endpoint on switch", async () => {
const { ctx, manager, config } = runtime();
await handleChatSessionCommand(ctx, {
action: "start",
config: {
...config,
apiKey: "old-provider-key",
baseUrl: "https://old-provider.example",
headers: { Authorization: "old-secret" },
providerConfig: { providerId: "cline", apiKey: "old-provider-key" },
},
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId: "remote-session",
prompt: "hello",
config: { environmentId: "remote", provider: "openai", model: "model-b" },
});
expect(manager.start).toHaveBeenLastCalledWith(
expect.objectContaining({
config: expect.objectContaining({
providerId: "openai",
apiKey: "openai-key",
baseUrl: "https://openai.example/v1",
}),
}),
);
expect(JSON.stringify(manager.start.mock.calls.at(-1))).not.toContain(
"old-provider",
);
expect(JSON.stringify(manager.start.mock.calls.at(-1))).not.toContain(
"old-secret",
);
});
it("never reads a same-ID local transcript when starting or forking remotely", async () => {
const directory = mkdtempSync(join(tmpdir(), "cline-remote-fork-"));
vi.stubEnv("CLINE_SESSION_DATA_DIR", directory);
try {
mkdirSync(join(directory, "shared-id"));
writeFileSync(
join(directory, "shared-id", "shared-id.messages.json"),
JSON.stringify([{ role: "user", content: "private local message" }]),
);
const { ctx, manager, config } = runtime();
await handleChatSessionCommand(ctx, {
action: "start",
config: { ...config, sessionId: "shared-id" },
});
expect(manager.readMessages).toHaveBeenCalledWith("shared-id");
expect(manager.start).toHaveBeenCalledWith(
expect.objectContaining({
initialMessages: [{ role: "user", content: "remote message" }],
}),
);
expect(JSON.stringify(manager.start.mock.calls)).not.toContain(
"private local message",
);
await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: "shared-id",
config,
});
expect(manager.start).toHaveBeenLastCalledWith(
expect.objectContaining({
initialMessages: [{ role: "user", content: "remote message" }],
}),
);
expect(JSON.stringify(manager.start.mock.calls)).not.toContain(
"private local message",
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});
@@ -60,21 +60,33 @@ describe("restore_checkpoint", () => {
pendingQuestions: new Map(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId,
status: "idle",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
})),
// A restore that reuses the source id is what the hub does today.
restore: vi.fn(async () => ({
sessionId,
messages: restoredMessages,
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
})),
pendingPrompts: { list: vi.fn(async () => []) },
},
activeEnvironmentId: "local",
sessionEnvironmentIds: new Map(),
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local",
workspaceRoot: "/tmp/project",
sessionManager: {
get: vi.fn(async () => ({
sessionId,
status: "idle",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
})),
// A restore that reuses the source id is what the hub does today.
restore: vi.fn(async () => ({
sessionId,
messages: restoredMessages,
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
})),
pendingPrompts: { list: vi.fn(async () => []) },
},
},
],
]),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
@@ -333,17 +333,22 @@ export async function readSessionMessages(
ctx: Pick<SidecarContext, "liveSessions">,
sessionId: string,
maxMessages = 800,
remoteMessages?: unknown[],
): Promise<unknown[]> {
const persisted =
readPersistedChatMessages(sessionId) ??
// A child agent's transcript is not stored under its own session
// directory — it lives beside the root session's artifacts — so opening a
// subagent session has to resolve the path recorded on its row.
readChildSessionMessages(sessionId);
const isRemoteRead = remoteMessages !== undefined;
const persisted = isRemoteRead
? (remoteMessages as MessageWithMetadata[])
: (readPersistedChatMessages(sessionId) ??
// A child agent's transcript is not stored under its own session
// directory — it lives beside the root session's artifacts — so opening a
// subagent session has to resolve the path recorded on its row.
readChildSessionMessages(sessionId));
const messages =
persisted && persisted.length > 0
? persisted
: (ctx.liveSessions.get(sessionId)?.messages ?? []);
: isRemoteRead
? []
: (ctx.liveSessions.get(sessionId)?.messages ?? []);
const max = Math.max(1, maxMessages);
const start = Math.max(0, messages.length - max);
const displayMessages = projectSessionMessagesForDisplay(
@@ -354,7 +359,11 @@ export async function readSessionMessages(
}));
const baseTs = nowMs() - messages.length;
const out: JsonRecord[] = [];
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
// Remote artifacts belong to the SSH host. Never decorate them with a
// same-id local session's live transcript or checkpoint metadata.
const checkpointsByRunCount = isRemoteRead
? new Map<number, StoredCheckpointEntry>()
: readCheckpointEntriesByRunCount(sessionId);
const pendingToolMessages = new Map<string, [number, string, unknown]>();
let userRunCount = 0;
for (let idx = 0; idx < start; idx += 1) {
@@ -2,13 +2,13 @@ import { getFileIndex } from "@cline/core";
import type { SidecarContext } from "../types";
export function searchWorkspaceFiles(
ctx: Pick<SidecarContext, "workspaceRoot">,
ctx: Pick<SidecarContext, "localWorkspaceRoot">,
args?: Record<string, unknown>,
): Promise<string[]> {
const root =
typeof args?.workspaceRoot === "string" && args.workspaceRoot.trim()
? args.workspaceRoot.trim()
: ctx.workspaceRoot;
: ctx.localWorkspaceRoot;
const query =
typeof args?.query === "string" ? args.query.trim().toLowerCase() : "";
const limit =
@@ -1,250 +0,0 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
defaultShellFor,
ensureLoginShellPath,
extractMarkedPath,
loginShellFor,
mergePaths,
resolveLoginShellPath,
shellInvocation,
} from "./shell-path";
const MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const MARKER_END = "__CLINE_SIDECAR_PATH_END__";
let tempDirs: string[] = [];
/**
* Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c <cmd>`,
* so the command to run arrives as $4. The default body mimics a login shell
* whose profile prepends Homebrew before running the command.
*/
function writeFakeShell(
script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"',
name = "fake-shell",
): string {
const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-"));
tempDirs.push(dir);
const shellPath = join(dir, name);
writeFileSync(shellPath, `#!/bin/sh\n${script}\n`);
chmodSync(shellPath, 0o755);
return shellPath;
}
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
});
describe("extractMarkedPath", () => {
it("extracts the PATH between markers", () => {
expect(
extractMarkedPath(
`${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`,
),
).toBe("/opt/homebrew/bin:/usr/bin");
});
it("ignores shell profile noise around the markers", () => {
const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`;
expect(extractMarkedPath(output)).toBe("/usr/local/bin");
});
it("returns undefined when markers are missing or empty", () => {
expect(extractMarkedPath("no markers here")).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined();
});
});
describe("mergePaths", () => {
it("puts shell entries first and keeps current-only entries", () => {
expect(
mergePaths(
"/opt/homebrew/bin:/usr/bin:/bin",
"/usr/bin:/bin:/custom/bin",
),
).toBe("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin");
});
it("drops duplicate and empty entries", () => {
expect(mergePaths("/a::/b:/a", "/b:/c:")).toBe("/a:/b:/c");
});
});
describe("defaultShellFor", () => {
it("uses zsh on macOS and bash elsewhere", () => {
expect(defaultShellFor("darwin")).toBe("/bin/zsh");
expect(defaultShellFor("linux")).toBe("/bin/bash");
});
});
describe("loginShellFor", () => {
it("returns the passwd-database shell when one exists", () => {
// The test runner's uid has a passwd entry, so $SHELL must lose.
const shell = loginShellFor(process.platform, {
SHELL: "/env/should-not-win",
});
expect(shell.startsWith("/")).toBe(true);
expect(shell).not.toBe("/env/should-not-win");
});
});
describe("shellInvocation", () => {
it("uses separate login+interactive flags for posix-style shells", () => {
expect(shellInvocation("/bin/zsh", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
});
it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => {
expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-tcsh",
});
expect(shellInvocation("/bin/csh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-csh",
});
});
});
describe("resolveLoginShellPath", () => {
it("captures PATH from the shell", async () => {
const shell = writeFakeShell();
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("reads PATH from the environment, not the shell's own expansion", async () => {
// Mimics fish: its "$PATH" expansion would space-join the entries,
// but the printf runs inside /bin/sh, which reads the exported
// colon-delimited PATH env var — so the shell's expansion rules
// never apply. This fake shell never evals the command text; it
// only exports PATH and runs the command via sh, like fish would.
const shell = writeFakeShell(
'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"',
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("resolves undefined when the shell prints garbage", async () => {
const shell = writeFakeShell('echo "no markers"');
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
});
it("resolves undefined when the shell is missing", async () => {
await expect(
resolveLoginShellPath("/nonexistent/shell"),
).resolves.toBeUndefined();
});
it("times out hung shells without rejecting", async () => {
const shell = writeFakeShell("sleep 60");
await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined();
});
it("invokes csh-family shells without login/interactive flags", async () => {
// A csh stand-in that rejects any first flag other than -c.
const shell = writeFakeShell(
'[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"',
"tcsh",
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
});
describe("ensureLoginShellPath", () => {
it("merges the login shell PATH into env.PATH", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(result).toEqual({
status: "applied",
pathEntries: 3,
shell,
});
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin");
});
it("falls back to the default shell when $SHELL can't resolve", async () => {
const fallbackShell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell,
});
expect(result.status).toBe("applied");
expect(result).toMatchObject({ shell: fallbackShell });
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
});
it("leaves PATH untouched when every shell fails", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell: "/nonexistent/other-shell",
});
expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" });
expect(env.PATH).toBe("/usr/bin");
});
it("skips on windows", async () => {
const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
const result = await ensureLoginShellPath({ platform: "win32", env });
expect(result).toEqual({ status: "skipped", reason: "windows" });
});
it("skips when the escape hatch is set", async () => {
const env: NodeJS.ProcessEnv = {
PATH: "/usr/bin",
CLINE_SIDECAR_SKIP_SHELL_PATH: "1",
};
const result = await ensureLoginShellPath({ platform: "darwin", env });
expect(result.status).toBe("skipped");
expect(env.PATH).toBe("/usr/bin");
});
it("never exposes the resolved PATH in its result", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin");
});
it("resolves against a real shell end to end", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/bin" };
const result = await ensureLoginShellPath({
platform: "linux",
env,
userShell: "/bin/sh",
});
expect(result.status).toBe("applied");
expect(env.PATH).toContain("/bin");
});
});
@@ -1,239 +0,0 @@
/**
* Login-shell PATH resolution for the desktop sidecar.
*
* When the Tauri app is launched from Finder/the Dock on macOS, it inherits
* launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the
* user's shell PATH. The sidecar and every process it spawns for the agent
* (bash tool, MCP servers) then can't find tools like `gh` that live in
* /opt/homebrew/bin or other shell-profile-added directories, even though
* the same task works from the CLI in a terminal.
*
* At startup we ask the user's login shell for its PATH and merge it into
* process.env.PATH, so child processes see the same PATH a terminal would.
*/
import { spawn } from "node:child_process";
import { userInfo } from "node:os";
import { basename, delimiter } from "node:path";
const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__";
/**
* Kept well under the Tauri shell's 5s endpoint-readiness poll: this
* resolution overlaps sidecar startup but is awaited before the server
* starts, so a pathological shell profile must not eat the whole window.
*/
const SHELL_TIMEOUT_MS = 2_000;
/**
* The command every shell is asked to run. $PATH expansion happens inside
* POSIX sh not the user's shell so shells with different expansion rules
* (fish would space-join "$PATH") still produce a colon-delimited value; sh
* reads the PATH environment variable the login shell exported.
*/
const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`;
/**
* Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched
* (e.g. if a broken shell profile makes resolution misbehave).
*/
const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH";
export function defaultShellFor(platform: NodeJS.Platform): string {
return platform === "darwin" ? "/bin/zsh" : "/bin/bash";
}
/**
* The user's configured login shell. The account database is authoritative:
* a GUI-launched process has no parent shell, so $SHELL may be unset there.
* userInfo() reads getpwuid(), which on macOS goes through DirectoryServices
* the same source `dscl . -read /Users/$USER UserShell` reports and on
* Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform
* default are fallbacks for environments with no passwd entry.
*/
export function loginShellFor(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): string {
try {
const shell = userInfo().shell?.trim();
if (shell) {
return shell;
}
} catch {
// No passwd entry for the current uid (some containers) — fall through.
}
return env.SHELL?.trim() || defaultShellFor(platform);
}
export interface ShellInvocation {
args: string[];
/**
* argv[0] the shell should see. A leading dash is the historical "you
* are a login shell" signal, used where -l can't be passed as a flag.
*/
argv0?: string;
}
/**
* How to invoke a shell so it sources its profiles and runs a command.
* csh/tcsh accept -l only as the sole flag, so they're marked login via the
* argv[0] dash convention instead (sources ~/.login on top of the always-read
* ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile
* Homebrew's shellenv) plus interactive (-i, ~/.zshrc nvm-style version
* managers) as separate flags.
*/
export function shellInvocation(
shell: string,
command: string,
): ShellInvocation {
const kind = basename(shell);
if (kind === "csh" || kind === "tcsh") {
return { args: ["-c", command], argv0: `-${kind}` };
}
return { args: ["-i", "-l", "-c", command] };
}
/**
* Extract the PATH value printed between the sentinel markers, ignoring any
* noise a shell profile writes to stdout around it.
*/
export function extractMarkedPath(output: string): string | undefined {
const start = output.indexOf(PATH_MARKER_START);
if (start === -1) {
return undefined;
}
const end = output.indexOf(PATH_MARKER_END, start);
if (end === -1) {
return undefined;
}
const value = output.slice(start + PATH_MARKER_START.length, end).trim();
return value.length > 0 ? value : undefined;
}
/**
* Merge the login shell's PATH with the current one: shell entries first (so
* profile-managed dirs like /opt/homebrew/bin win), then any current entries
* the shell PATH doesn't already contain (so explicitly-injected dirs from
* the launching environment aren't lost). Duplicates are dropped.
*/
export function mergePaths(shellPath: string, currentPath: string): string {
const entries = [
...shellPath.split(delimiter),
...currentPath.split(delimiter),
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return Array.from(new Set(entries)).join(delimiter);
}
/**
* Run the user's shell with its profiles sourced and capture its PATH.
* Resolves to undefined on any failure (missing shell, timeout, profile
* error) callers should treat that as "keep the current PATH".
*/
export function resolveLoginShellPath(
shell: string,
timeoutMs = SHELL_TIMEOUT_MS,
): Promise<string | undefined> {
return new Promise((resolve) => {
const invocation = shellInvocation(shell, PRINT_PATH_COMMAND);
const child = spawn(shell, invocation.args, {
argv0: invocation.argv0,
stdio: ["ignore", "pipe", "ignore"],
detached: true,
});
let output = "";
let settled = false;
const settle = (value: string | undefined) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(value);
};
const timeout = setTimeout(() => {
try {
if (child.pid) {
process.kill(-child.pid, "SIGKILL");
}
} catch {
child.kill("SIGKILL");
}
settle(undefined);
}, timeoutMs);
child.stdout?.on("data", (data: Buffer) => {
output += data.toString("utf8");
});
child.on("error", () => settle(undefined));
child.on("close", () => settle(extractMarkedPath(output)));
});
}
/**
* Resolve the login shell's PATH and merge it into process.env.PATH. The
* shell comes from the account database (see loginShellFor); if it can't
* produce a PATH (exotic shell, broken profile), retry once with the
* platform default shell before giving up.
*
* No-op on Windows (the GUI PATH comes from the registry there) and when
* CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the
* returned status but never block startup. The result never contains the
* resolved PATH itself so it is safe to log verbatim.
*/
export async function ensureLoginShellPath(options?: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
/** Test seam: overrides passwd/$SHELL discovery of the user's shell. */
userShell?: string;
/** Test seam: overrides the platform-default fallback shell. */
fallbackShell?: string;
}): Promise<
| { status: "applied"; pathEntries: number; shell: string }
| { status: "skipped"; reason: string }
| { status: "failed"; shell: string }
> {
const platform = options?.platform ?? process.platform;
const env = options?.env ?? process.env;
if (platform === "win32") {
return { status: "skipped", reason: "windows" };
}
if (env[SKIP_ENV_VAR]?.trim()) {
return { status: "skipped", reason: SKIP_ENV_VAR };
}
const userShell = options?.userShell ?? loginShellFor(platform, env);
const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform);
const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS;
// The fallback gets half the budget so the combined worst case stays
// bounded even when both shells hang (see SHELL_TIMEOUT_MS).
const attempts: Array<[shell: string, timeoutMs: number]> =
userShell === fallbackShell
? [[userShell, baseTimeoutMs]]
: [
[userShell, baseTimeoutMs],
[fallbackShell, baseTimeoutMs / 2],
];
for (const [shell, timeoutMs] of attempts) {
const shellPath = await resolveLoginShellPath(shell, timeoutMs);
if (!shellPath) {
continue;
}
const merged = mergePaths(shellPath, env.PATH ?? "");
env.PATH = merged;
return {
status: "applied",
pathEntries: merged.split(delimiter).length,
shell,
};
}
return { status: "failed", shell: userShell };
}
+29 -6
View File
@@ -5,11 +5,15 @@ import type {
ITelemetryService,
ManagedHubBuildMismatchEvent,
NodeHubClient,
RemoteEnvironmentConnection,
RemoteEnvironmentService,
ToolApprovalResult,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type { UserContext } from "@cline/shared";
export const LOCAL_ENVIRONMENT_ID = "local";
export type JsonRecord = Record<string, unknown>;
export type ChatTurnAttachments = {
@@ -50,6 +54,7 @@ export type PromptInQueue = {
};
export type LiveSession = {
environmentId?: string;
config: JsonRecord;
messages: MessageWithMetadata[];
promptsInQueue: PromptInQueue[];
@@ -61,6 +66,8 @@ export type LiveSession = {
prompt?: string;
title?: string;
attachedViaHub?: boolean;
/** Last Hub lifecycle sequence applied to this session. */
lastHubStatusSequence?: number;
/** Iterations already in flight when the user supplied recovery guidance. */
mistakeRecovery?: {
latestIteration: number;
@@ -74,6 +81,16 @@ export type LiveSession = {
consumedAttachmentFiles?: Map<string, string[]>;
};
export type SessionRuntimeBinding = {
environmentId: string;
kind: "local" | "ssh";
workspaceRoot: string;
sessionManager: ClineCore;
hubClient: NodeHubClient;
unsubscribeSessionEvents: () => void;
remote?: RemoteEnvironmentConnection;
};
export type ToolApprovalRequestItem = {
requestId: string;
sessionId: string;
@@ -88,8 +105,9 @@ export type ToolApprovalRequestItem = {
export type PendingToolApproval = {
item: ToolApprovalRequestItem;
owner: SidecarWebSocketClient;
resolve: (result: ToolApprovalResult) => void;
/** Cloud approvals have no local WebSocket owner and may resolve remotely. */
owner?: SidecarWebSocketClient;
resolve: (result: ToolApprovalResult) => void | Promise<void>;
};
export type AskQuestionRequestItem = {
@@ -130,14 +148,19 @@ export type SidecarContext = {
wsClients: Set<SidecarWebSocketClient>;
pendingApprovals: Map<string, PendingToolApproval>;
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
workspaceRoot: string;
runtimeBindings: Map<string, SessionRuntimeBinding>;
sessionEnvironmentIds: Map<string, string>;
activeEnvironmentId: string;
remoteEnvironments: RemoteEnvironmentService | null;
localWorkspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
/** Analytics identity and explicit account state forwarded with each session. */
telemetryUser?: UserContext;
unsubscribeSessionEvents: (() => void) | null;
cloudSessionManager: {
dispose(): Promise<void>;
isCloudSession(sessionId: string): boolean;
} | null;
/**
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
* replayed to webviews that connect after the event fired.
@@ -205,7 +205,22 @@ fn set_update_status(
refresh_tray_status(app, update_state);
}
// An empty endpoint list disables both background and on-demand updates.
// Nightly builds use this configuration because they are Actions artifacts only.
fn updates_enabled(app: &tauri::AppHandle) -> bool {
app.config()
.plugins
.0
.get("updater")
.and_then(|config| config.get("endpoints"))
.and_then(serde_json::Value::as_array)
.is_some_and(|endpoints| !endpoints.is_empty())
}
async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
if !updates_enabled(app) {
return;
}
let _cycle = state.cycle.lock().await;
// An update that already finished downloading only needs a restart; keep
// reporting "ready" instead of flipping back to transient states unless a
@@ -1257,7 +1272,7 @@ fn main() {
});
// Dev builds are not installed app bundles, so there is nothing the
// updater could meaningfully check or replace.
if !cfg!(debug_assertions) {
if !cfg!(debug_assertions) && updates_enabled(app.handle()) {
let update_state = app.state::<Arc<UpdateState>>().inner().clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.30",
"version": "0.0.32",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -38,7 +38,11 @@
"active": true,
"targets": "all",
"externalBin": ["bin/code-sidecar"],
"resources": ["icons/app/*.png", "icons/app/macos/*.png"],
"resources": [
"icons/app/*.png",
"icons/app/macos/*.png",
"bin/remote-helpers/*"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Nightly",
"identifier": "bot.cline.app.nightly",
"plugins": {
"updater": {
"endpoints": []
}
}
}
@@ -54,10 +54,11 @@
}
/* Selectable content surfaces: chat message bodies (incl. markdown and
* code blocks), reasoning/tool panels, and diff text. */
* code blocks), reasoning/tool panels, settings, and diff text. */
.cline-chat-message-content,
.cline-markdown,
.cline-chat-selectable,
.cline-settings-content,
pre,
code {
-webkit-user-select: text;
+464 -55
View File
@@ -34,6 +34,8 @@ import {
} from "@/components/ui/sidebar";
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import { EnvironmentSelector } from "@/components/views/chat/environment-selector";
import { RemoteDirectoryPicker } from "@/components/views/chat/remote-directory-picker";
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
@@ -45,6 +47,7 @@ import {
} from "@/components/window-title-bar";
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { ProcessContext } from "@/hooks/chat-session/types";
import { useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionAgents } from "@/hooks/use-session-agents";
@@ -84,6 +87,11 @@ import {
subscribeToProviderCatalogInvalidation,
writeProviderCatalogSnapshot,
} from "@/lib/provider-model-catalog";
import type {
RemoteEnvironmentConnectResult,
RemoteEnvironmentListResult,
RemoteEnvironmentProfile,
} from "@/lib/remote-environments";
import {
buildSessionAgentActivity,
mergeAgentActivity,
@@ -93,10 +101,16 @@ import {
type SessionHistoryItem,
type SessionMetadata,
} from "@/lib/session-history";
import { eventEnvironmentId, sessionKey } from "@/lib/session-identity";
import { readImportedFromTool } from "@/lib/session-import";
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
type RemoteWorkspaceEnvironment,
remoteWorkspaceEnvironmentFromContext,
} from "@/lib/workspace-environment";
import {
filterWorkspacePaths,
LOCAL_WORKSPACE_ENVIRONMENT_ID,
mergeWorkspacePaths,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
@@ -172,7 +186,12 @@ export default function Home() {
const [appState, dispatchApp] = useReducer(
desktopAppReducer<SettingsSection>,
initialThreadId,
(threadId) => createDesktopAppState(threadId, "General"),
(threadId) =>
createDesktopAppState(
threadId,
"General",
LOCAL_WORKSPACE_ENVIRONMENT_ID,
),
);
// Starts false on both server and first client render (hydration-safe);
// the effect below reads the persisted state right after mount.
@@ -185,8 +204,26 @@ export default function Home() {
// provider setup step.
const [onboardingInitialStep, setOnboardingInitialStep] =
useState<OnboardingStep>("welcome");
const environmentSelectionRevision = useRef(0);
const [activeRemoteEnvironment, setActiveRemoteEnvironment] =
useState<RemoteWorkspaceEnvironment | null>(null);
const [remoteEnvironmentProfiles, setRemoteEnvironmentProfiles] = useState<
RemoteEnvironmentProfile[]
>([]);
const [
remoteEnvironmentProfilesLoading,
setRemoteEnvironmentProfilesLoading,
] = useState(true);
const [remoteDirectoryPicker, setRemoteDirectoryPicker] =
useState<RemoteWorkspaceEnvironment | null>(null);
const remoteDirectoryPickerResolverRef = useRef<
((path: string | null) => void) | null
>(null);
const selectLocalDraftWhenChatVisibleRef = useRef(false);
const { navigation, threads } = appState;
const { activeThreadId, settingsSection, view } = navigation.current;
const activeEnvironmentId =
activeRemoteEnvironment?.id ?? LOCAL_WORKSPACE_ENVIRONMENT_ID;
const navigate = useCallback((destination: AppLocation) => {
dispatchApp({ type: "navigate", destination });
@@ -231,13 +268,223 @@ export default function Home() {
void syncDesktopWindowTitle();
}, []);
useEffect(() => {
let cancelled = false;
const revision = environmentSelectionRevision.current;
desktopClient
.invoke<ProcessContext>("get_process_context")
.then((context) => {
if (!cancelled && revision === environmentSelectionRevision.current) {
const remoteEnvironment =
remoteWorkspaceEnvironmentFromContext(context);
setActiveRemoteEnvironment(remoteEnvironment);
if (remoteEnvironment) {
dispatchApp({
type: "select-environment-draft",
threadId: makeThreadId(),
environmentId: remoteEnvironment.id,
});
}
}
})
.catch(() => {
// The chat bootstrap reports backend availability separately.
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (view !== "chat") return;
let revision = 0;
const refresh = () => {
const requestRevision = ++revision;
setRemoteEnvironmentProfilesLoading(true);
void desktopClient
.invoke<RemoteEnvironmentListResult>("list_remote_environments")
.then((result) => {
if (requestRevision === revision)
setRemoteEnvironmentProfiles(result.profiles);
})
.catch(() => {
// The Settings > Remote surface owns profile-management errors.
})
.finally(() => {
if (requestRevision === revision)
setRemoteEnvironmentProfilesLoading(false);
});
};
const unsubscribe = desktopClient.subscribe(
"remote_environment_profiles_changed",
refresh,
);
refresh();
return () => {
++revision;
unsubscribe();
};
}, [view]);
useEffect(
() => () => {
remoteDirectoryPickerResolverRef.current?.(null);
remoteDirectoryPickerResolverRef.current = null;
},
[],
);
useEffect(() => watchDesktopTrayStatus(), []);
useEffect(() => watchDesktopNotifications(), []);
const handleNewThread = useCallback(() => {
dispatchApp({ type: "new-thread", threadId: makeThreadId() });
const createThreadForEnvironment = useCallback((environmentId: string) => {
dispatchApp({
type: "new-thread",
threadId: makeThreadId(),
environmentId,
});
requestPromptInputFocus();
}, []);
const handleNewThread = useCallback(() => {
createThreadForEnvironment(activeEnvironmentId);
}, [activeEnvironmentId, createThreadForEnvironment]);
const selectEnvironmentDraft = useCallback((environmentId: string) => {
dispatchApp({
type: "select-environment-draft",
environmentId,
threadId: makeThreadId(),
});
}, []);
const handleSelectEnvironment = useCallback(
async (environmentId: string) => {
environmentSelectionRevision.current += 1;
if (environmentId === activeRemoteEnvironment?.id) {
// Already connected (e.g. after navigating Back to a local draft);
// reconnecting would tear down and rebuild the remote runtime.
selectEnvironmentDraft(environmentId);
return;
}
try {
if (environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID) {
if (activeRemoteEnvironment) {
await desktopClient.invoke(
"disconnect_remote_environment",
{ id: activeRemoteEnvironment.id },
{ timeoutMs: null },
);
}
setActiveRemoteEnvironment(null);
selectEnvironmentDraft(LOCAL_WORKSPACE_ENVIRONMENT_ID);
return;
}
const result =
await desktopClient.invoke<RemoteEnvironmentConnectResult>(
"connect_remote_environment",
{ id: environmentId },
{ timeoutMs: null },
);
const connectedEnvironmentId = result.environmentId.trim();
const homeDir = result.homeDir.trim() || result.workspaceRoot.trim();
if (
connectedEnvironmentId !== environmentId ||
result.activeEnvironmentId !== connectedEnvironmentId ||
result.activeProfileId !== connectedEnvironmentId ||
!homeDir
) {
throw new Error(
"The SSH host connected without a valid environment identity or home directory.",
);
}
const storedWorkspace = readWorkspaceSelectionFromWindow(
connectedEnvironmentId,
);
if (!storedWorkspace.lastWorkspace) {
writeWorkspaceSelectionToWindow(connectedEnvironmentId, {
...storedWorkspace,
lastWorkspace: homeDir,
});
}
setActiveRemoteEnvironment({
id: connectedEnvironmentId,
homeDir,
});
selectEnvironmentDraft(connectedEnvironmentId);
} catch (error) {
toast({
title:
environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID
? "Unable to switch to Local"
: "Unable to connect to SSH host",
description: error instanceof Error ? error.message : String(error),
variant: "destructive",
});
throw error;
}
},
[activeRemoteEnvironment, selectEnvironmentDraft],
);
const pickRemoteWorkspaceDirectory = useCallback(
(environment: RemoteWorkspaceEnvironment): Promise<string | null> => {
remoteDirectoryPickerResolverRef.current?.(null);
return new Promise((resolve) => {
remoteDirectoryPickerResolverRef.current = resolve;
setRemoteDirectoryPicker(environment);
});
},
[],
);
const completeRemoteDirectoryPicker = useCallback((path: string | null) => {
const resolve = remoteDirectoryPickerResolverRef.current;
remoteDirectoryPickerResolverRef.current = null;
setRemoteDirectoryPicker(null);
resolve?.(path);
}, []);
useEffect(
() =>
desktopClient.subscribe("remote_environment_changed", (payload) => {
if (!payload || typeof payload !== "object") return;
const event = payload as {
status?: unknown;
environmentId?: unknown;
homeDir?: unknown;
workspaceRoot?: unknown;
};
if (
event.status === "connected" &&
typeof event.environmentId === "string" &&
typeof event.homeDir === "string"
) {
environmentSelectionRevision.current += 1;
selectLocalDraftWhenChatVisibleRef.current = false;
setActiveRemoteEnvironment({
id: event.environmentId,
homeDir: event.homeDir,
});
}
if (event.status === "disconnected") {
environmentSelectionRevision.current += 1;
completeRemoteDirectoryPicker(null);
setActiveRemoteEnvironment(null);
if (view === "chat") {
selectEnvironmentDraft(LOCAL_WORKSPACE_ENVIRONMENT_ID);
} else {
selectLocalDraftWhenChatVisibleRef.current = true;
}
}
}),
[completeRemoteDirectoryPicker, selectEnvironmentDraft, view],
);
useEffect(() => {
if (view !== "chat" || !selectLocalDraftWhenChatVisibleRef.current) {
return;
}
selectLocalDraftWhenChatVisibleRef.current = false;
selectEnvironmentDraft(LOCAL_WORKSPACE_ENVIRONMENT_ID);
}, [selectEnvironmentDraft, view]);
const completeOnboarding = useCallback(() => {
markOnboardingCompleted();
@@ -255,26 +502,46 @@ export default function Home() {
const handleOpenSession = useCallback(
(session: SessionHistoryItem, initialPromptDraft?: string) => {
dispatchApp({ type: "open-session", session, initialPromptDraft });
},
[],
);
const handleDeleteSession = useCallback(
(deletedSessionId: string, deletedThreadId?: string) => {
dispatchApp({
type: "delete-session",
deletedSessionId,
deletedThreadId,
fallbackThreadId: makeThreadId(),
type: "open-session",
session,
environmentId: session.environmentId,
initialPromptDraft,
});
},
[],
);
const handleDeleteSession = useCallback(
(
deletedSessionId: string,
deletedThreadId?: string,
environmentId = LOCAL_WORKSPACE_ENVIRONMENT_ID,
) => {
dispatchApp({
type: "delete-session",
environmentId,
deletedSessionId,
deletedThreadId,
fallbackThreadId: makeThreadId(),
fallbackEnvironmentId: activeEnvironmentId,
});
},
[activeEnvironmentId],
);
const handleUpdateSessionMetadata = useCallback(
(sessionId: string, metadata: SessionMetadata) => {
dispatchApp({ type: "update-session-metadata", sessionId, metadata });
(
sessionId: string,
metadata: SessionMetadata,
environmentId = LOCAL_WORKSPACE_ENVIRONMENT_ID,
) => {
dispatchApp({
type: "update-session-metadata",
sessionId,
metadata,
environmentId,
});
},
[],
);
@@ -291,13 +558,16 @@ export default function Home() {
if (!sessionId) {
return;
}
handleDeleteSession(sessionId);
handleDeleteSession(sessionId, undefined, eventEnvironmentId(payload));
});
}, [handleDeleteSession]);
const activeHistorySessionId =
threads.find((thread) => thread.id === activeThreadId)?.historySession
?.sessionId ?? null;
const activeHistorySession = threads.find(
(thread) => thread.id === activeThreadId,
)?.historySession;
const activeHistorySessionId = activeHistorySession
? sessionKey(activeHistorySession)
: null;
const activeThread =
threads.find((thread) => thread.id === activeThreadId) ?? threads[0];
const handleHome = useCallback(() => {
@@ -359,7 +629,8 @@ export default function Home() {
}, []);
const sessionHistory = useSessionHistory({
activeSessionId: activeHistorySessionId,
onDeleteSession: handleDeleteSession,
onDeleteSession: (sessionId, environmentId) =>
handleDeleteSession(sessionId, undefined, environmentId),
onOpenSession: handleOpenSession,
onUpdateSessionMetadata: handleUpdateSessionMetadata,
});
@@ -368,9 +639,12 @@ export default function Home() {
sessionHistoryRef.current = sessionHistory.sessions;
}, [sessionHistory.sessions]);
const handleOpenSessionById = useCallback(
async (sessionId: string) => {
async (sessionId: string, environmentId?: string) => {
const cachedSession = sessionHistoryRef.current.find(
(session) => session.sessionId === sessionId,
(session) =>
session.sessionId === sessionId &&
session.environmentId ===
(environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID),
);
if (cachedSession) {
handleOpenSession(cachedSession);
@@ -379,11 +653,22 @@ export default function Home() {
try {
const session = await desktopClient.invoke<SessionHistoryItem | null>(
"get_discovered_session",
{ sessionId },
{
environmentId: environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID,
sessionId: sessionId,
},
);
if (!session) {
throw new Error("The session for this run is no longer available.");
}
if (
environmentId !== undefined &&
session.environmentId !== environmentId
) {
throw new Error(
`The session belongs to environment ${session.environmentId}, not ${environmentId}.`,
);
}
handleOpenSession(session);
} catch (error) {
toast({
@@ -418,8 +703,12 @@ export default function Home() {
[handleNewThread, handleOpenSessionById, handleViewChange],
);
const historyWorkspacePaths = useMemo(
() => workspacePathsFromSessions(sessionHistory.sessions),
[sessionHistory.sessions],
() =>
workspacePathsFromSessions(
sessionHistory.sessions,
activeThread?.environmentId ?? activeEnvironmentId,
),
[activeEnvironmentId, activeThread?.environmentId, sessionHistory.sessions],
);
// A child agent session names its parent, but only the history list knows the
// parent's title — resolve it here so the chat header can point back to it.
@@ -430,10 +719,19 @@ export default function Home() {
return undefined;
}
const title = sessionHistory.threads.find(
(thread) => thread.id === parentSessionId,
(thread) =>
thread.id ===
sessionKey({
sessionId: parentSessionId,
environmentId: activeThread.environmentId,
}),
)?.title;
return { sessionId: parentSessionId, title };
}, [activeThread?.historySession?.parentSessionId, sessionHistory.threads]);
}, [
activeThread?.historySession?.parentSessionId,
activeThread?.environmentId,
sessionHistory.threads,
]);
return (
<AccountProvider>
@@ -490,16 +788,44 @@ export default function Home() {
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
key={`${activeThread.id}:${activeThread.environmentId}`}
environmentId={activeThread.environmentId}
environmentProfiles={remoteEnvironmentProfiles}
environmentProfilesLoading={
remoteEnvironmentProfilesLoading
}
onAddSshHost={() => handleSettingsSectionChange("Remote")}
onPickRemoteWorkspaceDirectory={
pickRemoteWorkspaceDirectory
}
onSelectEnvironment={handleSelectEnvironment}
remoteEnvironment={
activeRemoteEnvironment?.id ===
activeThread.environmentId
? activeRemoteEnvironment
: null
}
historySession={activeThread.historySession}
initialPromptDraft={activeThread.initialPromptDraft}
knownWorkspacePaths={historyWorkspacePaths}
onInitialPromptDraftConsumed={
handleInitialPromptDraftConsumed
}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
onUpdateSessionMetadata={(sessionId, metadata) =>
handleUpdateSessionMetadata(
sessionId,
metadata,
activeThread.environmentId,
)
}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onDeleteSession={(sessionId, threadId) =>
handleDeleteSession(
sessionId,
threadId,
activeThread.environmentId,
)
}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onOpenSessionById={handleOpenSessionById}
@@ -549,6 +875,15 @@ export default function Home() {
onOpenSession={handleOpenSessionById}
open={commandBarOpen && !showOnboarding}
/>
{remoteDirectoryPicker ? (
<RemoteDirectoryPicker
environmentId={remoteDirectoryPicker.id}
homeDir={remoteDirectoryPicker.homeDir}
onCancel={() => completeRemoteDirectoryPicker(null)}
onSelect={completeRemoteDirectoryPicker}
open
/>
) : null}
</AccountProvider>
);
}
@@ -562,11 +897,15 @@ let workspacesLoadedOnce = false;
function ChatThreadPane({
threadId,
environmentId,
environmentProfiles,
environmentProfilesLoading,
historySession,
initialPromptDraft,
knownWorkspacePaths,
onInitialPromptDraftConsumed,
onUpdateSessionMetadata,
onAddSshHost,
onDeleteSession,
onNewThread,
onOpenSession,
@@ -574,10 +913,16 @@ function ChatThreadPane({
onOpenSetup,
onOpenModelSettings,
onOpenAccountSettings,
onPickRemoteWorkspaceDirectory,
onSelectEnvironment,
parentSession,
remoteEnvironment,
onThreadStarted,
}: {
threadId: string;
environmentId: string;
environmentProfiles: RemoteEnvironmentProfile[];
environmentProfilesLoading: boolean;
historySession?: SessionHistoryItem;
initialPromptDraft?: string;
knownWorkspacePaths: string[];
@@ -586,17 +931,26 @@ function ChatThreadPane({
sessionId: string,
metadata: SessionMetadata,
) => void;
onAddSshHost: () => void;
onDeleteSession?: (sessionId: string, threadId?: string) => void;
onNewThread?: () => void;
onOpenSession?: (
session: SessionHistoryItem,
initialPromptDraft?: string,
) => void;
onOpenSessionById?: (sessionId: string) => void | Promise<void>;
onOpenSessionById?: (
sessionId: string,
environmentId?: string,
) => void | Promise<void>;
onPickRemoteWorkspaceDirectory: (
environment: RemoteWorkspaceEnvironment,
) => Promise<string | null>;
onSelectEnvironment: (environmentId: string) => Promise<void>;
onOpenSetup?: () => void;
onOpenModelSettings?: () => void;
onOpenAccountSettings?: () => void;
parentSession?: { sessionId: string; title?: string };
remoteEnvironment: RemoteWorkspaceEnvironment | null;
onThreadStarted?: (threadId: string) => void;
}) {
const {
@@ -630,7 +984,7 @@ function ChatThreadPane({
reset,
abort,
hydrateSession,
} = useChatSession();
} = useChatSession(environmentId);
// The live composer text lives inside ChatInputBar so typing does not
// re-render this whole pane. The pane mirrors it in a ref (for reads) and
// pushes external updates (quick actions, undo, resets) via promptDraft.
@@ -675,7 +1029,7 @@ function ChatThreadPane({
filterWorkspacePaths(
mergeWorkspacePaths(
knownWorkspacePaths,
readWorkspaceSelectionFromWindow().workspaces,
readWorkspaceSelectionFromWindow(environmentId).workspaces,
),
),
);
@@ -699,23 +1053,35 @@ function ChatThreadPane({
useEffect(() => {
setWorkspaces((current) => {
const stored = readWorkspaceSelectionFromWindow(environmentId);
const merged = filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, current),
mergeWorkspacePaths(knownWorkspacePaths, stored.workspaces),
);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
: merged;
});
}, [knownWorkspacePaths]);
}, [environmentId, knownWorkspacePaths]);
useEffect(() => {
if (
(config.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID) !== environmentId
) {
return;
}
const lastWorkspace = (config.workspaceRoot || config.cwd || "").trim();
writeWorkspaceSelectionToWindow({
writeWorkspaceSelectionToWindow(environmentId, {
lastWorkspace,
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
});
}, [config.cwd, config.workspaceRoot, workspaces]);
}, [
config.cwd,
config.environmentId,
config.workspaceRoot,
environmentId,
workspaces,
]);
const providerCredentialsRequestRef = useRef(0);
const loadProviderCredentials = useCallback(async () => {
@@ -814,10 +1180,13 @@ function ChatThreadPane({
return;
}
try {
const payload = await desktopClient.invoke<{ branch?: string }>(
"get_git_branch",
{ cwd },
);
const payload = await desktopClient.invoke<{
environmentId: string;
branch?: string;
}>("get_git_branch", { cwd, environmentId });
if (payload.environmentId !== environmentId) {
return;
}
if (!gitBranchRequestGateRef.current.commit(requestId)) {
return;
}
@@ -826,7 +1195,7 @@ function ChatThreadPane({
} catch {
// Preserve the latest successful branch through transient failures.
}
}, [getWorkspaceCwd]);
}, [environmentId, getWorkspaceCwd]);
const invalidateGitBranch = useCallback(() => {
gitBranchRequestGateRef.current.invalidate();
@@ -845,9 +1214,13 @@ function ChatThreadPane({
}
try {
const payload = await desktopClient.invoke<{
environmentId: string;
current?: string;
branches?: string[];
}>("list_git_branches", { cwd });
}>("list_git_branches", { cwd, environmentId });
if (payload.environmentId !== environmentId) {
return { current: "no-git", branches: [] };
}
const current = payload?.current?.trim() || "no-git";
const branches = Array.isArray(payload?.branches)
? payload.branches.filter((item) => item.trim().length > 0)
@@ -856,7 +1229,7 @@ function ChatThreadPane({
} catch {
return { current: "no-git", branches: [] };
}
}, [getWorkspaceCwd]);
}, [environmentId, getWorkspaceCwd]);
const switchGitBranch = useCallback(
async (nextBranch: string): Promise<boolean> => {
@@ -865,10 +1238,17 @@ function ChatThreadPane({
return false;
}
try {
await desktopClient.invoke<{ branch?: string }>("checkout_git_branch", {
const payload = await desktopClient.invoke<{
environmentId: string;
branch?: string;
}>("checkout_git_branch", {
cwd,
branch: nextBranch,
environmentId,
});
if (payload.environmentId !== environmentId) {
return false;
}
invalidateGitBranch();
await refreshGitBranch();
return true;
@@ -876,7 +1256,7 @@ function ChatThreadPane({
return false;
}
},
[getWorkspaceCwd, invalidateGitBranch, refreshGitBranch],
[environmentId, getWorkspaceCwd, invalidateGitBranch, refreshGitBranch],
);
const listWorkspaces = useCallback(
@@ -891,10 +1271,14 @@ function ChatThreadPane({
// process cwd fallback); it renders via its own registration in the
// selector and welcome screen instead of joining the catalog.
return filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]),
mergeWorkspacePaths(
knownWorkspacePaths,
readWorkspaceSelectionFromWindow(environmentId).workspaces,
[preferred, current],
),
);
},
[knownWorkspacePaths],
[environmentId, knownWorkspacePaths],
);
const refreshWorkspaces = useCallback(
@@ -902,7 +1286,7 @@ function ChatThreadPane({
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(results, current);
const merged = results;
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -939,6 +1323,7 @@ function ChatThreadPane({
valid?: boolean;
path?: string;
}>("validate_workspace_directory", {
environmentId,
path: nextWorkspace,
})
.catch(() => ({ valid: false, path: undefined }));
@@ -967,7 +1352,7 @@ function ChatThreadPane({
return true;
},
[invalidateGitBranch, refreshWorkspaces, setWorkspacePath],
[environmentId, invalidateGitBranch, refreshWorkspaces, setWorkspacePath],
);
const selectChat = useCallback(async (): Promise<boolean> => {
@@ -982,10 +1367,14 @@ function ChatThreadPane({
// Resolves to null when the user cancels; rethrows picker failures
// (e.g. no zenity/kdialog on Linux) so callers can surface an error
// and offer manual path entry instead of a silent no-op.
if (remoteEnvironment) {
return await onPickRemoteWorkspaceDirectory(remoteEnvironment);
}
try {
const selected = await desktopClient.invoke<string | null>(
"pick_workspace_directory",
{
environmentId,
initialPath: initialPath?.trim() || undefined,
},
);
@@ -1002,7 +1391,7 @@ function ChatThreadPane({
);
}
},
[],
[environmentId, onPickRemoteWorkspaceDirectory, remoteEnvironment],
);
useEffect(() => {
@@ -1206,6 +1595,7 @@ function ChatThreadPane({
const cwd = config.cwd ?? workspaceRoot;
const forkedHistorySession: SessionHistoryItem = {
sessionId: result.newSessionId,
environmentId: config.environmentId,
status: "completed",
provider: config.provider,
model: config.model,
@@ -1278,6 +1668,7 @@ function ChatThreadPane({
"delete_chat_session",
{
sessionId: activeSessionToDelete,
environmentId,
},
);
if (!deleted) {
@@ -1296,6 +1687,7 @@ function ChatThreadPane({
new CustomEvent("cline:session-deleted", {
detail: {
sessionId: activeSessionToDelete,
environmentId,
},
}),
);
@@ -1321,6 +1713,7 @@ function ChatThreadPane({
}, [
activeSessionToDelete,
deletingSession,
environmentId,
onDeleteSession,
reset,
threadId,
@@ -1444,6 +1837,7 @@ function ChatThreadPane({
loading: agentsLoading,
error: agentsError,
} = useSessionAgents({
environmentId,
sessionId: displayedSessionId,
panelOpen: agentPanelOpen,
sessionActive: isSessionActive,
@@ -1458,8 +1852,9 @@ function ChatThreadPane({
// A child agent has its own session row, so opening it goes through the same
// path as any other session — it is just never listed in the sidebar.
const onOpenAgentSession = useCallback(
(agentSessionId: string) => onOpenSessionById?.(agentSessionId),
[onOpenSessionById],
(agentSessionId: string) =>
onOpenSessionById?.(agentSessionId, environmentId),
[environmentId, onOpenSessionById],
);
const handleRenameTitle = useCallback(
@@ -1471,6 +1866,7 @@ function ChatThreadPane({
try {
await desktopClient.invoke("update_chat_session_title", {
sessionId: activeSessionForTitle,
environmentId,
title: nextTitle,
});
const normalizedTitle = nextTitle.trim();
@@ -1483,6 +1879,7 @@ function ChatThreadPane({
new CustomEvent("cline:session-title-updated", {
detail: {
sessionId: activeSessionForTitle,
environmentId,
title: normalizedTitle,
},
}),
@@ -1493,6 +1890,7 @@ function ChatThreadPane({
},
[
activeSessionForTitle,
environmentId,
historySession?.metadata,
onUpdateSessionMetadata,
renamingSession,
@@ -1553,6 +1951,7 @@ function ChatThreadPane({
const composer = (
<ChatInputBar
attachments={attachmentList}
environmentId={environmentId}
hasRunningAgents={agentActivity.running > 0}
onAbort={handleAbort}
onAttachFiles={handleAttachFiles}
@@ -1605,7 +2004,7 @@ function ChatThreadPane({
agentsLoading={agentsLoading}
onAgentsOpenChange={setAgentPanelOpen}
onOpenAgentSession={onOpenAgentSession}
onOpenParentSession={onOpenSessionById}
onOpenParentSession={onOpenAgentSession}
parentSession={hideDeletedSessionUi ? undefined : parentSession}
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
@@ -1628,6 +2027,7 @@ function ChatThreadPane({
showDiffView ? (
<DiffView
cwd={config.cwd || config.workspaceRoot}
environmentId={environmentId}
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
@@ -1656,6 +2056,15 @@ function ChatThreadPane({
)
}
composer={composer}
environmentSelector={
<EnvironmentSelector
activeEnvironmentId={environmentId}
loading={environmentProfilesLoading}
onAddSshHost={onAddSshHost}
onSelectEnvironment={onSelectEnvironment}
profiles={environmentProfiles}
/>
}
gitBranch={gitBranch}
notice={
providersLoaded &&
@@ -13,6 +13,7 @@ import {
Import,
Loader2,
Mic,
Network,
PanelLeftOpen,
Pencil,
Pin,
@@ -147,6 +148,7 @@ const SETTINGS_SECTION_ICONS = {
Channels: Radio,
Schedules: Clock3,
Import: Import,
Remote: Network,
Account: CircleUserRound,
Customize: Blocks,
Marketplace: Store,
@@ -13,6 +13,7 @@ import {
import type { ProviderModel } from "@/lib/provider-schema";
import {
buildUserInstructionSlashCommands,
buildWorkspaceFileSearchKey,
ChatInputBar,
} from "./chat-input-bar";
@@ -342,6 +343,24 @@ describe("ChatInputBar", () => {
expect(onAbort).toHaveBeenCalledOnce();
});
it("isolates workspace file search caches by environment", () => {
const localKey = buildWorkspaceFileSearchKey(
"local",
"/workspace/shared",
"src",
);
const remoteKey = buildWorkspaceFileSearchKey(
"pi-server",
"/workspace/shared",
"src",
);
expect(remoteKey).not.toBe(localKey);
expect(
buildWorkspaceFileSearchKey("pi-server", "/workspace/shared", "src"),
).toBe(remoteKey);
});
it("builds slash commands from both workflows and skills", () => {
expect(
buildUserInstructionSlashCommands({
@@ -923,6 +942,7 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -1105,6 +1125,7 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -1192,6 +1213,7 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -2307,6 +2329,7 @@ describe("ChatInputBar token ring", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -286,7 +286,16 @@ export type PromptDraft = {
value: string;
};
export function buildWorkspaceFileSearchKey(
environmentId: string,
workspaceRoot: string,
query: string,
): string {
return JSON.stringify([environmentId, workspaceRoot, query]);
}
type ChatInputBarProps = {
environmentId: string;
variant?: "conversation" | "welcome";
status: ChatSessionStatus;
hasRunningAgents?: boolean;
@@ -331,6 +340,7 @@ type ChatInputBarProps = {
};
function ChatInputBarImpl({
environmentId,
variant = "conversation",
status,
hasRunningAgents = false,
@@ -927,7 +937,11 @@ function ChatInputBarImpl({
return;
}
const requestKey = `${workspaceRoot}::${activeMention.query}`;
const requestKey = buildWorkspaceFileSearchKey(
environmentId,
workspaceRoot,
activeMention.query,
);
if (mentionLastRequestKeyRef.current === requestKey) {
return;
}
@@ -949,6 +963,7 @@ function ChatInputBarImpl({
const results = await desktopClient.invoke<string[]>(
"search_workspace_files",
{
environmentId,
workspaceRoot,
query: activeMention.query,
limit: 10,
@@ -979,7 +994,13 @@ function ChatInputBarImpl({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [activeMention, mentionOpen, workspaceRoot, mentionFiles.length]);
}, [
activeMention,
environmentId,
mentionOpen,
workspaceRoot,
mentionFiles.length,
]);
const insertMentionFile = useCallback(
(filePath: string) => {
@@ -143,6 +143,7 @@ describe("DiffView file actions", () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
@@ -159,6 +160,7 @@ describe("DiffView file actions", () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
@@ -176,6 +178,7 @@ describe("DiffView file actions", () => {
await click(vscodeItem as Element);
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
environmentId: "local",
path: "docs/a.mdx",
cwd: "/Users/renee/cline",
editor: "vscode",
@@ -191,7 +194,13 @@ describe("DiffView file actions", () => {
});
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
root.render(
<DiffView
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await pointerDown(buttonWithLabel("Open docs/a.mdx in editor"));
@@ -202,6 +211,7 @@ describe("DiffView file actions", () => {
await click(menuItems()[0] as Element);
expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", {
environmentId: "local",
path: "docs/a.mdx",
editor: "default",
});
@@ -209,7 +219,13 @@ describe("DiffView file actions", () => {
it("copies the path as-is when no cwd is available", async () => {
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
root.render(
<DiffView
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
@@ -28,6 +28,7 @@ import { resolveWorkspaceFilePath } from "@/lib/workspace-paths";
import { EditorIcon } from "./editor-icons";
type DiffViewProps = {
environmentId: string;
fileDiffs: SessionFileDiff[];
cwd?: string;
onClose: () => void;
@@ -38,7 +39,12 @@ type EditorOption = {
label: string;
};
export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
export function DiffView({
environmentId,
fileDiffs,
cwd,
onClose,
}: DiffViewProps) {
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [editors, setEditors] = useState<EditorOption[]>([]);
@@ -120,6 +126,7 @@ export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) {
collapsed={collapsedFiles.has(file.path)}
cwd={cwd}
editors={editors}
environmentId={environmentId}
file={file}
key={file.path}
onToggle={() => toggleFileCollapse(file.path)}
@@ -137,12 +144,14 @@ function DiffFileSection({
collapsed,
cwd,
editors,
environmentId,
onToggle,
}: {
file: SessionFileDiff;
collapsed: boolean;
cwd?: string;
editors: EditorOption[];
environmentId: string;
onToggle: () => void;
}) {
const [copied, setCopied] = useState(false);
@@ -175,6 +184,7 @@ function DiffFileSection({
setOpening(true);
try {
await desktopClient.invoke("open_file_in_editor", {
environmentId,
path: file.path,
...(cwd?.trim() ? { cwd } : {}),
...(editor ? { editor } : {}),
@@ -192,7 +202,7 @@ function DiffFileSection({
setOpening(false);
}
},
[file.path, cwd],
[cwd, environmentId, file.path],
);
return (
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RemoteEnvironmentProfile } from "@/lib/remote-environments";
import {
buildEnvironmentSelectorModel,
EnvironmentSelector,
} from "./environment-selector";
const profiles: RemoteEnvironmentProfile[] = [
{
id: "pi-server",
name: "Raspberry Pi",
host: "pi.example.com",
user: "pi",
},
{
id: "build-box",
name: "Build box",
host: "builder.example.com",
user: "ubuntu",
port: 2200,
},
];
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
if (!("ResizeObserver" in globalThis)) {
Object.assign(globalThis, {
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
}
Element.prototype.scrollIntoView ??= () => {};
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.setPointerCapture ??= () => {};
Element.prototype.releasePointerCapture ??= () => {};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
async function pointerDown(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
}),
);
await Promise.resolve();
});
}
function trigger(): HTMLButtonElement {
const element = container.querySelector<HTMLButtonElement>(
"#environment-selector-btn",
);
expect(element).not.toBeNull();
return element as HTMLButtonElement;
}
function menuItemContaining(text: string): HTMLElement {
const item = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((candidate) => candidate.textContent?.includes(text));
expect(item).toBeDefined();
return item as HTMLElement;
}
describe("buildEnvironmentSelectorModel", () => {
it("builds a sorted remote catalog and identifies the connected profile", () => {
const model = buildEnvironmentSelectorModel("pi-server", [
...profiles,
{ ...profiles[0], name: "Duplicate Pi" },
{ ...profiles[0], id: undefined, name: "Unsaved" },
]);
expect(model).toMatchObject({
activeKind: "remote",
activeLabel: "Raspberry Pi",
local: { id: "local", selected: false },
});
expect(model.remotes).toEqual([
expect.objectContaining({
id: "build-box",
label: "Build box",
selected: false,
}),
expect.objectContaining({
id: "pi-server",
label: "Raspberry Pi",
selected: true,
}),
]);
});
it("does not mislabel an unloaded remote environment as Local", () => {
expect(buildEnvironmentSelectorModel("remote-loading", [])).toMatchObject({
activeKind: "remote",
activeLabel: "Remote",
local: { selected: false },
});
});
});
describe("EnvironmentSelector", () => {
it("renders every environment tier and routes selections and host setup", async () => {
const onSelectEnvironment = vi.fn(async () => undefined);
const onAddSshHost = vi.fn();
await act(async () => {
root.render(
<EnvironmentSelector
activeEnvironmentId="pi-server"
onAddSshHost={onAddSshHost}
onSelectEnvironment={onSelectEnvironment}
profiles={profiles}
/>,
);
});
expect(trigger().textContent?.trim()).toBe("");
expect(trigger().getAttribute("aria-label")).toBe(
"Environment: Raspberry Pi",
);
expect(trigger().title).toBe("Environment: Raspberry Pi");
expect(document.body.textContent).not.toContain("Raspberry Pi");
await pointerDown(trigger());
expect(document.body.textContent).toContain("Raspberry Pi");
expect(document.body.textContent).toContain("Local");
expect(document.body.textContent).toContain("Remote");
expect(document.body.textContent).toContain("Build box");
expect(document.body.textContent).not.toContain(
"ubuntu@builder.example.com:2200",
);
expect(document.body.textContent).not.toContain("Connected");
expect(document.body.textContent).toContain("Cloud");
expect(document.body.textContent).toContain("Coming soon");
expect(
Array.from(document.querySelectorAll('[role="menuitem"]')).some((item) =>
item.textContent?.includes("Cloud"),
),
).toBe(false);
await click(menuItemContaining("Local"));
await vi.waitFor(() => {
expect(onSelectEnvironment).toHaveBeenCalledWith("local");
});
await pointerDown(trigger());
const addHost = document.querySelector(
'[role="menuitem"][aria-label="Add SSH Host"]',
);
expect(addHost).not.toBeNull();
expect(addHost?.textContent?.trim()).toBe("");
expect(addHost?.parentElement?.textContent).toContain("Remote");
expect(document.body.textContent?.indexOf("Cloud")).toBeLessThan(
document.body.textContent?.indexOf("Remote") ?? 0,
);
await click(addHost as HTMLElement);
expect(onAddSshHost).toHaveBeenCalledTimes(1);
});
it("reopens the menu after a rejected environment switch", async () => {
const onSelectEnvironment = vi
.fn()
.mockRejectedValue(new Error("SSH unavailable"));
await act(async () => {
root.render(
<EnvironmentSelector
activeEnvironmentId="local"
onAddSshHost={vi.fn()}
onSelectEnvironment={onSelectEnvironment}
profiles={profiles}
/>,
);
});
await pointerDown(trigger());
await click(menuItemContaining("Build box"));
await vi.waitFor(() => {
expect(onSelectEnvironment).toHaveBeenCalledWith("build-box");
expect(menuItemContaining("Build box")).toBeDefined();
});
expect(trigger().disabled).toBe(false);
});
});
@@ -0,0 +1,200 @@
"use client";
import { Check, Cloud, Laptop, Loader2, Server, Settings } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { RemoteEnvironmentProfile } from "@/lib/remote-environments";
import { LOCAL_WORKSPACE_ENVIRONMENT_ID } from "@/lib/workspace-paths";
export type EnvironmentSelectorOption = {
id: string;
label: string;
kind: "local" | "remote";
selected: boolean;
};
export type EnvironmentSelectorModel = {
activeKind: "local" | "remote";
activeLabel: string;
local: EnvironmentSelectorOption;
remotes: EnvironmentSelectorOption[];
};
export type EnvironmentSelectorProps = {
activeEnvironmentId: string;
profiles: RemoteEnvironmentProfile[];
loading?: boolean;
switchingEnvironmentId?: string | null;
onSelectEnvironment: (environmentId: string) => void | Promise<void>;
onAddSshHost: () => void;
};
export function buildEnvironmentSelectorModel(
activeEnvironmentId: string,
profiles: RemoteEnvironmentProfile[],
): EnvironmentSelectorModel {
const remoteById = new Map<string, EnvironmentSelectorOption>();
for (const profile of profiles) {
const id = profile.id?.trim();
if (!id || remoteById.has(id)) continue;
const selected = id === activeEnvironmentId;
remoteById.set(id, {
id,
label: profile.name.trim() || profile.host.trim() || "SSH host",
kind: "remote",
selected,
});
}
const remotes = [...remoteById.values()].sort(
(left, right) =>
left.label.localeCompare(right.label) || left.id.localeCompare(right.id),
);
const localSelected = activeEnvironmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID;
const activeRemote = remotes.find((option) => option.selected);
return {
activeKind: localSelected ? "local" : "remote",
activeLabel: activeRemote?.label ?? (localSelected ? "Local" : "Remote"),
local: {
id: LOCAL_WORKSPACE_ENVIRONMENT_ID,
label: "Local",
kind: "local",
selected: localSelected,
},
remotes,
};
}
export function EnvironmentSelector({
activeEnvironmentId,
profiles,
loading = false,
switchingEnvironmentId,
onSelectEnvironment,
onAddSshHost,
}: EnvironmentSelectorProps) {
const model = useMemo(
() => buildEnvironmentSelectorModel(activeEnvironmentId, profiles),
[activeEnvironmentId, profiles],
);
const [internalSwitchingId, setInternalSwitchingId] = useState<string | null>(
null,
);
const [open, setOpen] = useState(false);
const pendingEnvironmentId = switchingEnvironmentId ?? internalSwitchingId;
const busy = loading || pendingEnvironmentId !== null;
const ActiveIcon = model.activeKind === "remote" ? Server : Laptop;
const selectEnvironment = async (environmentId: string) => {
if (busy || environmentId === activeEnvironmentId) return;
setInternalSwitchingId(environmentId);
try {
await onSelectEnvironment(environmentId);
} catch {
// The parent owns connection errors and their user-facing presentation;
// reopen so the failed choice does not strand the user at a closed menu.
setOpen(true);
} finally {
setInternalSwitchingId(null);
}
};
const optionStatus = (option: EnvironmentSelectorOption) => {
if (pendingEnvironmentId === option.id) {
return (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Connecting
</span>
);
}
return null;
};
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Environment: ${model.activeLabel}`}
className="size-9 shrink-0 rounded-md border border-border/70 bg-background/80 p-0 text-foreground shadow-none transition-colors hover:bg-accent hover:text-foreground"
disabled={busy}
id="environment-selector-btn"
title={`Environment: ${model.activeLabel}`}
variant="ghost"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ActiveIcon className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72" side="bottom">
<DropdownMenuItem
aria-current={model.local.selected ? "true" : undefined}
className="aria-current:bg-purple-500/20 aria-current:focus:bg-purple-500/25"
disabled={busy}
onSelect={() => void selectEnvironment(model.local.id)}
>
<Laptop />
<span className="uppercase">{model.local.label}</span>
{optionStatus(model.local)}
{model.local.selected ? <Check className="ml-auto" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Cloud className="size-4" />
<span>Cloud</span>
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] font-normal normal-case tracking-normal text-muted-foreground">
Coming soon
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex items-center justify-between">
<DropdownMenuLabel className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Server className="size-4" />
Remote
</DropdownMenuLabel>
<DropdownMenuItem
aria-label="Add SSH Host"
title="Add SSH Host"
className="mr-1 size-6 justify-center p-0"
disabled={busy}
onSelect={onAddSshHost}
>
<Settings className="size-3.5" />
</DropdownMenuItem>
</div>
{model.remotes.length > 0 ? (
model.remotes.map((option) => (
<DropdownMenuItem
aria-current={option.selected ? "true" : undefined}
className="aria-current:bg-purple-500/20 aria-current:focus:bg-purple-500/25"
disabled={busy}
key={option.id}
onSelect={() => void selectEnvironment(option.id)}
>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{optionStatus(option)}
{option.selected ? <Check className="ml-auto" /> : null}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-muted-foreground">No SSH hosts saved</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RemoteDirectoryPicker } from "./remote-directory-picker";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
invokeMock.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function clickButton(text: string): Promise<void> {
const button = [
...document.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
await act(async () => {
button?.click();
await Promise.resolve();
});
}
describe("RemoteDirectoryPicker", () => {
it("browses from remote home and returns the selected directory", async () => {
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
expect(command).toBe("list_workspace_directories");
if (args?.path === "/home/pi") {
return {
environmentId: "pi-host",
currentPath: "/home/pi",
parentPath: "/home",
entries: [{ name: "projects", path: "/home/pi/projects" }],
truncated: true,
};
}
if (args?.path === "/home/pi/projects") {
return {
environmentId: "pi-host",
currentPath: "/srv/projects",
parentPath: "/srv",
entries: [{ name: "cline", path: "/srv/projects/cline" }],
truncated: false,
};
}
return {
environmentId: "pi-host",
currentPath: String(args?.path),
parentPath: "/srv/projects",
entries: [],
truncated: false,
};
},
);
const onSelect = vi.fn();
await act(async () => {
root.render(
<RemoteDirectoryPicker
environmentId="pi-host"
homeDir="/home/pi"
onCancel={vi.fn()}
onSelect={onSelect}
open
/>,
);
});
await vi.waitFor(() => {
expect(document.body.textContent).toContain("projects");
expect(document.body.textContent).toContain(
"Only the first directories are shown",
);
});
expect(invokeMock).toHaveBeenCalledWith("list_workspace_directories", {
environmentId: "pi-host",
path: "/home/pi",
});
await clickButton("projects");
await vi.waitFor(() => {
expect(document.body.textContent).toContain("cline");
});
expect(invokeMock).toHaveBeenCalledWith("list_workspace_directories", {
environmentId: "pi-host",
path: "/home/pi/projects",
});
await clickButton("cline");
await vi.waitFor(() => {
expect(document.body.textContent).toContain("/srv/projects/cline");
});
await clickButton("Use this folder");
expect(onSelect).toHaveBeenCalledWith("/srv/projects/cline");
});
it("rejects a directory response from another environment", async () => {
invokeMock.mockResolvedValue({
environmentId: "other-host",
currentPath: "/home/other",
parentPath: "/home",
entries: [],
truncated: false,
});
await act(async () => {
root.render(
<RemoteDirectoryPicker
environmentId="pi-host"
homeDir="/home/pi"
onCancel={vi.fn()}
onSelect={vi.fn()}
open
/>,
);
});
await vi.waitFor(() => {
expect(document.body.textContent).toContain(
"Directory response belongs to other-host, not pi-host.",
);
});
});
});
@@ -0,0 +1,229 @@
"use client";
import {
ArrowUp,
CircleAlert,
Folder,
Home,
Loader2,
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { desktopClient } from "@/lib/desktop-client";
type WorkspaceDirectoryListResult = {
environmentId: string;
currentPath: string;
parentPath: string | null;
entries: Array<{ name: string; path: string }>;
truncated: boolean;
};
function normalizeRemotePath(path: string): string {
const trimmed = path.trim();
if (trimmed === "/") return trimmed;
return trimmed.replace(/\/+$/, "");
}
export function RemoteDirectoryPicker({
open,
environmentId,
homeDir,
onCancel,
onSelect,
}: {
open: boolean;
environmentId: string;
homeDir: string;
onCancel: () => void;
onSelect: (path: string) => void;
}) {
const normalizedHome = normalizeRemotePath(homeDir) || "/";
const [currentPath, setCurrentPath] = useState(normalizedHome);
const [requestedPath, setRequestedPath] = useState(normalizedHome);
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<
Array<{ name: string; path: string }>
>([]);
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadVersion, setReloadVersion] = useState(0);
useEffect(() => {
if (!open) return;
setCurrentPath(normalizedHome);
setRequestedPath(normalizedHome);
setParentPath(null);
setDirectories([]);
setTruncated(false);
}, [normalizedHome, open]);
useEffect(() => {
if (!open) return;
const request = {
environmentId,
path: requestedPath,
reloadVersion,
};
let cancelled = false;
setLoading(true);
setError(null);
desktopClient
.invoke<WorkspaceDirectoryListResult>("list_workspace_directories", {
environmentId: request.environmentId,
path: request.path,
})
.then((result) => {
if (cancelled) return;
if (result.environmentId !== request.environmentId) {
throw new Error(
`Directory response belongs to ${result.environmentId}, not ${request.environmentId}.`,
);
}
const canonicalPath = normalizeRemotePath(result.currentPath);
if (!canonicalPath) {
throw new Error("Remote host returned an empty directory path.");
}
setCurrentPath(canonicalPath);
setParentPath(
result.parentPath ? normalizeRemotePath(result.parentPath) : null,
);
setDirectories(
(result.entries ?? []).filter(
(entry) => entry.name.trim() && normalizeRemotePath(entry.path),
),
);
setTruncated(result.truncated === true);
})
.catch((listError: unknown) => {
if (cancelled) return;
setDirectories([]);
setTruncated(false);
setError(
listError instanceof Error ? listError.message : String(listError),
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [environmentId, open, reloadVersion, requestedPath]);
const canGoUp = Boolean(parentPath && parentPath !== currentPath);
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
<DialogContent className="gap-4 sm:max-w-xl">
<DialogHeader>
<DialogTitle>Choose remote workspace</DialogTitle>
<DialogDescription>
Browse directories on the connected SSH host. No local folders are
shown here.
</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2">
<Button
aria-label="Remote home directory"
disabled={loading || currentPath === normalizedHome}
onClick={() => setRequestedPath(normalizedHome)}
size="icon"
variant="outline"
>
<Home />
</Button>
<Button
aria-label="Parent remote directory"
disabled={loading || !canGoUp}
onClick={() => parentPath && setRequestedPath(parentPath)}
size="icon"
variant="outline"
>
<ArrowUp />
</Button>
<p
className="min-w-0 flex-1 truncate rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs"
title={currentPath}
>
{currentPath}
</p>
<Button
aria-label="Refresh remote directories"
disabled={loading}
onClick={() => setReloadVersion((version) => version + 1)}
size="icon"
variant="outline"
>
<RefreshCw className={loading ? "animate-spin" : undefined} />
</Button>
</div>
<div className="min-h-56 rounded-md border p-1.5">
{loading ? (
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading remote directories
</div>
) : error ? (
<div className="flex h-52 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-destructive">
<CircleAlert className="size-5" />
{error}
</div>
) : directories.length === 0 ? (
<div className="flex h-52 items-center justify-center text-sm text-muted-foreground">
No subdirectories
</div>
) : (
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{directories.map((entry) => {
return (
<Button
className="h-auto w-full justify-start gap-2 px-2 py-2 text-left"
key={entry.path}
onClick={() => setRequestedPath(entry.path)}
variant="ghost"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-sm">
{entry.name}
</span>
</Button>
);
})}
</div>
)}
</div>
{truncated && !loading && !error ? (
<p className="text-xs text-muted-foreground">
Only the first directories are shown. Open a folder to continue
browsing.
</p>
) : null}
<DialogFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
disabled={loading || Boolean(error)}
onClick={() => onSelect(currentPath)}
>
Use this folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import type { AgendaTaskRecord } from "@cline/shared";
import { act } from "react";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
@@ -55,6 +55,7 @@ async function renderWelcomeScreen({
workspaceRoot,
workspaces,
gitBranch = "main",
environmentSelector = null,
selectChat = vi.fn(async () => true),
onListGitBranches = vi.fn(async () => ({
current: "main",
@@ -65,6 +66,7 @@ async function renderWelcomeScreen({
workspaceRoot: string;
workspaces: string[];
gitBranch?: string | null;
environmentSelector?: ReactNode;
selectChat?: () => Promise<boolean>;
onListGitBranches?: () => Promise<{
current: string;
@@ -90,6 +92,7 @@ async function renderWelcomeScreen({
body={null}
composer={null}
gitBranch={gitBranch}
environmentSelector={environmentSelector}
onListGitBranches={onListGitBranches}
onOpenSession={onOpenSession}
onSwitchGitBranch={vi.fn(async () => true)}
@@ -117,6 +120,31 @@ async function clickButton(
}
describe("WelcomeScreen", () => {
it("places the environment selector before the workspace selector", async () => {
await renderWelcomeScreen({
environmentSelector: (
<button data-testid="environment-selector" type="button">
Local
</button>
),
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
});
const environmentSelector = container.querySelector(
'[data-testid="environment-selector"]',
);
const workspaceSelector = container.querySelector(
'button[title="project-1"]',
);
expect(environmentSelector).not.toBeNull();
expect(workspaceSelector).not.toBeNull();
expect(
environmentSelector?.compareDocumentPosition(workspaceSelector as Node) ??
0,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it("does not render static prompt suggestions", async () => {
await renderWelcomeScreen({
gitBranch: "main",
@@ -21,6 +21,7 @@ export function WelcomeScreen({
body,
composer,
notice,
environmentSelector,
gitBranch,
onListGitBranches,
onSwitchGitBranch,
@@ -33,6 +34,7 @@ export function WelcomeScreen({
notice?: ReactNode;
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
gitBranch: string | null;
environmentSelector: ReactNode;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
onOpenSession?: (sessionId: string) => void | Promise<void>;
@@ -136,7 +138,8 @@ export function WelcomeScreen({
<h1 className="sr-only">What would you like to build?</h1>
<AgentWelcomeHero />
<div className="mt-11 flex min-w-0 items-center">
<div className="mt-11 flex min-w-0 items-center gap-2">
{environmentSelector}
<WelcomeWorkspaceControls
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
@@ -30,6 +30,7 @@ const thread: SessionThread = {
const session: SessionHistoryItem = {
sessionId: thread.id,
environmentId: "local",
status: "completed",
provider: thread.provider,
model: thread.model,
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, type HTMLAttributes } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RemoteEnvironmentProfile } from "@/lib/remote-environments";
import { RemoteEnvironmentsContent } from "./remote-environments-view";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
}));
const profile: RemoteEnvironmentProfile = {
id: "build-box",
name: "Build box",
host: "builder.example.com",
user: "ubuntu",
port: 22,
identityFile: "~/.ssh/id_ed25519",
};
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
invokeMock.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
function buttonWithText(text: string): HTMLButtonElement {
const button = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
function inputById(id: string): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>(`#${id}`);
expect(input).not.toBeNull();
return input as HTMLInputElement;
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
describe("RemoteEnvironmentsContent", () => {
it("locks a saved profile destination while leaving editable metadata available", async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === "list_remote_environments") {
return { profiles: [profile], activeProfileId: null };
}
throw new Error(`Unexpected command: ${command}`);
});
await act(async () => {
root.render(<RemoteEnvironmentsContent />);
});
await vi.waitFor(() => {
expect(inputById("remote-name").value).toBe("Build box");
});
expect(inputById("remote-host").disabled).toBe(true);
expect(inputById("remote-user").disabled).toBe(true);
expect(inputById("remote-port").disabled).toBe(true);
expect(inputById("remote-name").disabled).toBe(false);
expect(inputById("remote-identity").disabled).toBe(false);
expect(container.textContent).toContain(
"Create a new host to change the SSH host, user, or port.",
);
await click(buttonWithText("New Host"));
expect(inputById("remote-host").disabled).toBe(false);
expect(inputById("remote-user").disabled).toBe(false);
expect(inputById("remote-port").disabled).toBe(false);
expect(container.textContent).not.toContain(
"Create a new host to change the SSH host, user, or port.",
);
});
it("keeps settings limited to saving and testing SSH hosts", async () => {
invokeMock.mockImplementation(async (command: string) => {
switch (command) {
case "list_remote_environments":
return { profiles: [profile], activeProfileId: profile.id };
case "upsert_remote_environment":
return { profile };
default:
throw new Error(`Unexpected command: ${command}`);
}
});
await act(async () => {
root.render(<RemoteEnvironmentsContent />);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Build box");
expect(buttonWithText("Save").disabled).toBe(false);
});
expect(container.querySelector("#remote-workspace")).toBeNull();
expect(container.textContent).not.toContain("Connect & Open");
expect(container.textContent).not.toContain("Disconnect");
expect(container.textContent).toContain(
"Manage your remote SSH hosts and their configurations.",
);
expect(container.textContent).toContain(
"Password sign-in is not supported.",
);
await click(buttonWithText("Save"));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledTimes(2);
});
expect(invokeMock).toHaveBeenNthCalledWith(2, "upsert_remote_environment", {
profile,
});
expect(container.textContent).toContain("Connected");
expect(container.textContent).toContain("Ready");
});
it("keeps a failed SSH test visible on its profile", async () => {
invokeMock.mockImplementation(async (command: string) => {
switch (command) {
case "list_remote_environments":
return { profiles: [profile], activeProfileId: null };
case "upsert_remote_environment":
return { profile };
case "test_remote_environment":
throw new Error("Permission denied (publickey)");
default:
throw new Error(`Unexpected command: ${command}`);
}
});
await act(async () => {
root.render(<RemoteEnvironmentsContent />);
});
await vi.waitFor(() => {
expect(buttonWithText("Test Connection").disabled).toBe(false);
});
await click(buttonWithText("Test Connection"));
await vi.waitFor(() => {
expect(container.textContent).toContain("Permission denied (publickey)");
});
expect(container.textContent).toContain("Failed");
expect(invokeMock).toHaveBeenNthCalledWith(3, "test_remote_environment", {
id: profile.id,
});
});
});
@@ -0,0 +1,685 @@
"use client";
import {
CheckCircle2,
CircleAlert,
Loader2,
Plug,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { desktopClient } from "@/lib/desktop-client";
import {
createRemoteEnvironmentDraft,
DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE,
formatRemoteEnvironmentDestination,
normalizeRemoteEnvironmentProfile,
type RemoteEnvironmentDeleteResult,
type RemoteEnvironmentListResult,
type RemoteEnvironmentProfile,
type RemoteEnvironmentRuntimeState,
type RemoteEnvironmentTestResult,
type RemoteEnvironmentUpsertResult,
validateRemoteEnvironmentProfile,
} from "@/lib/remote-environments";
import { cn } from "@/lib/utils";
import { PageEmptyState, PageFrame, PageHeader } from "../page-layout";
type RemoteAction = "save" | "test" | "delete";
type BusyAction = {
action: RemoteAction;
profileId: string;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function profileIdOrThrow(profile: RemoteEnvironmentProfile): string {
if (!profile.id) {
throw new Error("The desktop backend did not return an SSH profile ID.");
}
return profile.id;
}
function statusLabel(value: string): string {
if (value === "untested") return "Not tested";
return value
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
function StatusBadge({ label, value }: { label: string; value: string }) {
const isPositive =
value === "connected" || value === "passed" || value === "ready";
const isPending =
value === "connecting" ||
value === "disconnecting" ||
value === "testing" ||
value === "installing";
const isError = value === "failed" || value === "error";
return (
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{label}</span>
<Badge
className={cn(
isPositive &&
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
isPending &&
"border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
isError && "border-destructive/30 bg-destructive/10 text-destructive",
)}
variant="outline"
>
{isPending ? <Loader2 className="animate-spin" /> : null}
{isPositive ? <CheckCircle2 /> : null}
{isError ? <CircleAlert /> : null}
{statusLabel(value)}
</Badge>
</div>
);
}
function runtimeStateFor(
states: Record<string, RemoteEnvironmentRuntimeState>,
profileId: string | undefined,
activeProfileId: string | null,
): RemoteEnvironmentRuntimeState {
if (profileId && states[profileId]) {
return states[profileId];
}
if (profileId && profileId === activeProfileId) {
return {
...DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE,
bootstrap: "ready",
connection: "connected",
};
}
return DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE;
}
export function RemoteEnvironmentsContent() {
const [profiles, setProfiles] = useState<RemoteEnvironmentProfile[]>([]);
const [activeProfileId, setActiveProfileId] = useState<string | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [draft, setDraft] = useState<RemoteEnvironmentProfile>(() =>
createRemoteEnvironmentDraft(),
);
const [runtimeStates, setRuntimeStates] = useState<
Record<string, RemoteEnvironmentRuntimeState>
>({});
const [isLoading, setIsLoading] = useState(true);
const [busyAction, setBusyAction] = useState<BusyAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] =
useState<RemoteEnvironmentProfile | null>(null);
const selectedProfile = useMemo(
() => profiles.find((profile) => profile.id === selectedProfileId),
[profiles, selectedProfileId],
);
const selectedRuntime = runtimeStateFor(
runtimeStates,
selectedProfileId ?? draft.id,
activeProfileId,
);
const isBusy = isLoading || busyAction !== null;
const hasSavedDestination = Boolean(draft.id);
const setRuntimeState = useCallback(
(
profileId: string,
updates:
| Partial<RemoteEnvironmentRuntimeState>
| ((
current: RemoteEnvironmentRuntimeState,
) => Partial<RemoteEnvironmentRuntimeState>),
) => {
setRuntimeStates((current) => {
const previous = runtimeStateFor(current, profileId, activeProfileId);
const nextUpdates =
typeof updates === "function" ? updates(previous) : updates;
return {
...current,
[profileId]: { ...previous, ...nextUpdates },
};
});
},
[activeProfileId],
);
const selectProfile = useCallback((profile: RemoteEnvironmentProfile) => {
setSelectedProfileId(profile.id ?? null);
setDraft(createRemoteEnvironmentDraft(profile));
setFormError(null);
setError(null);
}, []);
const startNewProfile = useCallback(() => {
setSelectedProfileId(null);
setDraft(createRemoteEnvironmentDraft());
setFormError(null);
setError(null);
}, []);
const loadProfiles = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await desktopClient.invoke<RemoteEnvironmentListResult>(
"list_remote_environments",
);
setProfiles(result.profiles);
setActiveProfileId(result.activeProfileId);
setRuntimeStates((current) => {
if (!result.activeProfileId) return current;
return {
...current,
[result.activeProfileId]: {
...DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE,
...current[result.activeProfileId],
bootstrap: "ready",
connection: "connected",
},
};
});
const nextProfile =
result.profiles.find(
(profile) => profile.id === result.activeProfileId,
) ?? result.profiles[0];
if (nextProfile) {
setSelectedProfileId(nextProfile.id ?? null);
setDraft(createRemoteEnvironmentDraft(nextProfile));
} else {
startNewProfile();
}
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setIsLoading(false);
}
}, [startNewProfile]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadProfiles();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProfiles]);
const updateDraft = <Key extends keyof RemoteEnvironmentProfile>(
key: Key,
value: RemoteEnvironmentProfile[Key],
) => {
setDraft((current) => ({ ...current, [key]: value }));
setFormError(null);
};
const upsertLocalProfile = useCallback(
(profile: RemoteEnvironmentProfile) => {
setProfiles((current) => {
const existingIndex = current.findIndex(
(candidate) => candidate.id === profile.id,
);
if (existingIndex < 0) return [...current, profile];
return current.map((candidate, index) =>
index === existingIndex ? profile : candidate,
);
});
setSelectedProfileId(profile.id ?? null);
setDraft(createRemoteEnvironmentDraft(profile));
},
[],
);
const persistDraft = useCallback(
async (action: RemoteAction): Promise<RemoteEnvironmentProfile> => {
const validationError = validateRemoteEnvironmentProfile(draft);
if (validationError) {
setFormError(validationError);
throw new Error(validationError);
}
const normalized = normalizeRemoteEnvironmentProfile(draft);
setFormError(null);
setError(null);
setBusyAction({ action, profileId: normalized.id ?? "new" });
const result = await desktopClient.invoke<RemoteEnvironmentUpsertResult>(
"upsert_remote_environment",
{ profile: normalized },
);
profileIdOrThrow(result.profile);
upsertLocalProfile(result.profile);
return result.profile;
},
[draft, upsertLocalProfile],
);
const saveProfile = async () => {
try {
await persistDraft("save");
} catch (saveError) {
if (!validateRemoteEnvironmentProfile(draft)) {
setError(errorMessage(saveError));
}
} finally {
setBusyAction(null);
}
};
const testProfile = async () => {
let profile: RemoteEnvironmentProfile;
try {
profile = await persistDraft("test");
} catch (saveError) {
if (!validateRemoteEnvironmentProfile(draft)) {
setError(errorMessage(saveError));
}
setBusyAction(null);
return;
}
const profileId = profileIdOrThrow(profile);
setRuntimeState(profileId, { test: "testing", message: undefined });
setBusyAction({ action: "test", profileId });
try {
const result = await desktopClient.invoke<RemoteEnvironmentTestResult>(
"test_remote_environment",
{ id: profileId },
);
setRuntimeState(profileId, {
test: result.status === "failed" ? "failed" : "passed",
message: result.message,
remotePlatform: result.remotePlatform,
remoteArch: result.remoteArch,
});
} catch (testError) {
setRuntimeState(profileId, {
test: "failed",
message: errorMessage(testError),
});
} finally {
setBusyAction(null);
}
};
const deleteProfile = async (profile: RemoteEnvironmentProfile) => {
const profileId = profileIdOrThrow(profile);
setBusyAction({ action: "delete", profileId });
setError(null);
try {
await desktopClient.invoke<RemoteEnvironmentDeleteResult>(
"delete_remote_environment",
{ id: profileId },
);
const remaining = profiles.filter(
(candidate) => candidate.id !== profileId,
);
setProfiles(remaining);
setRuntimeStates((current) => {
const next = { ...current };
delete next[profileId];
return next;
});
if (activeProfileId === profileId) setActiveProfileId(null);
const nextProfile = remaining[0];
if (nextProfile) selectProfile(nextProfile);
else startNewProfile();
} catch (deleteError) {
setError(errorMessage(deleteError));
} finally {
setBusyAction(null);
setDeleteTarget(null);
}
};
return (
<PageFrame>
<PageHeader
actions={
<Button
aria-label="Refresh remote environments"
disabled={isLoading || isBusy}
onClick={() => void loadProfiles()}
variant="ghost"
size="icon-sm"
>
<RefreshCw className={cn(isLoading && "animate-spin")} />
</Button>
}
title="Remote Environments"
description="Manage your remote SSH hosts and their configurations."
/>
{error ? (
<Alert className="mb-5" variant="destructive">
<CircleAlert />
<AlertTitle>Remote environment error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="grid grid-cols-[minmax(16rem,0.75fr)_minmax(24rem,1.25fr)] gap-5 max-[960px]:grid-cols-1">
<Card className="h-112 gap-4 overflow-hidden py-5">
<CardHeader className="flex shrink-0 flex-row items-center justify-between gap-3 px-5">
<CardTitle className="flex items-center gap-2">
SSH Hosts
<Badge variant="secondary">{profiles.length}</Badge>
</CardTitle>
<Button
disabled={isBusy}
onClick={startNewProfile}
variant="default"
size="xs"
>
<Plus />
New Host
</Button>
</CardHeader>
<CardContent className="min-h-0 flex-1 space-y-2 overflow-y-auto px-3">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading SSH hosts
</div>
) : profiles.length === 0 ? (
<PageEmptyState>
No SSH hosts yet. Add the address for your first remote
environment.
</PageEmptyState>
) : (
profiles.map((profile) => {
const runtime = runtimeStateFor(
runtimeStates,
profile.id,
activeProfileId,
);
const isSelected = profile.id === selectedProfileId;
const isActive = profile.id === activeProfileId;
return (
<button
aria-current={isSelected ? "true" : undefined}
className={cn(
"w-full rounded-lg border border-transparent px-3 py-3 text-left transition-colors hover:bg-accent/60",
isSelected && "border-border bg-accent",
)}
key={profile.id ?? `${profile.host}:${profile.port}`}
onClick={() => selectProfile(profile)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{profile.name}
</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">
{formatRemoteEnvironmentDestination(profile)}
</p>
</div>
{isActive ? (
<Badge
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
variant="outline"
>
Active
</Badge>
) : runtime.connection === "connecting" ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="gap-5 py-5">
<CardHeader className="px-5">
<div className="flex items-center justify-between gap-3">
<CardTitle>
{selectedProfile?.name ?? "Adding New Host..."}
</CardTitle>
{draft.id ? (
<Button
disabled={isBusy}
onClick={() => setDeleteTarget(draft)}
variant="ghost"
size="icon-lg"
>
<Trash2 />
</Button>
) : null}
</div>
{hasSavedDestination ? (
<CardDescription>
Create a new host to change the SSH host, user, or port.
</CardDescription>
) : null}
</CardHeader>
<CardContent className="space-y-5 px-5">
<div className="grid grid-cols-2 gap-4 max-[620px]:grid-cols-1">
<div className="space-y-2">
<Label htmlFor="remote-name">Name</Label>
<Input
disabled={isBusy}
id="remote-name"
onChange={(event) => updateDraft("name", event.target.value)}
placeholder="Build server"
value={draft.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-host">SSH host</Label>
<Input
autoCapitalize="none"
disabled={isBusy || hasSavedDestination}
id="remote-host"
onChange={(event) => updateDraft("host", event.target.value)}
placeholder="dev.example.com or ssh-config-alias"
spellCheck={false}
value={draft.host}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-user">User (optional)</Label>
<Input
autoCapitalize="none"
disabled={isBusy || hasSavedDestination}
id="remote-user"
onChange={(event) => updateDraft("user", event.target.value)}
placeholder="ubuntu"
spellCheck={false}
value={draft.user ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-port">Port</Label>
<Input
disabled={isBusy || hasSavedDestination}
id="remote-port"
max={65_535}
min={1}
onChange={(event) =>
updateDraft(
"port",
event.target.value === ""
? undefined
: Number(event.target.value),
)
}
placeholder="22 (from SSH config by default)"
type="number"
value={
draft.port === undefined || Number.isNaN(draft.port)
? ""
: draft.port
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="remote-identity">Identity file (optional)</Label>
<Input
disabled={isBusy}
id="remote-identity"
onChange={(event) =>
updateDraft("identityFile", event.target.value)
}
placeholder="~/.ssh/id_ed25519"
spellCheck={false}
value={draft.identityFile ?? ""}
/>
<p className="text-xs text-muted-foreground">
Password sign-in is not supported. The host key must already be
trusted in your SSH known_hosts file.
</p>
</div>
{formError ? (
<p className="text-sm text-destructive">{formError}</p>
) : null}
<div className="rounded-lg border bg-muted/30 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Environment status</p>
</div>
<Button
disabled={isBusy}
onClick={() => void testProfile()}
variant="secondary"
className="text-muted-foreground"
size="sm"
>
{busyAction?.action === "test" ? (
<Loader2 className="animate-spin" />
) : (
<Plug />
)}
Test Connection
</Button>
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-3 max-[620px]:grid-cols-1">
<StatusBadge
label="Environment"
value={draft.id === activeProfileId ? "active" : "inactive"}
/>
<StatusBadge
label="Connection"
value={selectedRuntime.connection}
/>
<StatusBadge
label="Connection test"
value={selectedRuntime.test}
/>
<StatusBadge
label="Cline setup"
value={selectedRuntime.bootstrap}
/>
</div>
{selectedRuntime.remotePlatform || selectedRuntime.remoteArch ? (
<p className="mt-3 text-xs text-muted-foreground">
Remote:{" "}
{[selectedRuntime.remotePlatform, selectedRuntime.remoteArch]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{selectedRuntime.message ? (
<p
className={cn(
"mt-3 text-xs leading-5 text-muted-foreground",
(selectedRuntime.connection === "error" ||
selectedRuntime.test === "failed" ||
selectedRuntime.bootstrap === "failed") &&
"text-destructive",
)}
>
{selectedRuntime.message}
</p>
) : null}
</div>
<div className="flex justify-end border-t pt-5">
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isBusy}
onClick={() => void saveProfile()}
variant="default"
>
{busyAction?.action === "save" ? (
<Loader2 className="animate-spin" />
) : null}
Save
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<AlertDialog
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
open={deleteTarget !== null}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete SSH host?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? `Delete “${deleteTarget.name}” from remote environments? Projects and Cline session data remain on the remote host.`
: "Delete this SSH host from remote environments?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
disabled={isBusy || !deleteTarget}
onClick={() => {
if (deleteTarget) void deleteProfile(deleteTarget);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageFrame>
);
}
@@ -12,6 +12,7 @@ const ALL_SETTINGS_SECTIONS = [
"Channels",
"Schedules",
"Import",
"Remote",
"Account",
] as const;
@@ -72,6 +72,7 @@ import {
ProviderDetailContent,
ProviderListContent,
} from "./provider-list-view";
import { RemoteEnvironmentsContent } from "./remote-environments-view";
import { RoutineSchedulesContent } from "./routine-view";
import type { SettingsSection } from "./sections";
import { toSettingsPatch } from "./settings-patch";
@@ -625,6 +626,8 @@ export function SettingsView({
<RoutineSchedulesContent onOpenSession={onOpenSession} />
) : activeNav === "Import" ? (
<ImportContent />
) : activeNav === "Remote" ? (
<RemoteEnvironmentsContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
@@ -640,7 +643,7 @@ export function SettingsView({
);
return (
<div className="h-full overflow-hidden bg-background">
<div className="cline-settings-content h-full overflow-hidden bg-background">
<div className="h-full min-h-0 overflow-hidden">{content}</div>
</div>
);
@@ -2,7 +2,10 @@ import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import { normalizeProviderId } from "@/lib/provider-id";
import { readWorkspaceSelectionFromWindow } from "@/lib/workspace-paths";
import {
LOCAL_WORKSPACE_ENVIRONMENT_ID,
readWorkspaceSelectionFromWindow,
} from "@/lib/workspace-paths";
export const CHAT_TRANSPORT_UNAVAILABLE_MESSAGE =
"Chat connection is unavailable. Reopen the app window to restore realtime chat.";
@@ -17,6 +20,7 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
sessionId: undefined,
workspaceRoot: "",
cwd: "",
environmentId: LOCAL_WORKSPACE_ENVIRONMENT_ID,
provider: "cline",
model: CLINE_DEFAULT_MODEL_ID,
apiKey: process.env.CLINE_API_KEY || "",
@@ -31,9 +35,9 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
missionTimeIntervalMs: undefined,
};
export function getInitialChatConfig(): ChatSessionConfig {
export function getInitialChatConfig(environmentId: string): ChatSessionConfig {
const selection = readModelSelectionStorageFromWindow();
const workspaceSelection = readWorkspaceSelectionFromWindow();
const workspaceSelection = readWorkspaceSelectionFromWindow(environmentId);
const rememberedProvider = normalizeProviderId(selection.lastProvider);
const rememberedModelForProvider = rememberedProvider
? (selection.lastModelByProvider[rememberedProvider] ??
@@ -51,6 +55,7 @@ export function getInitialChatConfig(): ChatSessionConfig {
return {
...DEFAULT_CHAT_CONFIG,
environmentId,
provider,
model,
workspaceRoot: workspaceSelection.lastWorkspace,
@@ -1,11 +1,21 @@
import type { SessionHookEvent } from "@/lib/session-diff";
export type ProcessContext = {
environmentId: string;
workspaceRoot: string;
cwd: string;
homeDir?: string;
platform?: string;
appVersion?: string;
activeEnvironmentId?: string;
remoteEnvironment?: {
id: string;
name?: string;
host?: string;
workspaceRoot?: string;
platform?: string;
arch?: string;
} | null;
};
export type AgentChunkEvent = {
@@ -89,9 +99,11 @@ export type ChatApiResult = {
text: string;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
usage?: {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
totalCost?: number;
};
iterations?: number;
@@ -112,6 +124,7 @@ export type ChatApiResult = {
};
export type ChatSessionCommandResponse = {
environmentId?: string;
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
@@ -39,8 +39,8 @@ let container: HTMLDivElement;
let root: Root;
let current: ChatSessionHook;
function HookHarness() {
current = useChatSession();
function HookHarness({ environmentId = "local" }: { environmentId?: string }) {
current = useChatSession(environmentId);
return null;
}
@@ -70,7 +70,11 @@ beforeEach(async () => {
subscribeMock.mockClear();
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
return {
environmentId: "local",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
};
}
return [];
});
@@ -84,6 +88,29 @@ afterEach(async () => {
});
describe("useChatSession", () => {
it("ignores another environment's question for the same session", async () => {
invokeMock.mockImplementation(async (command: string) =>
command === "chat_session_command" ? { sessionId: "same-id" } : [],
);
await act(async () => current.start(current.config));
const handler = handlerFor("ask_question_requested");
const question = {
sessionId: "same-id",
requestId: "question-id",
question: "Which branch?",
options: [],
createdAt: new Date().toISOString(),
};
await act(async () => {
handler({ ...question, environmentId: "remote" });
});
expect(current.pendingAskQuestions).toEqual([]);
await act(async () => {
handler({ ...question, environmentId: "local" });
});
expect(current.pendingAskQuestions).toHaveLength(1);
});
it("sends first-prompt steering intent without reading a queue snapshot", async () => {
const requests: Record<string, unknown>[] = [];
invokeMock.mockImplementation(
@@ -99,7 +126,11 @@ describe("useChatSession", () => {
requests.length = 0;
await act(async () => current.steerPromptInQueue());
expect(requests).toEqual([
{ action: "steer_prompt", sessionId: "atomic-steer" },
{
action: "steer_prompt",
sessionId: "atomic-steer",
config: { environmentId: "local" },
},
]);
});
@@ -163,7 +194,9 @@ describe("useChatSession", () => {
});
expect(
requests.filter((request) => request.action === "steer_prompt"),
).toEqual([{ action: "steer_prompt", sessionId }]);
).toEqual([
{ action: "steer_prompt", sessionId, config: { environmentId: "local" } },
]);
expect(current.status).toBe("running");
await act(async () => {
activeResponse.resolve({ ok: true });
@@ -227,7 +260,13 @@ describe("useChatSession", () => {
});
expect(
requests.filter((request) => request.action === "steer_prompt"),
).toEqual([{ action: "steer_prompt", sessionId }]);
).toEqual([
{
action: "steer_prompt",
sessionId,
config: { environmentId: "local" },
},
]);
await act(async () => {
activeResponse.resolve({ ok: true });
await activeTask;
@@ -751,12 +790,19 @@ describe("useChatSession", () => {
current.proceedWhileRunning(current.sessionId as string, "call-output"),
);
expect(invokeMock).toHaveBeenCalledWith("proceed_while_running", {
environmentId: "local",
sessionId: current.sessionId,
toolCallId: "call-output",
});
});
it("heals a running attached session with a dead event stream by polling history", async () => {
it.each([
"local",
"remote",
])("heals a running attached session in %s with a dead event stream by polling history", async (environmentId) => {
await act(async () =>
root.render(<HookHarness environmentId={environmentId} />),
);
// Scheduled runs can execute on a host whose live events never reach
// this client; the transcript must still settle without a remount.
const hydratedSessionId = "session-dead-stream";
@@ -768,6 +814,7 @@ describe("useChatSession", () => {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "read_session_messages") {
expect(args?.environmentId).toBe(environmentId);
readCount += 1;
const base = [
{
@@ -792,6 +839,7 @@ describe("useChatSession", () => {
];
}
if (command === "get_discovered_session") {
expect(args?.environmentId).toBe(environmentId);
recordReads += 1;
// Still running on the first poll — the snapshot already
// ends on assistant narration, which must NOT read as
@@ -826,6 +874,7 @@ describe("useChatSession", () => {
try {
await act(async () => {
await current.hydrateSession({
environmentId,
sessionId: hydratedSessionId,
status: "running",
provider: "cline",
@@ -2348,6 +2397,23 @@ describe("useChatSession", () => {
).not.toBeUndefined();
});
it("rejects hydration for a session owned by another environment", async () => {
await expect(
current.hydrateSession({
sessionId: "remote-session",
environmentId: "pi-server",
status: "completed",
provider: "cline",
model: "test-model",
cwd: "/home/pi/project",
workspaceRoot: "/home/pi/project",
startedAt: "2026-07-31T00:00:00.000Z",
}),
).rejects.toThrow("belongs to environment pi-server, not local");
expect(current.sessionId).toBeNull();
expect(current.config.environmentId).toBe("local");
});
it("preserves consecutive queued costs while the preceding turn is persisted", async () => {
type SendResponse = {
ok: true;
@@ -2554,6 +2620,7 @@ describe("useChatSession", () => {
await act(async () => {
await current.hydrateSession({
sessionId: hydratedSessionId,
environmentId: "local",
status: "completed",
provider: "cline",
model: "test-model",
@@ -2569,6 +2636,21 @@ describe("useChatSession", () => {
cacheReadTokens: 8_000,
});
expect(current.summary.totalCostUsd).toBeCloseTo(0.03);
expect(invokeMock).toHaveBeenCalledWith("read_session_messages", {
environmentId: "local",
sessionId: hydratedSessionId,
maxMessages: 800,
});
expect(invokeMock).toHaveBeenCalledWith(
"chat_session_command",
expect.objectContaining({
request: expect.objectContaining({
action: "attach",
config: expect.objectContaining({ environmentId: "local" }),
sessionId: hydratedSessionId,
}),
}),
);
});
it("restores a pending question when switching to its session", async () => {
@@ -2639,6 +2721,7 @@ describe("useChatSession", () => {
expect(current.pendingAskQuestions).toEqual([pendingQuestion]),
);
expect(invokeMock).toHaveBeenCalledWith("poll_ask_questions", {
environmentId: "local",
sessionId: hydratedSessionId,
});
});
@@ -2809,6 +2892,7 @@ describe("useChatSession", () => {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
}
if (command === "read_session_messages") {
expect(args?.environmentId).toBe("local");
return canonicalMessages;
}
if (command === "chat_session_command") {
@@ -3964,18 +4048,26 @@ describe("useChatSession", () => {
it("falls back to process context when the remembered workspace is stale", async () => {
await act(async () => root.unmount());
window.localStorage.setItem(
"cline.code.workspace-selection.v1",
"cline.code.workspace-selection.v2",
JSON.stringify({
lastWorkspace: "/workspace/deleted",
workspaces: ["/workspace/deleted"],
environments: {
local: {
lastWorkspace: "/workspace/deleted",
workspaces: ["/workspace/deleted"],
},
},
}),
);
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
return {
environmentId: "local",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
};
}
if (command === "validate_workspace_directory") {
return { valid: false };
return { environmentId: "local", valid: false };
}
return [];
});
@@ -3987,16 +4079,74 @@ describe("useChatSession", () => {
expect(current.config.cwd).toBe("/workspace/cline");
});
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
environmentId: "local",
path: "/workspace/deleted",
});
});
it("binds process context and remembered workspace to the requested remote environment", async () => {
await act(async () => root.unmount());
window.localStorage.setItem(
"cline.code.workspace-selection.v2",
JSON.stringify({
environments: {
local: {
lastWorkspace: "/Users/local/project",
workspaces: ["/Users/local/project"],
},
"pi-server": {
lastWorkspace: "/home/pi/project",
workspaces: ["/home/pi/project"],
},
},
}),
);
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === "get_process_context") {
expect(args).toEqual({ environmentId: "pi-server" });
return {
environmentId: "pi-server",
activeEnvironmentId: "another-host",
cwd: "/home/pi",
workspaceRoot: "/home/pi",
};
}
if (command === "validate_workspace_directory") {
return { environmentId: "pi-server", valid: true };
}
return [];
},
);
root = createRoot(container);
await act(async () =>
root.render(<HookHarness environmentId="pi-server" />),
);
await vi.waitFor(() => {
expect(current.config).toMatchObject({
environmentId: "pi-server",
cwd: "/home/pi/project",
workspaceRoot: "/home/pi/project",
});
});
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
environmentId: "pi-server",
path: "/home/pi/project",
});
});
it("applies a remembered workspace that becomes available while process context is loading", async () => {
await act(async () => root.unmount());
let resolveContext:
| ((value: { cwd: string; workspaceRoot: string }) => void)
| ((value: {
environmentId: string;
cwd: string;
workspaceRoot: string;
}) => void)
| undefined;
const contextResponse = new Promise<{
environmentId: string;
cwd: string;
workspaceRoot: string;
}>((resolve) => {
@@ -4007,25 +4157,32 @@ describe("useChatSession", () => {
return await contextResponse;
}
if (command === "validate_workspace_directory") {
return { valid: true };
return { environmentId: "local", valid: true };
}
return [];
});
root = createRoot(container);
await act(async () => root.render(<HookHarness />));
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("get_process_context");
expect(invokeMock).toHaveBeenCalledWith("get_process_context", {
environmentId: "local",
});
});
window.localStorage.setItem(
"cline.code.workspace-selection.v1",
"cline.code.workspace-selection.v2",
JSON.stringify({
lastWorkspace: "/workspace/remembered",
workspaces: ["/workspace/remembered"],
environments: {
local: {
lastWorkspace: "/workspace/remembered",
workspaces: ["/workspace/remembered"],
},
},
}),
);
await act(async () => {
resolveContext?.({
environmentId: "local",
cwd: "/workspace/default",
workspaceRoot: "/workspace/default",
});
@@ -4037,6 +4194,7 @@ describe("useChatSession", () => {
expect(current.config.cwd).toBe("/workspace/remembered");
});
expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", {
environmentId: "local",
path: "/workspace/remembered",
});
});
@@ -4044,9 +4202,14 @@ describe("useChatSession", () => {
it("preserves a workspace selected while process context is loading", async () => {
await act(async () => root.unmount());
let resolveContext:
| ((value: { cwd: string; workspaceRoot: string }) => void)
| ((value: {
environmentId: string;
cwd: string;
workspaceRoot: string;
}) => void)
| undefined;
const contextResponse = new Promise<{
environmentId: string;
cwd: string;
workspaceRoot: string;
}>((resolve) => {
@@ -4064,6 +4227,7 @@ describe("useChatSession", () => {
await act(async () => {
resolveContext?.({
environmentId: "local",
cwd: "/workspace/default",
workspaceRoot: "/workspace/default",
});
@@ -4076,9 +4240,14 @@ describe("useChatSession", () => {
it("preserves a chat selection while process context is loading", async () => {
await act(async () => root.unmount());
let resolveContext:
| ((value: { cwd: string; workspaceRoot: string }) => void)
| ((value: {
environmentId: string;
cwd: string;
workspaceRoot: string;
}) => void)
| undefined;
const contextResponse = new Promise<{
environmentId: string;
cwd: string;
workspaceRoot: string;
}>((resolve) => {
@@ -4096,6 +4265,7 @@ describe("useChatSession", () => {
await act(async () => {
resolveContext?.({
environmentId: "local",
cwd: "/workspace/default",
workspaceRoot: "/workspace/default",
});
@@ -57,8 +57,10 @@ import type {
SessionHistoryItem,
SessionHistoryStatus,
} from "@/lib/session-history";
import { eventEnvironmentId } from "@/lib/session-identity";
import { readImportedHistorySummaryActivity } from "@/lib/session-import";
import {
LOCAL_WORKSPACE_ENVIRONMENT_ID,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
registerHostHomeDirectory,
@@ -376,11 +378,20 @@ function dispatchCoreLog(chunk: string): void {
// Hook
// ---------------------------------------------------------------------------
export function useChatSession() {
export function useChatSession(environmentId: string) {
const subscribeToEnvironment = useCallback(
(name: string, listener: (payload: unknown) => void) =>
desktopClient.subscribe(name, (payload) => {
if (eventEnvironmentId(payload) === environmentId) listener(payload);
}),
[environmentId],
);
const [sessionId, setSessionId] = useState<string | null>(null);
const [status, setStatus] = useState<ChatSessionStatus>("idle");
const [isHydratingSession, setIsHydratingSession] = useState(false);
const [config, setConfig] = useState<ChatSessionConfig>(getInitialChatConfig);
const [config, setConfig] = useState<ChatSessionConfig>(() =>
getInitialChatConfig(environmentId),
);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [rawTranscript, setRawTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -750,6 +761,7 @@ export function useChatSession() {
turnEndReconcileTimerRef.current = null;
void desktopClient
.invoke<ChatMessage[]>("read_session_messages", {
environmentId,
sessionId: sid,
maxMessages: MAX_MESSAGES,
})
@@ -778,7 +790,7 @@ export function useChatSession() {
});
}, TURN_END_RECONCILE_DELAY_MS);
},
[applyCanonicalHistory],
[applyCanonicalHistory, environmentId],
);
useEffect(() => {
@@ -791,20 +803,34 @@ export function useChatSession() {
// ---- Data fetching ----
const postSession = useCallback(async (body: Record<string, unknown>) => {
const request = { request: body };
if (body.action === "send") {
const postSession = useCallback(
async (body: Record<string, unknown>) => {
const bodyConfig =
body.config &&
typeof body.config === "object" &&
!Array.isArray(body.config)
? (body.config as Record<string, unknown>)
: {};
const request = {
request: {
...body,
config: { ...bodyConfig, environmentId },
},
};
if (body.action === "send") {
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
{ timeoutMs: null },
);
}
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
{ timeoutMs: null },
);
}
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
);
}, []);
},
[environmentId],
);
// Confirms a "still running because prompts are queued" status against the
// server. The local queue snapshot can be stale when the dequeue
@@ -880,7 +906,7 @@ export function useChatSession() {
try {
const events = await desktopClient.invoke<ChatSessionHookEvent[]>(
"read_session_hooks",
{ sessionId: targetSessionId, limit: MAX_MESSAGES },
{ environmentId, sessionId: targetSessionId, limit: MAX_MESSAGES },
);
const diffState = buildSessionDiffState(events, sessionDiffCwd);
setFileDiffs(diffState.fileDiffs);
@@ -895,7 +921,7 @@ export function useChatSession() {
// Ignore in non-Tauri mode.
}
},
[sessionDiffCwd],
[environmentId, sessionDiffCwd],
);
// ---- Message helpers ----
@@ -1124,19 +1150,33 @@ export function useChatSession() {
try {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
{ environmentId },
);
if (ctx.environmentId !== environmentId) {
return;
}
if (ctx.homeDir) {
registerHostHomeDirectory(ctx.homeDir);
}
const rememberedWorkspace =
readWorkspaceSelectionFromWindow().lastWorkspace;
readWorkspaceSelectionFromWindow(environmentId).lastWorkspace;
const validation = rememberedWorkspace
? await desktopClient
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
path: rememberedWorkspace,
})
.invoke<{ environmentId: string; valid: boolean }>(
"validate_workspace_directory",
{
environmentId,
path: rememberedWorkspace,
},
)
.catch(() => ({ valid: false }))
: { valid: false };
if (
"environmentId" in validation &&
validation.environmentId !== environmentId
) {
return;
}
if (requestId !== workspaceSelectionRequestRef.current) {
return;
}
@@ -1154,6 +1194,7 @@ export function useChatSession() {
: ctx.workspaceRoot || ctx.cwd;
return {
...prev,
environmentId,
workspaceRoot: workspace,
cwd: workspace,
};
@@ -1161,7 +1202,7 @@ export function useChatSession() {
} catch {
// Ignore in non-Tauri mode.
}
}, []);
}, [environmentId]);
useEffect(() => {
void applyProcessContext();
@@ -1242,6 +1283,7 @@ export function useChatSession() {
void desktopClient
.invoke<ToolApprovalRequestItem[]>("poll_tool_approvals", {
environmentId,
sessionId: activeSessionId,
limit: 20,
})
@@ -1254,6 +1296,7 @@ export function useChatSession() {
void desktopClient
.invoke<AskQuestionRequestItem[]>("poll_ask_questions", {
environmentId,
sessionId: activeSessionId,
})
.then((pending) => {
@@ -1263,7 +1306,7 @@ export function useChatSession() {
})
.catch(() => {});
const unsubscribe = desktopClient.subscribe(
const unsubscribe = subscribeToEnvironment(
"tool_approval_state",
(payload) => {
if (!payload || typeof payload !== "object") return;
@@ -1282,10 +1325,10 @@ export function useChatSession() {
cancelled = true;
unsubscribe();
};
}, [sessionId]);
}, [environmentId, sessionId, subscribeToEnvironment]);
useEffect(() => {
return desktopClient.subscribe("ask_question_requested", (payload) => {
return subscribeToEnvironment("ask_question_requested", (payload) => {
if (!payload || typeof payload !== "object") return;
const item = payload as AskQuestionRequestItem;
if (
@@ -1303,10 +1346,10 @@ export function useChatSession() {
return [...prev, item];
});
});
}, []);
}, [subscribeToEnvironment]);
useEffect(() => {
return desktopClient.subscribe("ask_question_answered", (payload) => {
return subscribeToEnvironment("ask_question_answered", (payload) => {
if (!payload || typeof payload !== "object") return;
const requestId = String(
(payload as { requestId?: unknown }).requestId ?? "",
@@ -1316,10 +1359,10 @@ export function useChatSession() {
prev.filter((item) => item.requestId !== requestId),
);
});
}, []);
}, [subscribeToEnvironment]);
useEffect(() => {
return desktopClient.subscribe("ask_question_cancelled", (payload) => {
return subscribeToEnvironment("ask_question_cancelled", (payload) => {
if (!payload || typeof payload !== "object") return;
const requestId = String(
(payload as { requestId?: unknown }).requestId ?? "",
@@ -1329,10 +1372,10 @@ export function useChatSession() {
prev.filter((item) => item.requestId !== requestId),
);
});
}, []);
}, [subscribeToEnvironment]);
useEffect(() => {
return desktopClient.subscribe("prompts_in_queue_state", (payload) => {
return subscribeToEnvironment("prompts_in_queue_state", (payload) => {
if (!payload || typeof payload !== "object") return;
const record = payload as {
sessionId?: string;
@@ -1341,7 +1384,7 @@ export function useChatSession() {
if (record.sessionId !== activeSessionIdRef.current) return;
setPromptsInQueue(Array.isArray(record.items) ? record.items : []);
});
}, [setPromptsInQueue]);
}, [setPromptsInQueue, subscribeToEnvironment]);
// ---- Incoming chunk handler ----
@@ -1872,7 +1915,7 @@ export function useChatSession() {
setChatTransportError(desktopClient.getTransportError());
},
);
const unsubscribeEvents = desktopClient.subscribe(
const unsubscribeEvents = subscribeToEnvironment(
"chat_event",
(payload) => {
if (payload && typeof payload === "object") {
@@ -1884,10 +1927,10 @@ export function useChatSession() {
unsubscribeTransport();
unsubscribeEvents();
};
}, [handleIncomingChunk]);
}, [handleIncomingChunk, subscribeToEnvironment]);
useEffect(() => {
const unsubscribeStatus = desktopClient.subscribe(
const unsubscribeStatus = subscribeToEnvironment(
"chat_session_status",
(payload) => {
if (!payload || typeof payload !== "object") {
@@ -1939,7 +1982,7 @@ export function useChatSession() {
setStatus(nextStatus as ChatSessionStatus);
},
);
const unsubscribeEnded = desktopClient.subscribe(
const unsubscribeEnded = subscribeToEnvironment(
"chat_session_ended",
(payload) => {
if (!payload || typeof payload !== "object") {
@@ -1969,7 +2012,7 @@ export function useChatSession() {
unsubscribeStatus();
unsubscribeEnded();
};
}, [clearLiveToolRefs, finalizeSettledTurn]);
}, [clearLiveToolRefs, finalizeSettledTurn, subscribeToEnvironment]);
// ---- Stale-stream fallback for attached sessions ----
// Scheduled/automation runs execute on a session host whose events are
@@ -2022,12 +2065,14 @@ export function useChatSession() {
const [historyMessages, record] = await Promise.all([
desktopClient
.invoke<ChatMessage[]>("read_session_messages", {
environmentId,
sessionId,
maxMessages: MAX_MESSAGES,
})
.catch(() => null),
desktopClient
.invoke<{ status?: string } | null>("get_discovered_session", {
environmentId,
sessionId,
})
.catch(() => null),
@@ -2089,7 +2134,7 @@ export function useChatSession() {
cancelled = true;
window.clearInterval(interval);
};
}, [hydratedHistorySessionId, sessionId, status]);
}, [hydratedHistorySessionId, sessionId, status, environmentId]);
// ---- Shared: start a new session via RPC ----
@@ -2098,16 +2143,25 @@ export function useChatSession() {
validatedConfig: ChatSessionConfig,
options: { preserveStatus?: boolean } = {},
): Promise<string> => {
const boundConfig = { ...validatedConfig, environmentId };
const payload = await postSession({
action: "start",
config: validatedConfig,
config: boundConfig,
});
if (
payload.environmentId !== undefined &&
payload.environmentId !== environmentId
) {
throw new Error(
`Session started in environment ${payload.environmentId}, not ${environmentId}.`,
);
}
const id = payload.sessionId;
if (!id) throw new Error("Missing session id from server");
const workspaceRoot =
payload.workspaceRoot?.trim() || validatedConfig.workspaceRoot.trim();
payload.workspaceRoot?.trim() || boundConfig.workspaceRoot.trim();
const cwd =
payload.cwd?.trim() || validatedConfig.cwd?.trim() || workspaceRoot;
payload.cwd?.trim() || boundConfig.cwd?.trim() || workspaceRoot;
if (!workspaceRoot || !cwd) {
throw new Error("Missing resolved workspace from server");
}
@@ -2120,21 +2174,21 @@ export function useChatSession() {
}
workspaceSelectionRequestRef.current += 1;
setConfig({
...validatedConfig,
...boundConfig,
cwd,
workspaceRoot,
});
setHydratedHistorySessionId(null);
return id;
},
[postSession],
[environmentId, postSession],
);
// ---- Actions ----
const start = useCallback(
async (nextConfig: ChatSessionConfig) => {
const validation = validateConfig(nextConfig);
const validation = validateConfig({ ...nextConfig, environmentId });
if (!validation.parsed) {
setErrorState(validation.error);
return;
@@ -2171,6 +2225,7 @@ export function useChatSession() {
addMessage,
clearAbortFallbackTimeout,
discardPendingStream,
environmentId,
resetCounters,
setErrorState,
startSession,
@@ -2193,7 +2248,7 @@ export function useChatSession() {
const pendingSessionStart = sessionStartPromiseRef.current;
let activeSessionId = sessionId ?? activeSessionIdRef.current;
const validation = validateConfig(config);
const validation = validateConfig({ ...config, environmentId });
if (!validation.parsed) {
setErrorState(validation.error, activeSessionId);
return false;
@@ -2652,7 +2707,11 @@ export function useChatSession() {
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: activeSessionId, maxMessages: MAX_MESSAGES },
{
environmentId,
sessionId: activeSessionId,
maxMessages: MAX_MESSAGES,
},
);
if (historyMessages.length > 0 && !newerTurnOwnsTranscript()) {
applyCanonicalHistory(activeSessionId, historyMessages);
@@ -2676,7 +2735,11 @@ export function useChatSession() {
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: activeSessionId, maxMessages: MAX_MESSAGES },
{
environmentId,
sessionId: activeSessionId,
maxMessages: MAX_MESSAGES,
},
);
const hasCanonicalAssistantTurn = historyMessages.some(
(message) => message.role === "assistant",
@@ -2879,6 +2942,7 @@ export function useChatSession() {
clearLiveToolRefs,
config,
finalizeSettledTurn,
environmentId,
hydratedHistorySessionId,
materializeToolMessagesFromResult,
refreshSessionDiffSummary,
@@ -2897,6 +2961,7 @@ export function useChatSession() {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId) return;
await desktopClient.invoke("respond_tool_approval", {
environmentId,
sessionId: activeSessionId,
requestId,
approved,
@@ -2908,7 +2973,7 @@ export function useChatSession() {
prev.filter((item) => item.requestId !== requestId),
);
},
[],
[environmentId],
);
const approveToolApproval = useCallback(
@@ -2924,6 +2989,7 @@ export function useChatSession() {
const answerAskQuestion = useCallback(
async (requestId: string, answer: string) => {
await desktopClient.invoke("respond_ask_question", {
environmentId,
requestId,
answer,
});
@@ -2931,7 +2997,7 @@ export function useChatSession() {
prev.filter((item) => item.requestId !== requestId),
);
},
[],
[environmentId],
);
const restoreCheckpoint = useCallback(
@@ -2968,13 +3034,13 @@ export function useChatSession() {
throw new Error("Checkpoint restore did not return a new session id");
}
const nextMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{
sessionId: nextSessionId,
maxMessages: MAX_MESSAGES,
},
);
const nextMessages = Array.isArray(payload.messages)
? (payload.messages as ChatMessage[])
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
environmentId,
sessionId: nextSessionId,
maxMessages: MAX_MESSAGES,
});
setSessionId(nextSessionId);
activeSessionIdRef.current = nextSessionId;
@@ -2989,6 +3055,7 @@ export function useChatSession() {
clearAbortFallbackTimeout,
clearLiveToolRefs,
config,
environmentId,
postSession,
refreshPromptsInQueue,
refreshSessionDiffSummary,
@@ -3046,6 +3113,7 @@ export function useChatSession() {
const response = await desktopClient.invoke<{ detachedCount?: number }>(
"proceed_while_running",
{
environmentId,
sessionId: normalizedSessionId,
...(toolCallId ? { toolCallId } : {}),
},
@@ -3054,7 +3122,7 @@ export function useChatSession() {
throw new Error("The command finished before it could be detached.");
}
},
[],
[environmentId],
);
const reset = useCallback(async () => {
@@ -3117,6 +3185,14 @@ export function useChatSession() {
const hydrateSession = useCallback(
async (session: SessionHistoryItem) => {
if (
(session.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID) !==
environmentId
) {
throw new Error(
`Session ${session.sessionId} belongs to environment ${session.environmentId}, not ${environmentId}.`,
);
}
const requestId = hydrationRequestIdRef.current + 1;
const hydrationStartedAt = Date.now();
hydrationRequestIdRef.current = requestId;
@@ -3129,6 +3205,7 @@ export function useChatSession() {
setSessionId(session.sessionId);
setConfig((prev) => ({
...prev,
environmentId,
sessionId: session.sessionId,
provider: session.provider || prev.provider,
model: session.model || prev.model,
@@ -3180,7 +3257,11 @@ export function useChatSession() {
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: session.sessionId, maxMessages: MAX_MESSAGES },
{
environmentId,
sessionId: session.sessionId,
maxMessages: MAX_MESSAGES,
},
);
if (hydrationRequestIdRef.current !== requestId) return;
if (historyMessages.length > 0) {
@@ -3198,11 +3279,13 @@ export function useChatSession() {
cwd?: string;
workspaceRoot?: string;
prompt?: string;
environmentId?: string;
}>("chat_session_command", {
request: {
action: "attach",
sessionId: session.sessionId,
config: {
environmentId,
provider: session.provider,
model: session.model,
cwd: session.cwd,
@@ -3218,8 +3301,17 @@ export function useChatSession() {
return undefined;
});
if (hydrationRequestIdRef.current !== requestId) return;
if (
attached?.environmentId !== undefined &&
attached.environmentId !== environmentId
) {
throw new Error(
`Session ${session.sessionId} attached to environment ${attached.environmentId}, not ${environmentId}.`,
);
}
setConfig((prev) => ({
...prev,
environmentId,
sessionId: session.sessionId,
provider: attached?.provider || session.provider || prev.provider,
model: attached?.model || session.model || prev.model,
@@ -3276,6 +3368,7 @@ export function useChatSession() {
clearAbortFallbackTimeout,
clearLiveToolRefs,
discardPendingStream,
environmentId,
refreshPromptsInQueue,
refreshSessionDiffSummary,
resetStreamDedupe,
@@ -3317,16 +3410,16 @@ export function useChatSession() {
typeof payload.forkedFromSessionId === "string"
? payload.forkedFromSessionId
: activeSessionId;
const nextMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
},
);
const nextMessages = Array.isArray(payload.messages)
? (payload.messages as ChatMessage[])
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
environmentId,
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
});
return { newSessionId, forkedFromSessionId, messages: nextMessages };
},
[config, postSession, status],
[config, environmentId, postSession, status],
);
const steerPromptInQueue = useCallback(
@@ -18,15 +18,22 @@ let root: Root;
let current: SessionAgentsHook;
function HookHarness({
environmentId = "local",
sessionId,
panelOpen = false,
sessionActive = false,
}: {
environmentId?: string;
sessionId: string | null;
panelOpen?: boolean;
sessionActive?: boolean;
}) {
current = useSessionAgents({ sessionId, panelOpen, sessionActive });
current = useSessionAgents({
environmentId,
sessionId,
panelOpen,
sessionActive,
});
return null;
}
@@ -76,6 +83,7 @@ describe("useSessionAgents", () => {
invokeMock.mockResolvedValue([agentRow("a", "one")]);
await render({ sessionId: "a" });
expect(invokeMock).toHaveBeenCalledWith("list_session_agents", {
environmentId: "local",
sessionId: "a",
});
expect(current.agents.map((agent) => agent.agentId)).toEqual(["one"]);
@@ -90,6 +98,7 @@ describe("useSessionAgents", () => {
invokeMock.mockResolvedValue([agentRow("a", "aged-out")]);
await render({ sessionId: "a", panelOpen: false, sessionActive: false });
expect(invokeMock).toHaveBeenCalledWith("list_session_agents", {
environmentId: "local",
sessionId: "a",
});
expect(current.agents.map((agent) => agent.agentId)).toEqual(["aged-out"]);
@@ -12,6 +12,7 @@ const ACTIVE_POLL_INTERVAL_MS = 2500;
* structurally unreadable rather than something a reset has to remember to clear.
*/
type RosterState = {
environmentId: string | null;
sessionId: string | null;
entries: SessionAgentEntry[];
loading: boolean;
@@ -19,6 +20,7 @@ type RosterState = {
};
const EMPTY_ROSTER: RosterState = {
environmentId: null,
sessionId: null,
entries: [],
loading: false,
@@ -81,10 +83,12 @@ function parseAgentEntries(value: unknown): SessionAgentEntry[] {
* part worth gating, since it is the only part that costs anything repeatedly.
*/
export function useSessionAgents({
environmentId,
sessionId,
sessionActive,
panelOpen = false,
}: {
environmentId: string;
sessionId: string | null;
sessionActive: boolean;
/** Re-reads when the roster is put on screen; never gates the first read. */
@@ -106,9 +110,11 @@ export function useSessionAgents({
const seq = requestSeqRef.current;
if (!options?.quiet) {
setRoster((prev) =>
prev.environmentId === environmentId &&
prev.sessionId === targetSessionId
? { ...prev, loading: true }
: {
environmentId,
sessionId: targetSessionId,
entries: [],
loading: true,
@@ -119,12 +125,13 @@ export function useSessionAgents({
try {
const result = await desktopClient.invoke<unknown>(
"list_session_agents",
{ sessionId: targetSessionId },
{ environmentId, sessionId: targetSessionId },
);
if (requestSeqRef.current !== seq) {
return;
}
setRoster({
environmentId,
sessionId: targetSessionId,
entries: parseAgentEntries(result),
loading: false,
@@ -137,6 +144,7 @@ export function useSessionAgents({
const message =
err instanceof Error ? err.message : "Could not load agents.";
setRoster((prev) => ({
environmentId,
sessionId: targetSessionId,
// A failed read means this attempt learned nothing — not that the
// agents are gone. Discarding them would blank a list that had
@@ -148,13 +156,17 @@ export function useSessionAgents({
//
// Entries from a *different* session are still dropped, so a failure
// cannot make the previous session's agents surface under this one.
entries: prev.sessionId === targetSessionId ? prev.entries : [],
entries:
prev.environmentId === environmentId &&
prev.sessionId === targetSessionId
? prev.entries
: [],
loading: false,
error: message,
}));
}
},
[],
[environmentId],
);
// A roster is only ever read back for the session it was fetched for, so
@@ -163,7 +175,10 @@ export function useSessionAgents({
// because mergeAgentActivity prefers a non-empty roster over the
// message-derived tally, so a leaked one would render as phantom agents
// belonging to the new session.
const isCurrent = sessionId !== null && roster.sessionId === sessionId;
const isCurrent =
sessionId !== null &&
roster.environmentId === environmentId &&
roster.sessionId === sessionId;
const agents = isCurrent ? roster.entries : NO_AGENTS;
const loading = isCurrent && roster.loading;
const error = isCurrent ? roster.error : null;
@@ -3,6 +3,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sessionKey } from "../lib/session-identity";
import { useSessionHistory } from "./use-session-history";
const { invokeMock, subscribeMock } = vi.hoisted(() => ({
@@ -84,6 +85,44 @@ afterEach(async () => {
});
describe("useSessionHistory session mapping", () => {
it("keeps duplicate IDs visible and renames only the selected environment", async () => {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve(
["local", "remote"].map((environmentId) => ({
...sessionRow("same-id"),
environmentId,
metadata: { title: environmentId },
})),
);
await Promise.resolve();
});
expect(current.threads).toHaveLength(2);
const remoteKey = sessionKey({
sessionId: "same-id",
environmentId: "remote",
});
await act(async () => {
await current.renameThread(remoteKey, "Renamed remote");
});
expect(invokeMock).toHaveBeenCalledWith("update_chat_session_title", {
sessionId: "same-id",
environmentId: "remote",
title: "Renamed remote",
});
expect(
current.threads.find(
(thread) => thread.id === sessionKey({ sessionId: "same-id" }),
)?.title,
).toBe("local");
expect(
current.threads.find((thread) => thread.id === remoteKey)?.title,
).toBe("Renamed remote");
});
it("maps nested Core schedule provenance onto sidebar threads", async () => {
await act(async () => {
root.render(<HookHarness />);
@@ -112,10 +151,15 @@ describe("useSessionHistory session mapping", () => {
});
expect(
current.threads.find((thread) => thread.id === "scheduled-session"),
current.threads.find(
(thread) =>
thread.id === sessionKey({ sessionId: "scheduled-session" }),
),
).toMatchObject({ source: "core", isScheduled: true });
expect(
current.threads.find((thread) => thread.id === "regular-session"),
current.threads.find(
(thread) => thread.id === sessionKey({ sessionId: "regular-session" }),
),
).toMatchObject({ source: "core", isScheduled: false });
});
@@ -168,14 +212,18 @@ describe("useSessionHistory session mapping", () => {
// The executions list also supplies the schedule identity the session
// record itself lacks, so the sidebar can group it with its siblings.
expect(
current.threads.find((thread) => thread.id === "cron-session"),
current.threads.find(
(thread) => thread.id === sessionKey({ sessionId: "cron-session" }),
),
).toMatchObject({
isScheduled: true,
scheduleId: "sched_daily",
scheduleName: "Daily report",
});
expect(
current.threads.find((thread) => thread.id === "regular-session"),
current.threads.find(
(thread) => thread.id === sessionKey({ sessionId: "regular-session" }),
),
).toMatchObject({ isScheduled: false });
});
@@ -206,7 +254,9 @@ describe("useSessionHistory session mapping", () => {
});
expect(
current.threads.find((thread) => thread.id === "run-session"),
current.threads.find(
(thread) => thread.id === sessionKey({ sessionId: "run-session" }),
),
).toMatchObject({
isScheduled: true,
startedAt: "2026-07-20T10:00:00.000Z",
@@ -630,7 +680,9 @@ describe("useSessionHistory usage hydration", () => {
await renderWithRows(12);
expect(current.threads.map((thread) => thread.id)).toEqual(
Array.from({ length: 12 }, (_, index) => `session-${index}`),
Array.from({ length: 12 }, (_, index) =>
sessionKey({ sessionId: `session-${index}` }),
),
);
await flush(800);
@@ -663,7 +715,11 @@ describe("useSessionHistory usage hydration", () => {
// The second page comes into view: only the rows it asks for are read.
await act(async () => {
current.requestUsage(["session-11", " ", "not-a-session"]);
current.requestUsage(
["session-11", " ", "not-a-session"].map((sessionId) =>
sessionKey({ sessionId }),
),
);
});
await flush(800);
await settle();
@@ -676,7 +732,11 @@ describe("useSessionHistory usage hydration", () => {
// Asking again for rows that already have usage is a no-op.
await act(async () => {
current.requestUsage(["session-0", "session-11"]);
current.requestUsage(
["session-0", "session-11"].map((sessionId) =>
sessionKey({ sessionId }),
),
);
});
await flush(800);
await settle();
@@ -744,7 +804,9 @@ describe("useSessionHistory usage hydration", () => {
// A page request restarts the effect while four reads are pending. The
// restarted run must not add four reads of its own on top of them.
await act(async () => {
current.requestUsage(["session-11"]);
current.requestUsage(
["session-11"].map((sessionId) => sessionKey({ sessionId })),
);
});
await flush(800);
await settle();
@@ -883,7 +945,9 @@ describe("useSessionHistory usage hydration", () => {
expect(readsOfRunning()).toBe(0);
await act(async () => {
current.requestUsage(["session-11"]);
current.requestUsage(
["session-11"].map((sessionId) => sessionKey({ sessionId })),
);
});
await flush(800);
await settle();
@@ -903,7 +967,7 @@ describe("useSessionHistory usage hydration", () => {
// The view pages away or unmounts: the next refresh leaves it alone,
// and the completed rows it already hydrated are not read again either.
await act(async () => {
current.requestUsage([]);
current.requestUsage([].map((sessionId) => sessionKey({ sessionId })));
});
await flush(12_000);
await flush();
@@ -19,6 +19,8 @@ import {
getSessionSource,
PINNED_METADATA_KEY,
} from "@/lib/session-history";
import { eventEnvironmentId, sessionKey } from "@/lib/session-identity";
import { LOCAL_WORKSPACE_ENVIRONMENT_ID } from "@/lib/workspace-paths";
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
status: string;
@@ -82,20 +84,24 @@ type SessionUsage = {
};
type SessionTitleUpdatedEvent = CustomEvent<{
environmentId?: string;
sessionId: string;
title: string;
}>;
type SessionDeletedEvent = CustomEvent<{
environmentId?: string;
sessionId: string;
}>;
type SidecarSessionStateEvent = {
environmentId?: string;
sessionId?: string;
status?: string;
};
type SidecarChatEvent = {
environmentId?: string;
sessionId?: string;
stream?: string;
};
@@ -108,10 +114,11 @@ export type SessionPendingAction = {
export type UseSessionHistoryOptions = {
activeSessionId?: string | null;
onOpenSession?: (session: SessionHistoryItem) => void;
onDeleteSession?: (sessionId: string) => void;
onDeleteSession?: (sessionId: string, environmentId: string) => void;
onUpdateSessionMetadata?: (
sessionId: string,
metadata: SessionMetadata,
environmentId?: string,
) => void;
};
@@ -281,7 +288,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
const workspacePath = (session.workspaceRoot || session.cwd).trim();
const schedule = getSessionMetadataSchedule(session.metadata);
return {
id: session.sessionId,
id: sessionKey(session),
title: toTitle(session),
source: getSessionSource(session) || undefined,
codebase: basenamePath(workspacePath),
@@ -417,6 +424,7 @@ function areSessionsEquivalent(
getSessionMetadataSchedule(a.metadata),
getSessionMetadataSchedule(b.metadata),
) ||
a.environmentId !== b.environmentId ||
a.workspaceRoot !== b.workspaceRoot ||
a.cwd !== b.cwd ||
a.provider !== b.provider ||
@@ -491,7 +499,7 @@ function updateSessionById(
): SessionHistoryItem[] {
let changed = false;
const next = current.map((session) => {
if (session.sessionId !== sessionId) {
if (sessionKey(session) !== sessionId) {
return session;
}
const updated = updater(session);
@@ -511,10 +519,10 @@ function mergeDiscoveredSessions(
return discovered;
}
const currentById = new Map(
current.map((session) => [session.sessionId, session]),
current.map((session) => [sessionKey(session), session]),
);
return discovered.map((session) => {
const existing = currentById.get(session.sessionId);
const existing = currentById.get(sessionKey(session));
if (!existing) {
return session;
}
@@ -682,7 +690,7 @@ export function useSessionHistory({
typeof execution?.scheduleId === "string"
? execution.scheduleId.trim()
: "";
links.set(sessionId, {
links.set(sessionKey({ sessionId }), {
...(scheduleId ? { scheduleId } : {}),
...(scheduleId && scheduleNames.has(scheduleId)
? { scheduleName: scheduleNames.get(scheduleId) }
@@ -789,7 +797,7 @@ export function useSessionHistory({
const mapped = mergedSessions.map(toThread);
const metadataTitleById = new Map(
mergedSessions.map((session) => [
session.sessionId,
sessionKey(session),
getSessionMetadataTitle(session.metadata),
]),
);
@@ -933,18 +941,18 @@ export function useSessionHistory({
// The active session is skipped: its transcript is still being written
// and the chat tracks its usage live.
const inactiveSessions = sessions.filter(
(session) => session.sessionId !== activeSessionId,
(session) => sessionKey(session) !== activeSessionId,
);
const targets = inactiveSessions.slice(0, USAGE_HYDRATION_WINDOW);
if (requestedUsageIds.size > 0) {
const queued = new Set(targets.map((session) => session.sessionId));
const queued = new Set(targets.map(sessionKey));
for (const session of inactiveSessions) {
if (
requestedUsageIds.has(session.sessionId) &&
!queued.has(session.sessionId)
requestedUsageIds.has(sessionKey(session)) &&
!queued.has(sessionKey(session))
) {
targets.push(session);
queued.add(session.sessionId);
queued.add(sessionKey(session));
}
}
}
@@ -959,7 +967,7 @@ export function useSessionHistory({
const usageFetchVerdict = (
session: SessionHistoryItem,
): "fetch" | "defer" | "skip" => {
const sessionId = session.sessionId;
const sessionId = sessionKey(session);
if (!sessionId) {
return "skip";
}
@@ -975,11 +983,12 @@ export function useSessionHistory({
};
const startUsageFetch = (session: SessionHistoryItem): void => {
const sessionId = session.sessionId;
const sessionId = sessionKey(session);
usageLoadingRef.current.set(sessionId, session.status);
void desktopClient
.invoke<SessionMessage[]>("read_session_messages", {
sessionId,
environmentId: session.environmentId,
sessionId: session.sessionId,
maxMessages: 1200,
})
.then(async (sessionMessages): Promise<SessionUsage> => {
@@ -988,7 +997,8 @@ export function useSessionHistory({
const events = await desktopClient.invoke<SessionHookEvent[]>(
"read_session_hooks",
{
sessionId,
environmentId: session.environmentId,
sessionId: session.sessionId,
limit: 1200,
},
);
@@ -1108,7 +1118,9 @@ export function useSessionHistory({
useEffect(() => {
const handleTitleUpdated = (event: Event) => {
const detail = (event as SessionTitleUpdatedEvent).detail;
const sessionId = detail?.sessionId?.trim();
const sessionId = detail?.sessionId?.trim()
? sessionKey(detail)
: undefined;
if (!sessionId) {
return;
}
@@ -1132,7 +1144,9 @@ export function useSessionHistory({
const handleSessionDeleted = (event: Event) => {
const detail = (event as SessionDeletedEvent).detail;
const sessionId = detail?.sessionId?.trim();
const sessionId = detail?.sessionId?.trim()
? sessionKey(detail)
: undefined;
if (!sessionId) {
return;
}
@@ -1144,7 +1158,7 @@ export function useSessionHistory({
usageByIdRef.current.delete(sessionId);
messageHydratedStatusRef.current.delete(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
current.filter((session) => sessionKey(session) !== sessionId),
);
setThreads((current) =>
current.filter((thread) => thread.id !== sessionId),
@@ -1175,7 +1189,7 @@ export function useSessionHistory({
}
handleSessionDeleted(
new CustomEvent("cline:session-deleted", {
detail: { sessionId },
detail: { sessionId, environmentId: eventEnvironmentId(payload) },
}),
);
},
@@ -1187,12 +1201,17 @@ export function useSessionHistory({
return;
}
const record = payload as SidecarSessionStateEvent;
const sessionId = record.sessionId?.trim();
const sessionId = record.sessionId?.trim()
? sessionKey({
sessionId: record.sessionId,
environmentId: record.environmentId,
})
: undefined;
if (!sessionId) {
return;
}
const known = sessionsRef.current.some(
(session) => session.sessionId === sessionId,
(session) => sessionKey(session) === sessionId,
);
const status = normalizeDiscoveredStatus(record.status);
if (!known) {
@@ -1222,7 +1241,10 @@ export function useSessionHistory({
scheduleRefresh(HISTORY_TERMINAL_REFRESH_DELAY_MS, {
force: true,
});
const sessionId = record.sessionId.trim();
const sessionId = sessionKey({
sessionId: record.sessionId.trim(),
environmentId: record.environmentId,
});
if (sessionId !== activeSessionId) {
setUnreadSessionIds((current) => {
const next = new Set(current);
@@ -1254,12 +1276,17 @@ export function useSessionHistory({
return;
}
const record = payload as SidecarChatEvent;
const sessionId = record.sessionId?.trim();
const sessionId = record.sessionId?.trim()
? sessionKey({
sessionId: record.sessionId,
environmentId: record.environmentId,
})
: undefined;
if (!sessionId) {
return;
}
const known = sessionsRef.current.some(
(session) => session.sessionId === sessionId,
(session) => sessionKey(session) === sessionId,
);
if (!known) {
scheduleRefresh(HISTORY_EVENT_REFRESH_DELAY_MS);
@@ -1292,7 +1319,7 @@ export function useSessionHistory({
useEffect(() => {
const recent = sessions
.filter((session) => session.sessionId !== activeSessionId)
.filter((session) => sessionKey(session) !== activeSessionId)
.slice(0, 4);
let cancelled = false;
const timer = window.setTimeout(() => {
@@ -1300,7 +1327,7 @@ export function useSessionHistory({
if (cancelled) {
return;
}
const sessionId = session.sessionId;
const sessionId = sessionKey(session);
if (!sessionId) {
continue;
}
@@ -1330,7 +1357,8 @@ export function useSessionHistory({
titleLoadingRef.current.add(sessionId);
void desktopClient
.invoke<SessionMessage[]>("read_session_messages", {
sessionId,
environmentId: session.environmentId,
sessionId: session.sessionId,
maxMessages: 80,
})
.then((messages) => {
@@ -1380,7 +1408,7 @@ export function useSessionHistory({
const getSessionByThreadId = useCallback(
(threadId: string) =>
sessionsRef.current.find((session) => session.sessionId === threadId),
sessionsRef.current.find((session) => sessionKey(session) === threadId),
[],
);
@@ -1415,20 +1443,28 @@ export function useSessionHistory({
}
setPendingAction({ sessionId: threadId, action: "rename" });
try {
const sourceSession = getSessionByThreadId(threadId);
if (!sourceSession) return false;
await desktopClient.invoke("update_chat_session_title", {
sessionId: threadId,
environmentId:
sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID,
sessionId: sourceSession?.sessionId,
title: normalizedTitle,
});
const sourceSession = getSessionByThreadId(threadId);
const metadata = {
...(sourceSession?.metadata ?? {}),
title: normalizedTitle || undefined,
};
onUpdateSessionMetadata?.(threadId, metadata);
onUpdateSessionMetadata?.(
sourceSession.sessionId,
metadata,
sourceSession.environmentId,
);
window.dispatchEvent(
new CustomEvent("cline:session-title-updated", {
detail: {
sessionId: threadId,
sessionId: sourceSession.sessionId,
environmentId: sourceSession.environmentId,
title: normalizedTitle,
},
}),
@@ -1474,15 +1510,22 @@ export function useSessionHistory({
// if the write fails rather than blocking the row on a round trip.
applyPinned(pinned);
try {
const sourceSession = getSessionByThreadId(threadId);
if (!sourceSession) return false;
await desktopClient.invoke("update_chat_session_metadata", {
sessionId: threadId,
environmentId:
sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID,
sessionId: sourceSession?.sessionId,
metadata: { [PINNED_METADATA_KEY]: pinned ? true : null },
});
const sourceSession = getSessionByThreadId(threadId);
onUpdateSessionMetadata?.(threadId, {
...(sourceSession?.metadata ?? {}),
[PINNED_METADATA_KEY]: pinned || undefined,
});
onUpdateSessionMetadata?.(
sourceSession.sessionId,
{
...(sourceSession?.metadata ?? {}),
[PINNED_METADATA_KEY]: pinned || undefined,
},
sourceSession.environmentId,
);
scheduleRefresh(HISTORY_FAST_REFRESH_DELAY_MS);
return true;
} catch (error) {
@@ -1508,6 +1551,7 @@ export function useSessionHistory({
return false;
}
const sourceSession = getSessionByThreadId(threadId);
if (!sourceSession) return false;
setPendingAction({ sessionId: threadId, action: "fork" });
try {
const payload = await desktopClient.invoke<{
@@ -1516,8 +1560,10 @@ export function useSessionHistory({
}>("chat_session_command", {
request: {
action: "fork",
sessionId: threadId,
sessionId: sourceSession?.sessionId,
config: {
environmentId:
sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID,
provider: sourceSession?.provider || thread.provider,
model: sourceSession?.model || thread.model,
cwd: sourceSession?.cwd || sourceSession?.workspaceRoot || "",
@@ -1532,6 +1578,8 @@ export function useSessionHistory({
}
const forkedSession: SessionHistoryItem = {
sessionId: newSessionId,
environmentId:
sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID,
status: "completed",
provider: sourceSession?.provider || thread.provider,
model: sourceSession?.model || thread.model,
@@ -1568,12 +1616,16 @@ export function useSessionHistory({
const deleteThread = useCallback(
async (threadId: string) => {
const sourceSession = getSessionByThreadId(threadId);
if (!sourceSession) return false;
setPendingAction({ sessionId: threadId, action: "delete" });
try {
const deleteResult = await desktopClient.invoke<
boolean | { deleted?: boolean }
>("delete_chat_session", {
sessionId: threadId,
environmentId:
sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID,
sessionId: sourceSession?.sessionId,
});
const deleted =
typeof deleteResult === "boolean"
@@ -1584,11 +1636,12 @@ export function useSessionHistory({
"The session could not be removed from local history.",
);
}
onDeleteSession?.(threadId);
onDeleteSession?.(sourceSession.sessionId, sourceSession.environmentId);
window.dispatchEvent(
new CustomEvent("cline:session-deleted", {
detail: {
sessionId: threadId,
sessionId: sourceSession.sessionId,
environmentId: sourceSession.environmentId,
},
}),
);
@@ -1607,7 +1660,7 @@ export function useSessionHistory({
setPendingAction(null);
}
},
[onDeleteSession],
[getSessionByThreadId, onDeleteSession],
);
const loadMoreSessions = useCallback(
@@ -1686,7 +1739,7 @@ export function useSessionHistory({
}, [loadMoreSessions, refreshSessions]);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.sessionId, session])),
() => new Map(sessions.map((session) => [sessionKey(session), session])),
[sessions],
);
@@ -5,6 +5,7 @@ export const ChatSessionConfigSchema = z.object({
sessionId: z.string().min(1).optional(),
workspaceRoot: z.string(),
cwd: z.string().optional(),
environmentId: z.string().trim().min(1),
provider: z.string().min(1),
model: z.string().min(1),
mode: z.enum(["act", "plan"]).default("act"),
@@ -1,12 +1,14 @@
import { describe, expect, it } from "vitest";
import { createDesktopAppState, desktopAppReducer } from "./desktop-app-state";
import type { SessionHistoryItem } from "./session-history";
import { sessionKey } from "./session-identity";
const settingsSection = "General" as const;
function createSession(sessionId: string): SessionHistoryItem {
return {
sessionId,
environmentId: "local",
status: "completed",
provider: "test-provider",
model: "test-model",
@@ -17,50 +19,100 @@ function createSession(sessionId: string): SessionHistoryItem {
}
describe("desktopAppReducer", () => {
it("keeps identical session IDs separate across environments", () => {
let state = createDesktopAppState("welcome", settingsSection, "local");
for (const environmentId of ["local", "remote"]) {
state = desktopAppReducer(state, {
type: "open-session",
session: { ...createSession("same-id"), environmentId },
environmentId,
});
}
expect(
state.threads.filter(
(thread) => thread.historySession?.sessionId === "same-id",
),
).toHaveLength(2);
state = desktopAppReducer(state, {
type: "update-session-metadata",
sessionId: "same-id",
environmentId: "remote",
metadata: { title: "Remote title" },
});
expect(
state.threads.find(
(thread) => thread.environmentId === "local" && thread.historySession,
)?.historySession?.metadata?.title,
).toBeUndefined();
state = desktopAppReducer(state, {
type: "delete-session",
deletedSessionId: "same-id",
environmentId: "remote",
fallbackThreadId: "fallback",
fallbackEnvironmentId: "local",
});
expect(
state.threads
.filter((thread) => thread.historySession?.sessionId === "same-id")
.map((thread) => thread.environmentId),
).toEqual(["local"]);
});
it("hands an edited prompt to a fork exactly once", () => {
let state = createDesktopAppState("welcome", settingsSection);
let state = createDesktopAppState("welcome", settingsSection, "local");
state = desktopAppReducer(state, {
type: "open-session",
session: createSession("forked-session"),
environmentId: "local",
initialPromptDraft: "Revise this prompt",
});
expect(
state.threads.find((thread) => thread.id === "session_forked-session")
?.initialPromptDraft,
state.threads.find(
(thread) =>
thread.id ===
`session_${sessionKey({ sessionId: "forked-session", environmentId: "local" })}`,
)?.initialPromptDraft,
).toBe("Revise this prompt");
state = desktopAppReducer(state, {
type: "consume-initial-prompt-draft",
threadId: "session_forked-session",
threadId: `session_${sessionKey({ sessionId: "forked-session", environmentId: "local" })}`,
});
expect(
state.threads.find((thread) => thread.id === "session_forked-session")
?.initialPromptDraft,
state.threads.find(
(thread) =>
thread.id ===
`session_${sessionKey({ sessionId: "forked-session", environmentId: "local" })}`,
)?.initialPromptDraft,
).toBeUndefined();
});
it("keeps both sessions deleted when deletion actions are queued together", () => {
let state = createDesktopAppState("welcome", settingsSection);
let state = createDesktopAppState("welcome", settingsSection, "local");
state = desktopAppReducer(state, {
type: "open-session",
session: createSession("session-a"),
environmentId: "local",
});
state = desktopAppReducer(state, {
type: "open-session",
session: createSession("session-b"),
environmentId: "local",
});
state = desktopAppReducer(state, {
type: "delete-session",
deletedSessionId: "session-a",
fallbackThreadId: "fallback-a",
fallbackEnvironmentId: "local",
});
state = desktopAppReducer(state, {
type: "delete-session",
deletedSessionId: "session-b",
fallbackThreadId: "fallback-b",
fallbackEnvironmentId: "local",
});
expect(state.threads.map((thread) => thread.id)).toEqual([
@@ -73,30 +125,145 @@ describe("desktopAppReducer", () => {
state.navigation.current,
...state.navigation.forward,
]).not.toContainEqual(
expect.objectContaining({ activeThreadId: "session_session-a" }),
expect.objectContaining({
activeThreadId: `session_${sessionKey({ sessionId: "session-a", environmentId: "local" })}`,
}),
);
expect([
...state.navigation.back,
state.navigation.current,
...state.navigation.forward,
]).not.toContainEqual(
expect.objectContaining({ activeThreadId: "session_session-b" }),
expect.objectContaining({
activeThreadId: `session_${sessionKey({ sessionId: "session-b", environmentId: "local" })}`,
}),
);
});
it("ignores a duplicate deletion after its thread and history are removed", () => {
let state = createDesktopAppState("welcome", settingsSection);
let state = createDesktopAppState("welcome", settingsSection, "local");
state = desktopAppReducer(state, {
type: "open-session",
session: createSession("session-a"),
environmentId: "local",
});
const deletion = {
type: "delete-session" as const,
deletedSessionId: "session-a",
fallbackThreadId: "fallback-a",
fallbackEnvironmentId: "local",
};
state = desktopAppReducer(state, deletion);
expect(desktopAppReducer(state, deletion)).toBe(state);
});
it("binds drafts to an environment without rebinding started threads", () => {
let state = createDesktopAppState("welcome", settingsSection, "local");
state = desktopAppReducer(state, {
type: "bind-unstarted-thread",
threadId: "welcome",
environmentId: "pi-host",
});
expect(state.threads[0]?.environmentId).toBe("pi-host");
state = desktopAppReducer(state, {
type: "thread-started",
threadId: "welcome",
});
state = desktopAppReducer(state, {
type: "bind-unstarted-thread",
threadId: "welcome",
environmentId: "other-host",
});
expect(state.threads[0]?.environmentId).toBe("pi-host");
});
it("carries environment identity through new and restored threads", () => {
let state = createDesktopAppState("welcome", settingsSection, "local");
state = desktopAppReducer(state, {
type: "new-thread",
threadId: "remote-draft",
environmentId: "pi-host",
});
expect(state.threads.at(-1)).toMatchObject({
id: "remote-draft",
environmentId: "pi-host",
});
state = desktopAppReducer(state, {
type: "open-session",
session: {
...createSession("remote-session"),
environmentId: "pi-host",
workspaceRoot: "/home/pi/project",
cwd: "/home/pi/project",
},
environmentId: "pi-host",
});
expect(state.threads.at(-1)).toMatchObject({
id: `session_${sessionKey({ sessionId: "remote-session", environmentId: "pi-host" })}`,
environmentId: "pi-host",
historySession: { environmentId: "pi-host" },
});
});
it("creates one draft per selected environment and reuses it", () => {
let state = createDesktopAppState("local-draft", settingsSection, "local");
state = desktopAppReducer(state, {
type: "select-environment-draft",
environmentId: "pi-host",
threadId: "remote-draft",
});
expect(state.navigation.current).toMatchObject({
activeThreadId: "remote-draft",
view: "chat",
});
expect(state.threads).toContainEqual({
id: "remote-draft",
environmentId: "pi-host",
});
const selectedAgain = desktopAppReducer(state, {
type: "select-environment-draft",
environmentId: "pi-host",
threadId: "duplicate-remote-draft",
});
expect(selectedAgain).toBe(state);
expect(
selectedAgain.threads.filter(
(thread) => thread.environmentId === "pi-host" && !thread.hasStarted,
),
).toHaveLength(1);
state = desktopAppReducer(selectedAgain, {
type: "select-environment-draft",
environmentId: "local",
threadId: "duplicate-local-draft",
});
expect(state.navigation.current.activeThreadId).toBe("local-draft");
expect(
state.threads.some((thread) => thread.id === "duplicate-local-draft"),
).toBe(false);
});
it("does not reuse a started thread as an environment draft", () => {
let state = createDesktopAppState("local-draft", settingsSection, "local");
state = desktopAppReducer(state, {
type: "thread-started",
threadId: "local-draft",
});
state = desktopAppReducer(state, {
type: "select-environment-draft",
environmentId: "local",
threadId: "fresh-local-draft",
});
expect(state.navigation.current.activeThreadId).toBe("fresh-local-draft");
expect(state.threads.at(-1)).toEqual({
id: "fresh-local-draft",
environmentId: "local",
});
});
});
@@ -4,11 +4,13 @@ import {
navigationHistoryReducer,
} from "./navigation-history";
import type { SessionHistoryItem, SessionMetadata } from "./session-history";
import { sessionKey } from "./session-identity";
export type DesktopAppView = "chat" | "sessions" | "settings";
export type DesktopThread = {
id: string;
environmentId: string;
historySession?: SessionHistoryItem;
hasStarted?: boolean;
initialPromptDraft?: string;
@@ -29,21 +31,31 @@ export type DesktopAppAction<SettingsSection extends string> =
| { type: "navigate"; destination: DesktopAppLocation<SettingsSection> }
| { type: "back" }
| { type: "forward" }
| { type: "new-thread"; threadId: string }
| { type: "new-thread"; threadId: string; environmentId: string }
| { type: "bind-unstarted-thread"; threadId: string; environmentId: string }
| {
type: "select-environment-draft";
environmentId: string;
threadId: string;
}
| {
type: "open-session";
session: SessionHistoryItem;
environmentId: string;
initialPromptDraft?: string;
}
| { type: "consume-initial-prompt-draft"; threadId: string }
| {
type: "delete-session";
deletedSessionId: string;
environmentId?: string;
deletedThreadId?: string;
fallbackThreadId: string;
fallbackEnvironmentId: string;
}
| {
type: "update-session-metadata";
environmentId?: string;
sessionId: string;
metadata: SessionMetadata;
}
@@ -63,9 +75,10 @@ function areLocationsEqual<SettingsSection extends string>(
export function createDesktopAppState<SettingsSection extends string>(
initialThreadId: string,
initialSettingsSection: SettingsSection,
initialEnvironmentId: string,
): DesktopAppState<SettingsSection> {
return {
threads: [{ id: initialThreadId }],
threads: [{ id: initialThreadId, environmentId: initialEnvironmentId }],
navigation: createNavigationHistory({
activeThreadId: initialThreadId,
settingsSection: initialSettingsSection,
@@ -98,7 +111,10 @@ export function desktopAppReducer<SettingsSection extends string>(
};
case "new-thread":
return {
threads: [...state.threads, { id: action.threadId }],
threads: [
...state.threads,
{ id: action.threadId, environmentId: action.environmentId },
],
navigation: navigationHistoryReducer(state.navigation, {
type: "navigate",
destination: {
@@ -108,8 +124,54 @@ export function desktopAppReducer<SettingsSection extends string>(
},
}),
};
case "bind-unstarted-thread":
return {
...state,
threads: state.threads.map((thread) =>
thread.id === action.threadId &&
!thread.hasStarted &&
!thread.historySession
? { ...thread, environmentId: action.environmentId }
: thread,
),
};
case "select-environment-draft": {
const existingDraft = [...state.threads]
.reverse()
.find(
(thread) =>
thread.environmentId === action.environmentId &&
!thread.hasStarted &&
!thread.historySession,
);
const targetThreadId = existingDraft?.id ?? action.threadId;
const threads = existingDraft
? state.threads
: [
...state.threads,
{ id: targetThreadId, environmentId: action.environmentId },
];
const destination = {
...state.navigation.current,
activeThreadId: targetThreadId,
view: "chat" as const,
};
if (
threads === state.threads &&
areLocationsEqual(state.navigation.current, destination)
) {
return state;
}
return {
threads,
navigation: navigationHistoryReducer(state.navigation, {
type: "navigate",
destination,
}),
};
}
case "open-session": {
const threadId = `session_${action.session.sessionId}`;
const threadId = `session_${sessionKey({ ...action.session, environmentId: action.environmentId })}`;
const existingIdx = state.threads.findIndex(
(thread) => thread.id === threadId,
);
@@ -119,8 +181,12 @@ export function desktopAppReducer<SettingsSection extends string>(
index === existingIdx
? {
...thread,
environmentId: action.environmentId,
hasStarted: true,
historySession: action.session,
historySession: {
...action.session,
environmentId: action.environmentId,
},
initialPromptDraft: action.initialPromptDraft,
}
: thread,
@@ -129,8 +195,12 @@ export function desktopAppReducer<SettingsSection extends string>(
...state.threads,
{
id: threadId,
environmentId: action.environmentId,
hasStarted: true,
historySession: action.session,
historySession: {
...action.session,
environmentId: action.environmentId,
},
initialPromptDraft: action.initialPromptDraft,
},
];
@@ -157,14 +227,15 @@ export function desktopAppReducer<SettingsSection extends string>(
),
};
case "delete-session": {
const historyThreadId = `session_${action.deletedSessionId}`;
const historyThreadId = `session_${sessionKey({ sessionId: action.deletedSessionId, environmentId: action.environmentId })}`;
const deletedThreadIds = new Set(
state.threads
.filter(
(thread) =>
thread.id === action.deletedThreadId ||
thread.id === historyThreadId ||
thread.historySession?.sessionId === action.deletedSessionId,
(thread.historySession?.sessionId === action.deletedSessionId &&
thread.environmentId === (action.environmentId ?? "local")),
)
.map((thread) => thread.id),
);
@@ -193,7 +264,13 @@ export function desktopAppReducer<SettingsSection extends string>(
let replacementThreadId = threads[0]?.id;
if (deletedWasActive || !replacementThreadId) {
replacementThreadId = action.fallbackThreadId;
threads = [...threads, { id: replacementThreadId }];
threads = [
...threads,
{
id: replacementThreadId,
environmentId: action.fallbackEnvironmentId,
},
];
}
const fallback: DesktopAppLocation<SettingsSection> = {
...state.navigation.current,
@@ -224,7 +301,8 @@ export function desktopAppReducer<SettingsSection extends string>(
return {
...state,
threads: state.threads.map((thread) =>
thread.historySession?.sessionId === action.sessionId
thread.historySession?.sessionId === action.sessionId &&
thread.environmentId === (action.environmentId ?? "local")
? {
...thread,
historySession: {
@@ -62,6 +62,25 @@ afterEach(() => {
});
describe("desktop notifications", () => {
it("does not deduplicate completion notifications across hosts", async () => {
const { watchDesktopNotifications } = await importFresh();
const stop = watchDesktopNotifications();
for (const environmentId of ["local", "remote"]) {
emit("chat_session_ended", {
sessionId: "same-id",
environmentId,
reason: "completed",
});
emit("chat_session_ended", {
sessionId: "same-id",
environmentId,
reason: "completed",
});
}
await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2));
stop();
});
it("notifies once when a background approval remains in state snapshots", async () => {
const { watchDesktopNotifications } = await importFresh();
const stop = watchDesktopNotifications();
@@ -5,6 +5,7 @@ import {
requestPermission,
} from "@tauri-apps/plugin-notification";
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
import { eventEnvironmentId, sessionKey } from "./session-identity";
const DESKTOP_NOTIFICATION_SETTINGS_STORAGE_KEY =
"cline:desktop-notification-settings:v1";
@@ -309,19 +310,21 @@ export function watchDesktopNotifications(): () => void {
};
const handleTerminal = (
environmentId: string,
sessionId: string,
kind: TerminalKind,
detail = "",
) => {
if (!sessionId || terminalBySession.get(sessionId) === kind) {
const key = sessionKey({ sessionId, environmentId });
if (!sessionId || terminalBySession.get(key) === kind) {
return;
}
terminalBySession.set(sessionId, kind);
terminalBySession.set(key, kind);
if (kind === "cancelled") {
return;
}
if (kind === "completed") {
if ((queuedPromptsBySession.get(sessionId) ?? 0) > 0) {
if ((queuedPromptsBySession.get(key) ?? 0) > 0) {
return;
}
void notify({
@@ -347,7 +350,7 @@ export function watchDesktopNotifications(): () => void {
const sessionId = asNonEmptyString(record.sessionId);
if (!sessionId) return;
queuedPromptsBySession.set(
sessionId,
sessionKey({ sessionId, environmentId: eventEnvironmentId(payload) }),
Array.isArray(record.items) ? record.items.length : 0,
);
}),
@@ -362,13 +365,16 @@ export function watchDesktopNotifications(): () => void {
stream === "chat_tool_call_start" ||
stream === "chat_text"
) {
terminalBySession.delete(sessionId);
terminalBySession.delete(
sessionKey({ sessionId, environmentId: eventEnvironmentId(payload) }),
);
return;
}
if (stream !== "chat_done") return;
const done = parseDoneChunk(event.chunk);
const kind = terminalKind(done.reason || "completed");
if (kind) handleTerminal(sessionId, kind, done.text);
if (kind)
handleTerminal(eventEnvironmentId(payload), sessionId, kind, done.text);
}),
desktopClient.subscribe("chat_session_status", (payload) => {
if (!payload || typeof payload !== "object") return;
@@ -377,11 +383,14 @@ export function watchDesktopNotifications(): () => void {
const status = asNonEmptyString(record.status).toLowerCase();
if (!sessionId || !status) return;
if (status === "running" || status === "starting") {
terminalBySession.delete(sessionId);
terminalBySession.delete(
sessionKey({ sessionId, environmentId: eventEnvironmentId(payload) }),
);
return;
}
const kind = terminalKind(status);
if (kind && status !== "idle") handleTerminal(sessionId, kind);
if (kind && status !== "idle")
handleTerminal(eventEnvironmentId(payload), sessionId, kind);
}),
desktopClient.subscribe("chat_session_ended", (payload) => {
if (!payload || typeof payload !== "object") return;
@@ -389,7 +398,8 @@ export function watchDesktopNotifications(): () => void {
const sessionId = asNonEmptyString(record.sessionId);
const reason = asNonEmptyString(record.reason);
const kind = terminalKind(reason);
if (sessionId && kind) handleTerminal(sessionId, kind);
if (sessionId && kind)
handleTerminal(eventEnvironmentId(payload), sessionId, kind);
}),
desktopClient.subscribe("tool_approval_state", (payload) => {
if (!payload || typeof payload !== "object") return;
@@ -398,7 +408,16 @@ export function watchDesktopNotifications(): () => void {
if (!sessionId || !Array.isArray(record.items)) return;
for (const item of record.items as ToolApprovalItem[]) {
const requestId = asNonEmptyString(item.requestId);
if (!requestId || !addSeenRequest(seenApprovalRequests, requestId)) {
if (
!requestId ||
!addSeenRequest(
seenApprovalRequests,
sessionKey({
sessionId: requestId,
environmentId: eventEnvironmentId(payload),
}),
)
) {
continue;
}
const toolName = asNonEmptyString(item.toolName) || "A tool";
@@ -418,7 +437,13 @@ export function watchDesktopNotifications(): () => void {
if (
!requestId ||
!sessionId ||
!addSeenRequest(seenQuestionRequests, requestId)
!addSeenRequest(
seenQuestionRequests,
sessionKey({
sessionId: requestId,
environmentId: eventEnvironmentId(payload),
}),
)
) {
return;
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
createRemoteEnvironmentDraft,
formatRemoteEnvironmentDestination,
normalizeRemoteEnvironmentProfile,
validateRemoteEnvironmentProfile,
} from "./remote-environments";
describe("remote environment models", () => {
it("leaves a new profile port blank so SSH config can supply it", () => {
expect(createRemoteEnvironmentDraft()).toEqual({
id: undefined,
name: "",
host: "",
user: undefined,
port: undefined,
identityFile: undefined,
});
});
it("normalizes SSH connection fields", () => {
expect(
normalizeRemoteEnvironmentProfile({
id: " remote-1 ",
name: " Build box ",
host: " builder.example.com ",
user: " ubuntu ",
port: 2222,
identityFile: " ~/.ssh/build ",
}),
).toEqual({
id: "remote-1",
name: "Build box",
host: "builder.example.com",
user: "ubuntu",
port: 2222,
identityFile: "~/.ssh/build",
});
});
it("validates required connection fields and formats destinations", () => {
const profile = {
name: "Build box",
host: "builder.example.com",
user: "ubuntu",
port: 2222,
};
expect(validateRemoteEnvironmentProfile(profile)).toBeUndefined();
expect(formatRemoteEnvironmentDestination(profile)).toBe(
"ubuntu@builder.example.com:2222",
);
expect(
formatRemoteEnvironmentDestination({
host: "build-alias",
port: undefined,
}),
).toBe("build-alias");
});
});
@@ -0,0 +1,149 @@
export const DEFAULT_REMOTE_ENVIRONMENT_PORT = 22;
export type {
RemoteEnvironmentInput as RemoteEnvironmentProfile,
RemoteEnvironmentStatus,
} from "@cline/core";
import type {
RemoteEnvironmentInput as RemoteEnvironmentProfile,
RemoteEnvironmentStatus,
} from "@cline/core";
export type RemoteEnvironmentListResult = {
profiles: RemoteEnvironmentProfile[];
activeEnvironmentId: string;
activeProfileId: string | null;
statuses: RemoteEnvironmentStatus[];
};
export type RemoteEnvironmentUpsertResult = {
profile: RemoteEnvironmentProfile;
};
export type RemoteEnvironmentTestResult = {
profile?: RemoteEnvironmentProfile;
status: "passed" | "failed";
message?: string;
remotePlatform?: string;
remoteArch?: string;
};
export type RemoteEnvironmentConnectResult = {
profile: RemoteEnvironmentProfile;
status: "connected";
environmentId: string;
activeEnvironmentId: string;
activeProfileId: string;
homeDir: string;
workspaceRoot: string;
remotePlatform?: string;
remoteArch?: string;
};
export type RemoteEnvironmentDisconnectResult = {
status: "disconnected";
disconnectedProfileId: string | null;
activeEnvironmentId: string;
activeProfileId: string | null;
};
export type RemoteEnvironmentDeleteResult = {
deleted: boolean;
activeEnvironmentId: string;
activeProfileId: string | null;
};
export type RemoteEnvironmentTestState =
| "untested"
| "testing"
| "passed"
| "failed";
export type RemoteEnvironmentBootstrapState =
| "unknown"
| "installing"
| "ready"
| "failed";
export type RemoteEnvironmentConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "disconnecting"
| "error";
export type RemoteEnvironmentRuntimeState = {
test: RemoteEnvironmentTestState;
bootstrap: RemoteEnvironmentBootstrapState;
connection: RemoteEnvironmentConnectionState;
message?: string;
remotePlatform?: string;
remoteArch?: string;
};
export const DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE: RemoteEnvironmentRuntimeState =
{
test: "untested",
bootstrap: "unknown",
connection: "disconnected",
};
export function createRemoteEnvironmentDraft(
profile?: RemoteEnvironmentProfile,
): RemoteEnvironmentProfile {
return {
id: profile?.id,
name: profile?.name ?? "",
host: profile?.host ?? "",
user: profile?.user,
port: profile?.port,
identityFile: profile?.identityFile,
};
}
function trimmedOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function normalizeRemoteEnvironmentProfile(
profile: RemoteEnvironmentProfile,
): RemoteEnvironmentProfile {
return {
id: trimmedOptional(profile.id),
name: profile.name.trim(),
host: profile.host.trim(),
user: trimmedOptional(profile.user),
port: profile.port,
identityFile: trimmedOptional(profile.identityFile),
};
}
export function validateRemoteEnvironmentProfile(
profile: RemoteEnvironmentProfile,
): string | undefined {
if (!profile.name.trim()) return "Name is required.";
if (!profile.host.trim()) return "SSH host is required.";
if (
profile.port !== undefined &&
(!Number.isInteger(profile.port) ||
profile.port < 1 ||
profile.port > 65_535)
) {
return "Port must be a whole number between 1 and 65535.";
}
return undefined;
}
export function formatRemoteEnvironmentDestination(
profile: Pick<RemoteEnvironmentProfile, "host" | "user" | "port">,
): string {
const host = profile.host.trim();
const user = profile.user?.trim();
const destination = user ? `${user}@${host}` : host;
return profile.port === undefined ||
profile.port === DEFAULT_REMOTE_ENVIRONMENT_PORT
? destination
: `${destination}:${profile.port}`;
}
@@ -49,6 +49,12 @@ export interface SessionHistoryItem {
model: string;
cwd: string;
workspaceRoot: string;
environmentId: string;
remoteEnvironment?: {
id: string;
name?: string;
host?: string;
};
parentSessionId?: string;
isSubagent?: boolean;
prompt?: string;
@@ -0,0 +1,16 @@
/** Stable UI identity; the runtime session ID remains unchanged on the wire. */
export function sessionKey(session: {
sessionId: string;
environmentId?: string;
}): string {
return JSON.stringify([session.environmentId ?? "local", session.sessionId]);
}
export function eventEnvironmentId(payload: unknown): string {
return payload &&
typeof payload === "object" &&
"environmentId" in payload &&
typeof payload.environmentId === "string"
? payload.environmentId
: "local";
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { remoteWorkspaceEnvironmentFromContext } from "./workspace-environment";
describe("remoteWorkspaceEnvironmentFromContext", () => {
it("returns the active SSH environment and its reported home", () => {
expect(
remoteWorkspaceEnvironmentFromContext({
environmentId: "pi-host",
workspaceRoot: "/home/pi",
cwd: "/home/pi",
homeDir: "/home/pi",
activeEnvironmentId: "pi-host",
remoteEnvironment: { id: "pi-host", host: "pi.local" },
}),
).toEqual({ id: "pi-host", homeDir: "/home/pi" });
});
it("keeps local contexts local", () => {
expect(
remoteWorkspaceEnvironmentFromContext({
environmentId: "local",
workspaceRoot: "/Users/dev/project",
cwd: "/Users/dev/project",
homeDir: "/Users/dev",
activeEnvironmentId: "local",
remoteEnvironment: null,
}),
).toBeNull();
});
});
@@ -0,0 +1,16 @@
import type { ProcessContext } from "@/hooks/chat-session/types";
export type RemoteWorkspaceEnvironment = {
id: string;
homeDir: string;
};
export function remoteWorkspaceEnvironmentFromContext(
context: ProcessContext,
): RemoteWorkspaceEnvironment | null {
const id = context.remoteEnvironment?.id?.trim();
if (!id) return null;
const homeDir = context.homeDir?.trim();
if (!homeDir) return null;
return { id, homeDir };
}
@@ -1,3 +1,5 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import {
filterWorkspacePaths,
@@ -7,9 +9,12 @@ import {
mergeWorkspacePaths,
normalizeWorkspacePath,
parseWorkspaceSelectionStorage,
readWorkspaceSelectionFromWindow,
registerHostHomeDirectory,
resolveWorkspaceFilePath,
WORKSPACE_SELECTION_STORAGE_KEY,
workspacePathsFromSessions,
writeWorkspaceSelectionToWindow,
} from "./workspace-paths";
describe("workspace paths", () => {
@@ -25,6 +30,10 @@ describe("workspace paths", () => {
expect(looksLikeFolderPath("")).toBe(false);
});
afterEach(() => {
window.localStorage.clear();
});
it("normalizes trailing separators and Windows path casing", () => {
expect(normalizeWorkspacePath(" /workspace/cline/ ")).toBe(
"/workspace/cline",
@@ -100,17 +109,33 @@ describe("workspace paths", () => {
});
it("orders the catalog by the most recent session in each workspace", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" },
{
workspaceRoot: "/projects/active",
startedAt: "2026-02-01T00:00:00Z",
endedAt: "2026-02-01T01:00:00Z",
},
{ workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" },
{ workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" },
{ workspaceRoot: "/projects/undated" },
]);
const paths = workspacePathsFromSessions(
[
{
workspaceRoot: "/projects/old",
startedAt: "2026-01-05T00:00:00Z",
environmentId: "local",
},
{
workspaceRoot: "/projects/active",
startedAt: "2026-02-01T00:00:00Z",
endedAt: "2026-02-01T01:00:00Z",
environmentId: "local",
},
{
workspaceRoot: "/projects/old",
startedAt: "2026-03-01T00:00:00Z",
environmentId: "local",
},
{
workspaceRoot: "/projects/mid",
startedAt: "2026-02-15T00:00:00Z",
environmentId: "local",
},
{ workspaceRoot: "/projects/undated", environmentId: "local" },
],
"local",
);
expect(paths).toEqual([
"/projects/old",
@@ -123,10 +148,14 @@ describe("workspace paths", () => {
it("builds the project catalog from every loaded history workspace", () => {
const sessions = Array.from({ length: 25 }, (_, index) => ({
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
environmentId: "local",
}));
sessions.push({ workspaceRoot: "/projects/project-01/" });
sessions.push({
workspaceRoot: "/projects/project-01/",
environmentId: "local",
});
const paths = workspacePathsFromSessions(sessions);
const paths = workspacePathsFromSessions(sessions, "local");
expect(paths).toHaveLength(25);
expect(paths).toContain("/projects/project-25");
@@ -136,20 +165,73 @@ describe("workspace paths", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/projects/selected/",
workspaces: ["/projects/one", "/projects/selected"],
environments: {
local: {
lastWorkspace: "/projects/selected/",
workspaces: ["/projects/one", "/projects/selected"],
},
},
}),
"local",
),
).toEqual({
lastWorkspace: "/projects/selected/",
workspaces: ["/projects/one", "/projects/selected"],
});
expect(parseWorkspaceSelectionStorage("not json")).toEqual({
expect(parseWorkspaceSelectionStorage("not json", "local")).toEqual({
lastWorkspace: "",
workspaces: [],
});
});
it("does not interpret path-only v1 data as an environment selection", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/projects/legacy",
workspaces: ["/projects/legacy"],
}),
"local",
),
).toEqual({ lastWorkspace: "", workspaces: [] });
});
it("reads and writes each environment without replacing the others", () => {
writeWorkspaceSelectionToWindow("local", {
lastWorkspace: "/Users/dev/local-app",
workspaces: ["/Users/dev/local-app"],
});
writeWorkspaceSelectionToWindow("pi-host", {
lastWorkspace: "/home/pi/remote-app",
workspaces: ["/home/pi/other-app", "/home/pi/remote-app"],
});
expect(readWorkspaceSelectionFromWindow("local")).toEqual({
lastWorkspace: "/Users/dev/local-app",
workspaces: ["/Users/dev/local-app"],
});
expect(readWorkspaceSelectionFromWindow("pi-host")).toEqual({
lastWorkspace: "/home/pi/remote-app",
workspaces: ["/home/pi/other-app", "/home/pi/remote-app"],
});
expect(
JSON.parse(
window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY) ?? "{}",
),
).toEqual({
environments: {
local: {
lastWorkspace: "/Users/dev/local-app",
workspaces: ["/Users/dev/local-app"],
},
"pi-host": {
lastWorkspace: "/home/pi/remote-app",
workspaces: ["/home/pi/other-app", "/home/pi/remote-app"],
},
},
});
});
it("excludes .cline-internal paths from the workspace catalog", () => {
expect(
isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"),
@@ -169,17 +251,25 @@ describe("workspace paths", () => {
expect(isExcludedWorkspacePath(temporaryWorkspace)).toBe(true);
expect(
workspacePathsFromSessions([
{ workspaceRoot: temporaryWorkspace },
{ workspaceRoot: "/projects/app" },
]),
workspacePathsFromSessions(
[
{ workspaceRoot: temporaryWorkspace, environmentId: "local" },
{ workspaceRoot: "/projects/app", environmentId: "local" },
],
"local",
),
).toEqual(["/projects/app"]);
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: temporaryWorkspace,
workspaces: [temporaryWorkspace, "/projects/app"],
environments: {
local: {
lastWorkspace: temporaryWorkspace,
workspaces: [temporaryWorkspace, "/projects/app"],
},
},
}),
"local",
),
).toEqual({
lastWorkspace: "",
@@ -230,13 +320,19 @@ describe("workspace paths", () => {
});
it("filters excluded paths out of session-derived workspaces", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/app" },
{ workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" },
{ cwd: "/Users/beatrix/Desktop" },
{ cwd: "/Users/beatrix" },
{ cwd: "/projects/tool" },
]);
const paths = workspacePathsFromSessions(
[
{ workspaceRoot: "/projects/app", environmentId: "local" },
{
workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip",
environmentId: "local",
},
{ cwd: "/Users/beatrix/Desktop", environmentId: "local" },
{ cwd: "/Users/beatrix", environmentId: "local" },
{ cwd: "/projects/tool", environmentId: "local" },
],
"local",
);
expect(paths).toEqual(["/projects/app", "/projects/tool"]);
});
@@ -245,13 +341,18 @@ describe("workspace paths", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: [
"/projects/one",
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
"/Users/beatrix",
],
environments: {
local: {
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: [
"/projects/one",
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
"/Users/beatrix",
],
},
},
}),
"local",
),
).toEqual({
lastWorkspace: "/Users/beatrix/Desktop",
@@ -261,4 +362,42 @@ describe("workspace paths", () => {
filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]),
).toEqual(["/projects/one"]);
});
it("scopes stored and session-derived workspaces by environment", () => {
const raw = JSON.stringify({
environments: {
local: {
lastWorkspace: "/Users/dev/local-app",
workspaces: ["/Users/dev/local-app"],
},
"pi-host": {
lastWorkspace: "/home/pi/remote-app",
workspaces: ["/home/pi/remote-app"],
},
},
});
expect(parseWorkspaceSelectionStorage(raw, "local").workspaces).toEqual([
"/Users/dev/local-app",
]);
expect(parseWorkspaceSelectionStorage(raw, "pi-host").workspaces).toEqual([
"/home/pi/remote-app",
]);
expect(
workspacePathsFromSessions(
[
{ workspaceRoot: "/Users/dev/local-app", environmentId: "local" },
{
workspaceRoot: "/home/pi/remote-app",
environmentId: "pi-host",
},
{
workspaceRoot: "/home/other/app",
environmentId: "other-host",
},
],
"pi-host",
),
).toEqual(["/home/pi/remote-app"]);
});
});
@@ -1,18 +1,25 @@
import { isChatWorkspacePath } from "@cline/shared/browser";
export const WORKSPACE_SELECTION_STORAGE_KEY =
"cline.code.workspace-selection.v1";
"cline.code.workspace-selection.v2";
export const LOCAL_WORKSPACE_ENVIRONMENT_ID = "local";
export type WorkspaceSelectionStorage = {
lastWorkspace: string;
workspaces: string[];
};
type WorkspaceSelectionStore = {
environments: Record<string, WorkspaceSelectionStorage>;
};
export type WorkspacePathSource = {
cwd?: string;
workspaceRoot?: string;
startedAt?: string;
endedAt?: string;
environmentId: string;
};
/** Typed/pasted folder paths in search boxes double as manual path entry. */
@@ -144,9 +151,13 @@ export function filterWorkspacePaths(paths: readonly string[]): string[] {
*/
export function workspacePathsFromSessions(
sessions: readonly WorkspacePathSource[],
environmentId: string,
): string[] {
const scopedSessions = sessions.filter(
(session) => session.environmentId === environmentId,
);
const lastActivityByPath = new Map<string, number>();
for (const session of sessions) {
for (const session of scopedSessions) {
const normalized = normalizeWorkspacePath(
session.workspaceRoot || session.cwd || "",
);
@@ -164,7 +175,9 @@ export function workspacePathsFromSessions(
}
return filterWorkspacePaths(
mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
scopedSessions.map(
(session) => session.workspaceRoot || session.cwd || "",
),
),
).sort((a, b) => {
const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0;
@@ -175,24 +188,27 @@ export function workspacePathsFromSessions(
export function parseWorkspaceSelectionStorage(
raw: string | null,
environmentId: string,
): WorkspaceSelectionStorage {
if (!raw) {
return { lastWorkspace: "", workspaces: [] };
}
try {
const parsed = JSON.parse(raw) as {
lastWorkspace?: unknown;
workspaces?: unknown;
environments?: Record<string, unknown>;
};
const selected = parsed.environments?.[environmentId] as
| { lastWorkspace?: unknown; workspaces?: unknown }
| undefined;
const parsedLastWorkspace =
typeof parsed?.lastWorkspace === "string"
? parsed.lastWorkspace.trim()
typeof selected?.lastWorkspace === "string"
? selected.lastWorkspace.trim()
: "";
const lastWorkspace = isChatWorkspacePath(parsedLastWorkspace)
? ""
: parsedLastWorkspace;
const workspaces = Array.isArray(parsed?.workspaces)
? parsed.workspaces.filter(
const workspaces = Array.isArray(selected?.workspaces)
? selected.workspaces.filter(
(workspace): workspace is string => typeof workspace === "string",
)
: [];
@@ -207,13 +223,16 @@ export function parseWorkspaceSelectionStorage(
}
}
export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage {
export function readWorkspaceSelectionFromWindow(
environmentId: string,
): WorkspaceSelectionStorage {
if (typeof window === "undefined") {
return { lastWorkspace: "", workspaces: [] };
}
try {
return parseWorkspaceSelectionStorage(
window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY),
environmentId,
);
} catch {
return { lastWorkspace: "", workspaces: [] };
@@ -221,22 +240,40 @@ export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage {
}
export function writeWorkspaceSelectionToWindow(
environmentId: string,
value: WorkspaceSelectionStorage,
): void {
if (typeof window === "undefined") {
return;
}
try {
const current = (() => {
try {
const parsed = JSON.parse(
window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY) ?? "{}",
) as Partial<WorkspaceSelectionStore>;
return parsed.environments && typeof parsed.environments === "object"
? parsed.environments
: {};
} catch {
return {};
}
})();
const lastWorkspace = isChatWorkspacePath(value.lastWorkspace)
? ""
: value.lastWorkspace.trim();
window.localStorage.setItem(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
lastWorkspace,
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(value.workspaces, [lastWorkspace]),
),
environments: {
...current,
[environmentId]: {
lastWorkspace,
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(value.workspaces, [lastWorkspace]),
),
},
},
}),
);
} catch {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.1.18",
"version": "4.1.19",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
+3
View File
@@ -12,6 +12,7 @@ import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
import { registerClineClientIdentity } from "./services/ClineClientIdentity"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { getDistinctId } from "./services/logging/distinctId"
@@ -63,6 +64,8 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
})
}
void registerClineClientIdentity()
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
// which depend on the `vscode` module and cannot live in the SDK package.
// Must run before any handler is built (standalone utilities or task loop).
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const identityMocks = vi.hoisted(() => ({
setClineClientIdentity: vi.fn(),
}))
const hostState = vi.hoisted(() => ({
hostVersion: {} as {
platform?: string
version?: string
clineType?: string
clineVersion?: string
},
hostVersionError: undefined as Error | undefined,
}))
vi.mock("@cline/shared", () => ({
setClineClientIdentity: identityMocks.setClineClientIdentity,
}))
vi.mock("@/hosts/host-provider", () => ({
HostProvider: {
env: {
getHostVersion: vi.fn(async () => {
if (hostState.hostVersionError) {
throw hostState.hostVersionError
}
return hostState.hostVersion
}),
},
},
}))
vi.mock("@/shared/services/Logger", () => ({
Logger: { log: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock("@/registry", () => ({
ExtensionRegistryInfo: { version: "9.9.9" },
}))
import { registerClineClientIdentity } from "./ClineClientIdentity"
describe("registerClineClientIdentity", () => {
beforeEach(() => {
identityMocks.setClineClientIdentity.mockClear()
hostState.hostVersionError = undefined
hostState.hostVersion = {
platform: "Visual Studio Code",
version: "1.103.0",
clineType: "VSCode Extension",
clineVersion: "3.40.0",
}
})
it("publishes the host-reported client identity", async () => {
await registerClineClientIdentity()
expect(identityMocks.setClineClientIdentity).toHaveBeenCalledWith({
name: "VSCode Extension",
version: "3.40.0",
platform: "Visual Studio Code",
platformVersion: "1.103.0",
})
})
it("falls back to the extension's own identity when the host bridge fails", async () => {
hostState.hostVersionError = new Error("host bridge unavailable")
await registerClineClientIdentity()
expect(identityMocks.setClineClientIdentity).toHaveBeenCalledWith({
name: "VSCode Extension",
version: "9.9.9",
})
})
})
@@ -0,0 +1,24 @@
import { setClineClientIdentity } from "@cline/shared"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineClient } from "@/shared/cline"
import { EmptyRequest } from "@/shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
export async function registerClineClientIdentity(): Promise<void> {
try {
const host = await HostProvider.env.getHostVersion(EmptyRequest.create({}))
setClineClientIdentity({
name: host.clineType || ClineClient.VSCode,
version: host.clineVersion || ExtensionRegistryInfo.version,
platform: host.platform || undefined,
platformVersion: host.version || undefined,
})
} catch (error) {
Logger.log("Failed to get IDE/platform info via HostBridge EnvService.getHostVersion", error)
setClineClientIdentity({
name: ClineClient.VSCode,
version: ExtensionRegistryInfo.version,
})
}
}
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
"src/core/controller/remoteConfig/**/*.test.ts",
"src/core/controller/state/**/*.test.ts",
"src/core/controller/slash/**/*.test.ts",
"src/services/ClineClientIdentity.test.ts",
"src/services/mcp/__tests__/settingsLock.test.ts",
"src/shared/model-catalog/provider-helpers.test.ts",
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
+1 -1
View File
@@ -359,7 +359,7 @@
},
"apps/vscode": {
"name": "claude-dev",
"version": "4.1.18",
"version": "4.1.19",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@bufbuild/protobuf": "^2.2.5",
+22
View File
@@ -65,3 +65,25 @@ helpers produce an explicit error, without installing a runtime from the network
The helper implements `--remote-hub-ensure --cwd <path> --discovery-path <path>`
and the core detached-daemon sentinel. Agent tools and persistence run remotely;
the host only manages SSH and forwards the authenticated hub connection.
## Concurrent subagent tool calls
`spawn_agent` and configured `subagent_*` tools declare
`executionMode: "parallel"`. Consecutive calls to these tools in one model
response run concurrently even when the parent runtime uses its default
sequential mode. Each call still returns its child's completed answer, and tool
results remain in the model's original call order.
A tool's optional `executionMode` overrides the runtime's `toolExecution` setting.
Unmarked tools inherit the runtime setting. Sequential calls form ordering
boundaries: `read_file → [spawn A, spawn B] → edit_file` executes the read first,
then both child runs together, then the edit after both finish. This does not
change the execution mode of tools inside the child agents.
Preparation remains serial for the whole response, before any tool executes.
All before-tool hooks and required approvals therefore complete before the
parallel group starts. A before-tool `skip` blocks its own call; a before-tool
`stop` prevents the entire response's execution, as before. Pending approvals
can delay sibling execution. No background-run handles or new concurrency
limit are introduced.
@@ -911,6 +911,210 @@ describe("AgentRuntime", () => {
]);
});
it.each([
undefined,
"parallel",
] as const)("preserves tool boundaries and result order with runtime mode %s", async (toolExecution) => {
const firstGate = Promise.withResolvers<void>();
const secondGate = Promise.withResolvers<void>();
const events: string[] = [];
const calls = [
["read", "serial"],
["a", "worker"],
["b", "worker"],
["edit", "serial"],
["c", "worker"],
["d", "worker"],
["tail", "serial"],
];
const model = new ScriptedModel([
() => [
...calls.map(([id, name]) => ({
type: "tool-call-delta" as const,
toolCallId: id,
toolName: name,
inputText: JSON.stringify({ text: id }),
})),
{ type: "finish", reason: "tool-calls" },
],
() => [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
],
]);
const execute = async (input: { text: string }) => {
events.push(`start:${input.text}`);
if (input.text === "a") await firstGate.promise;
if (input.text === "c") await secondGate.promise;
events.push(`end:${input.text}`);
if (input.text === "b") throw new Error("worker failed");
return input.text;
};
const runtime = new AgentRuntime({
model,
toolExecution,
tools: [
{
name: "serial",
executionMode:
toolExecution === "parallel" ? "sequential" : undefined,
description: "ordinary tool",
inputSchema: { type: "object" },
execute,
},
{
name: "worker",
description: "concurrent tool",
executionMode: "parallel",
inputSchema: { type: "object" },
execute,
},
],
});
const run = runtime.run("Run tools");
try {
await vi.waitFor(() => expect(events).toContain("end:b"));
expect(events).toEqual([
"start:read",
"end:read",
"start:a",
"start:b",
"end:b",
]);
firstGate.resolve();
await vi.waitFor(() => expect(events).toContain("end:d"));
expect(events.indexOf("start:edit")).toBeGreaterThan(
events.indexOf("end:a"),
);
expect(events.indexOf("start:c")).toBeGreaterThan(
events.indexOf("end:edit"),
);
expect(events).not.toContain("start:tail");
} finally {
firstGate.resolve();
secondGate.resolve();
}
const result = await run;
expect(result.status).toBe("completed");
expect(events.indexOf("start:tail")).toBeGreaterThan(
events.indexOf("end:c"),
);
const messages = result.messages.filter(
(message) => message.role === "tool",
);
expect(messages.map((message) => message.content[0])).toEqual(
calls.map(([id]) => expect.objectContaining({ toolCallId: id })),
);
expect(messages[2].content[0]).toMatchObject({
isError: true,
output: { error: "worker failed" },
});
});
it.each([
"skip",
"stop",
] as const)("preserves beforeTool %s semantics for parallel tools", async (decision) => {
const executed: string[] = [];
const model = new ScriptedModel([
() => [
...["a", "b"].map((id) => ({
type: "tool-call-delta" as const,
toolCallId: id,
toolName: "worker",
inputText: JSON.stringify({ text: id }),
})),
{ type: "finish", reason: "tool-calls" },
],
() => [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
],
]);
const runtime = new AgentRuntime({
model,
tools: [
{
name: "worker",
description: "concurrent tool",
executionMode: "parallel",
inputSchema: { type: "object" },
async execute(input: { text: string }) {
executed.push(input.text);
return input.text;
},
},
],
hooks: {
beforeTool: ({ toolCall }) =>
toolCall.toolCallId === "b"
? { [decision]: true, reason: "blocked" }
: undefined,
},
});
const result = await runtime.run("Run tools");
expect(result.status).toBe(decision === "stop" ? "aborted" : "completed");
expect(executed).toEqual(decision === "stop" ? [] : ["a"]);
});
it("finishes approval preparation before executing opted-in parallel tools", async () => {
const gate = Promise.withResolvers<void>();
const approvalStarted = Promise.withResolvers<void>();
const events: string[] = [];
const runtime = new AgentRuntime({
model: new ScriptedModel([
() => [
...["a", "b"].map((id) => ({
type: "tool-call-delta" as const,
toolCallId: id,
toolName: "worker",
inputText: JSON.stringify({ text: id }),
})),
{ type: "finish", reason: "tool-calls" },
],
() => [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
],
]),
tools: [
{
name: "worker",
description: "concurrent tool",
executionMode: "parallel",
inputSchema: { type: "object" },
async execute(input: { text: string }) {
events.push(`execute:${input.text}`);
return input.text;
},
},
],
toolPolicies: { "*": { autoApprove: false } },
requestToolApproval: async ({ toolCallId }) => {
events.push(`approve:${toolCallId}`);
if (toolCallId === "b") {
approvalStarted.resolve();
await gate.promise;
}
return { approved: true };
},
});
const run = runtime.run("Run tools");
try {
await approvalStarted.promise;
expect(events).toEqual(["approve:a", "approve:b"]);
} finally {
gate.resolve();
}
expect((await run).status).toBe("completed");
expect(events).toEqual([
"approve:a",
"approve:b",
"execute:a",
"execute:b",
]);
});
it("executes a tool call and continues the loop", async () => {
const model = new ScriptedModel([
() => [
+26 -8
View File
@@ -1884,15 +1884,33 @@ export class AgentRuntime {
prepared.push(await this.prepareToolExecution(toolCall));
}
if (this.config.toolExecution === "parallel") {
return Promise.all(
prepared.map((execution) => this.executePreparedTool(execution)),
);
}
const results: AgentMessage[] = [];
for (const execution of prepared) {
results.push(await this.executePreparedTool(execution));
for (let index = 0; index < prepared.length; ) {
const execution = prepared[index];
const mode = execution.tool?.executionMode ?? this.config.toolExecution;
if (mode === "sequential") {
results.push(await this.executePreparedTool(execution));
index += 1;
continue;
}
// Only adjacent parallel calls overlap. An ordinary sequential tool
// must wait for the group before it, and finish before the next group.
const start = index;
while (
index < prepared.length &&
(prepared[index].tool?.executionMode ?? this.config.toolExecution) ===
"parallel"
) {
index += 1;
}
results.push(
...(await Promise.all(
prepared
.slice(start, index)
.map((call) => this.executePreparedTool(call)),
)),
);
}
return results;
}
@@ -37,6 +37,7 @@ describe("configured agent tools", () => {
});
expect(tool?.name).toBe("subagent_code_reviewer");
expect(tool?.executionMode).toBe("parallel");
expect(tool?.timeoutMs).toBe(300000);
expect(tool?.retryable).toBe(false);
});
@@ -156,6 +156,7 @@ export function createConfiguredAgentTools(
({ toolName, config }) => {
const tool = createTool<ConfiguredAgentInput, SpawnAgentOutput>({
name: toolName,
executionMode: "parallel",
description: `Use the "${config.name}" subagent: ${config.description}`,
inputSchema: zodToJsonSchema(ConfiguredAgentInputSchema),
execute: async (input, context) => {
@@ -0,0 +1,109 @@
import { AgentRuntime } from "@cline/agents";
import type { AgentConfig, AgentModel, AgentTool } from "@cline/shared";
import { expect, it, vi } from "vitest";
import { createAgentRuntimeConfig } from "../../../runtime/config/agent-runtime-config-builder";
import { createDelegatedAgentConfigProvider } from "./delegated-agent";
import { createSpawnAgentTool } from "./spawn-agent-tool";
const childRun = vi.hoisted(() => vi.fn());
vi.mock("../../../runtime/orchestration/session-runtime-orchestrator", () => ({
SessionRuntime: class {
getAgentId() {
return "child";
}
getConversationId() {
return "child-conversation";
}
subscribeEvents() {
return () => {};
}
run(input: string) {
return childRun(input);
}
},
}));
it.each([
undefined,
2,
])("runs spawn calls concurrently with maxParallelToolCalls=%s", async (maxParallelToolCalls) => {
let markFirstStarted!: () => void;
const firstStarted = new Promise<void>((resolve) => {
markFirstStarted = resolve;
});
let releaseFirst!: () => void;
const gate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const events: string[] = [];
let active = 0;
let peak = 0;
childRun.mockImplementation(async (task: string) => {
active++;
peak = Math.max(peak, active);
events.push(`start:${task}`);
if (task === "first") {
markFirstStarted();
await gate;
}
events.push(`end:${task}`);
active--;
return {
text: task,
iterations: 1,
finishReason: "completed",
usage: { inputTokens: 0, outputTokens: 0 },
};
});
const spawn = createSpawnAgentTool({
configProvider: createDelegatedAgentConfigProvider({
providerId: "test",
modelId: "test",
}),
});
let requests = 0;
const model: AgentModel = {
async *stream() {
requests++;
if (requests === 1) {
for (const task of ["first", "second"])
yield {
type: "tool-call-delta",
toolCallId: task,
toolName: "spawn_agent",
inputText: JSON.stringify({ task, systemPrompt: "test" }),
};
yield { type: "finish", reason: "tool-calls" };
} else {
yield { type: "text-delta", text: "done" };
yield { type: "finish", reason: "stop" };
}
},
};
const agentConfig: AgentConfig = {
providerId: "test",
modelId: "test",
systemPrompt: "test",
tools: [spawn as AgentTool],
maxParallelToolCalls,
};
const config = createAgentRuntimeConfig({
agentConfig,
agentId: "parent",
model,
tools: [spawn as AgentTool],
completionPolicy: null,
});
const run = new AgentRuntime(config).run("Delegate two tasks");
try {
await firstStarted;
await vi.waitFor(() => expect(events).toContain("start:second"));
} finally {
releaseFirst();
}
expect((await run).status).toBe("completed");
expect(peak).toBe(2);
expect(events.indexOf("start:second")).toBeLessThan(
events.indexOf("end:first"),
);
});
@@ -119,7 +119,8 @@ export function createSpawnAgentTool(
): AgentTool<SpawnAgentInput, SpawnAgentOutput> {
return createTool<SpawnAgentInput, SpawnAgentOutput>({
name: "spawn_agent",
description: `Spawn a sub-agent with a custom system prompt for specialized tasks. Use when delegating work that benefits from focused expertise.`,
executionMode: "parallel",
description: `Spawn a sub-agent with custom instructions for a specialized task. Waits for the sub-agent to finish and returns its result before your next step.`,
inputSchema: zodToJsonSchema(SpawnAgentInputSchema),
execute: async (input, context) => {
const tools = config.createSubAgentTools
+1
View File
@@ -6,6 +6,7 @@
export * as Llms from "@cline/llms";
export {
buildClineClientHeaders,
ClineFreeModelLimitError,
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
@@ -2,7 +2,8 @@ import {
GENERATED_CLINE_RECOMMENDED_MODELS,
getGeneratedProviderModels,
} from "@cline/llms";
import { describe, expect, it } from "vitest";
import { setClineClientIdentity } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyClineFeaturedModels,
type ClineRecommendedModelsData,
@@ -94,6 +95,10 @@ function namesOf(data: ClineRecommendedModelsData) {
}
describe("fetchClineRecommendedModels", () => {
afterEach(() => {
setClineClientIdentity(undefined);
});
it("resolves display names from the models catalog", async () => {
const data = await fetchClineRecommendedModels({
baseUrl: BASE_URL,
@@ -118,6 +123,22 @@ describe("fetchClineRecommendedModels", () => {
]);
});
it("identifies the client on the feed request", async () => {
setClineClientIdentity({ name: "VSCode Extension", version: "3.40.0" });
const fetchImpl = vi.fn(jsonResponse(ENDPOINT_PAYLOAD));
await fetchClineRecommendedModels({
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch,
catalogLoader: async () => CATALOG,
});
expect(fetchImpl.mock.calls[0]?.[1]?.headers).toMatchObject({
"X-CLIENT-TYPE": "VSCode Extension",
"X-CLIENT-VERSION": "3.40.0",
});
});
it("degrades to endpoint names and id slugs when the catalog is unavailable", async () => {
const data = await fetchClineRecommendedModels({
baseUrl: BASE_URL,
@@ -1,4 +1,5 @@
import {
buildClineClientHeaders,
GENERATED_CLINE_RECOMMENDED_MODELS,
getGeneratedProviderModels,
VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES,
@@ -133,11 +134,12 @@ async function fetchWithTimeout(
fetchImpl: typeof fetch,
input: string,
timeoutMs: number,
headers: Record<string, string>,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(input, { signal: controller.signal });
return await fetchImpl(input, { headers, signal: controller.signal });
} finally {
clearTimeout(timer);
}
@@ -265,6 +267,7 @@ export async function fetchClineRecommendedModels(
fetchImpl,
`${base}/api/v1/ai/cline/recommended-models`,
timeoutMs,
buildClineClientHeaders(),
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json: unknown = await resp.json();
@@ -1,4 +1,5 @@
import { getClineEnvironmentConfig } from "@cline/shared";
import { buildClineClientHeaders } from "../providers/cline-client-headers";
import type { ModelInfo } from "./types";
export interface ClineRecommendedModelEntry {
@@ -159,7 +160,7 @@ export async function fetchClineRecommendedModelsPayload(
fetcher: typeof fetch = fetch,
): Promise<ClineRecommendedModelsPayload> {
const url = `${getClineEnvironmentConfig().apiBaseUrl}/api/v1/ai/cline/recommended-models`;
const response = await fetcher(url);
const response = await fetcher(url, { headers: buildClineClientHeaders() });
if (!response.ok) {
throw new Error(
`Failed to load Cline recommended models from ${url}: HTTP ${response.status}`,
@@ -1143,6 +1143,9 @@ describe("models-dev-catalog", () => {
expect(fetcher).toHaveBeenCalledWith("https://models.dev/api.json");
expect(fetcher).toHaveBeenCalledWith(
"https://api.cline.bot/api/v1/ai/cline/recommended-models",
expect.objectContaining({
headers: expect.objectContaining({ "X-CLIENT-TYPE": "cline-sdk" }),
}),
);
expect(result.openrouter).toHaveProperty("vendor/live-base-model");
expect(result["cline-pass"]?.["cline-pass/live-base-model"]).toMatchObject({

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