mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcc12090a2 | |||
| 58b14c69b1 | |||
| b831100f20 | |||
| 23fa305eac | |||
| 0762e6406d | |||
| 94da9f6669 | |||
| fdeef9cece | |||
| dd055b3327 | |||
| e852c6953b | |||
| aef27eb1c4 | |||
| 863572031f | |||
| 65eee1ac6a | |||
| 8213b0b910 | |||
| f5fc3fed6f | |||
| 42df03177f | |||
| 648ae1b1fd | |||
| d3cff6ac47 | |||
| 13af435103 | |||
| 3b1477bc24 | |||
| 688f93db6d | |||
| a702270e85 | |||
| 03acada1b9 | |||
| 95cca05f5e | |||
| 33d77eb095 | |||
| 3e0c39acbb | |||
| 366d8a5411 | |||
| d22596e5c2 | |||
| 7cc612dbce | |||
| f0cab63a43 | |||
| 3c1b670b73 | |||
| 5de05d68ef | |||
| 9b6b1c376c | |||
| e5e26f45fa | |||
| b794583b7a | |||
| ca96bd8f62 | |||
| 0ecdf8d0cc | |||
| 5be6ba68a3 | |||
| 1bdd1e943f | |||
| c85a4abeb4 | |||
| 011b19225e | |||
| a3945dce7f | |||
| 8daca03996 | |||
| 777b8576f2 | |||
| 880755ec89 | |||
| 1c70089521 | |||
| fe2a8a9477 | |||
| eb2550ec3d | |||
| 9c3ac14cab | |||
| 6a8f900d75 | |||
| 407e472322 | |||
| 580b2e35e2 | |||
| 30c121509f | |||
| b3aee3857c | |||
| 10cbbb2c6b | |||
| 08365b3e0b | |||
| cb4c61b1ca | |||
| 2d9ff863b7 | |||
| ae9b20a12b | |||
| c16e271c14 | |||
| b940cef0e4 | |||
| bc228de20e | |||
| a9cac3206a |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Checkpoints multiroot pt.1: Accept array of workspaces when initializting checkpoints
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: automatically retry on rate limit errors with SAP AI Core provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Run Testing platform within Test workflow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: preserve reasoning traces for cline/openrouter/anthropic providers to maintain conversation integrity
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
remove temperature settings in z.ai models
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Empty Pr to bump changeset
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @dcbartlett
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/.github/ @saoudrizwan
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "User-Agent: cline-pr-trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/cline/intellij-plugin/dispatches \
|
||||
-d '{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": "${{ github.event.pull_request.title }}",
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Log trigger details
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.0]
|
||||
|
||||
- Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
|
||||
- Changes to inform Cline about commands that are available on your system
|
||||
|
||||
## [3.31.1]
|
||||
|
||||
- Version bump
|
||||
|
||||
## [3.31.0]
|
||||
|
||||
- UI Improvements: New task header and focus chain design to take up less space for a cleaner experience
|
||||
- Voice Mode: Experimental feature that must be enabled in settings for hands-free coding
|
||||
- YOLO Mode: Enable in settings to let Cline approve all actions and automatically switch between plan/act mode
|
||||
- Fix Oracle Code Assist provider issues
|
||||
|
||||
## [3.30.3]
|
||||
|
||||
- Add Oracle Code Assist provider
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"features/focus-chain",
|
||||
"features/auto-compact",
|
||||
"features/editing-messages",
|
||||
"features/dictation",
|
||||
{
|
||||
"group": "@ Mentions",
|
||||
"pages": [
|
||||
@@ -121,6 +122,13 @@
|
||||
"features/commands-and-shortcuts/git-integration",
|
||||
"features/commands-and-shortcuts/keyboard-shortcuts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Customization",
|
||||
"pages": [
|
||||
"features/customization/opening-cline-in-sidebar",
|
||||
"features/customization/disable-terminal-pagers"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Disable Terminal Pagers During Cline Sessions"
|
||||
description: "Make CLI output non-interactive when Cline runs commands by detecting the CLINE_ACTIVE environment variable and disabling pagers like less."
|
||||
---
|
||||
|
||||
Many CLI tools (like Git) use a pager such as `less` for interactive, scrollable output. When Cline runs commands in your terminal, that interactivity gets in the way — the pager can pause on the first page and block progress. You can configure your shell so that when a terminal is spawned by Cline, pagers are disabled and output streams through normally.
|
||||
|
||||
## How it works
|
||||
|
||||
Cline sets an environment variable for terminals it opens to run commands:
|
||||
|
||||
- `CLINE_ACTIVE` — non-empty when the shell is running under Cline
|
||||
|
||||
You can detect this variable in your shell startup file and adjust environment variables or aliases only for Cline-run sessions. This keeps your normal interactive terminals unchanged.
|
||||
|
||||
## Quick setup (Zsh/Bash)
|
||||
|
||||
Add the following to your `~/.zshrc`, `~/.bashrc`, or `~/.bash_profile`:
|
||||
|
||||
```bash
|
||||
# Disable pagers when the terminal is launched by Cline
|
||||
if [[ -n "$CLINE_ACTIVE" ]]; then
|
||||
export PAGER=cat
|
||||
export GIT_PAGER=cat
|
||||
export SYSTEMD_PAGER=cat
|
||||
export LESS="-FRX"
|
||||
fi
|
||||
```
|
||||
|
||||
<Note>
|
||||
- `PAGER=cat` ensures generic pager-aware tools print directly to stdout
|
||||
- `GIT_PAGER=cat` prevents Git from invoking `less`
|
||||
- `SYSTEMD_PAGER=cat` disables paging in systemd tools (if present)
|
||||
- `LESS="-FRX"` makes `less` behave more like streaming output if a tool still calls it
|
||||
</Note>
|
||||
|
||||
This configuration only applies when `CLINE_ACTIVE` is set, so your normal terminals keep their usual interactive behavior.
|
||||
|
||||
## Verify
|
||||
|
||||
- Open a task in Cline that runs terminal commands and check:
|
||||
- `echo "$CLINE_ACTIVE"` prints a non-empty value
|
||||
- `git log` or other long outputs should stream without pausing
|
||||
- If changes don't take effect:
|
||||
- Make sure you updated the correct startup file for your shell
|
||||
- Restart VS Code/Cursor so integrated terminals reload your shell config
|
||||
- Confirm your terminal profile sources your `~/.zshrc` or `~/.bashrc`
|
||||
|
||||
## Optional tweaks
|
||||
|
||||
- Prefer command-line options when you don't want to rely on env vars:
|
||||
|
||||
```bash
|
||||
# One-off usage (no aliases)
|
||||
git --no-pager log -n 50 --decorate --oneline
|
||||
systemctl --no-pager status nginx
|
||||
journalctl --no-pager -u nginx -n 200
|
||||
less -FRX README.md
|
||||
```
|
||||
|
||||
- You can also override paging via shell aliases scoped to Cline sessions using options rather than env vars:
|
||||
|
||||
```bash
|
||||
if [[ -n "$CLINE_ACTIVE" ]]; then
|
||||
# Make 'less' non-interactive by default
|
||||
alias less='less -FRX'
|
||||
# Disable paging for common tools via CLI flags
|
||||
alias git='command git --no-pager'
|
||||
alias systemctl='command systemctl --no-pager'
|
||||
alias journalctl='command journalctl --no-pager'
|
||||
fi
|
||||
```
|
||||
|
||||
- If you prefer environment variables, many CLIs also respect a generic or tool-specific pager variable:
|
||||
- Git: `GIT_PAGER=cat`
|
||||
- Systemd: `SYSTEMD_PAGER=cat`
|
||||
- Man pages: `MANPAGER=cat` (not typically needed for Cline-driven commands)
|
||||
|
||||
- Aliases affect the current interactive shell, while environment variables propagate to child processes. Choose the approach that best fits your workflow.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "Opening Cline in the Right Sidebar"
|
||||
description: "Learn how to open Cline in the right sidebar in VS Code and Cursor"
|
||||
---
|
||||
|
||||
By default, when you first install Cline, it appears in VS Code's left sidebar alongside your file explorer and other extensions. However, for a better coding experience, we recommend moving Cline to the right sidebar. This allows you to keep your project files visible in the left sidebar while chatting with Cline on the right, giving you full visibility of your codebase as Cline works on your project.
|
||||
|
||||
## VS Code
|
||||
|
||||
To open Cline in the right sidebar:
|
||||
|
||||
<Steps>
|
||||
<Step title="Align Extension View">
|
||||
Make sure your extension view is aligned vertically to the left
|
||||
</Step>
|
||||
<Step title="Open Right Side View">
|
||||
Click the button that opens the right side panel in VS Code (typically used to open GitHub Copilot chat). Optionally use the `Option + CMD/Ctrl + B` shortcut.
|
||||
</Step>
|
||||
<Step title="Drag Cline Icon">
|
||||
Drag the Cline icon over to the nav panel at the top of that right view
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/vscode_right_view.gif"
|
||||
alt="VS Code Right Sidebar Setup"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
## Cursor
|
||||
|
||||
To open Cline in the right sidebar:
|
||||
|
||||
<Steps>
|
||||
<Step title="Align Extensions">
|
||||
Cursor uses a horizontal activity bar by default to optimize space for the AI chat interface ([see here for details](https://cursor.com/docs/configuration/migrations/vscode#activity-bar-orientation)). To switch to vertical:
|
||||
|
||||
1. Open the Command Palette (`CMD/Ctrl + Shift + P`)
|
||||
2. Search for "Preferences: Open Settings (UI)"
|
||||
3. Search for `workbench.activityBar.orientation`
|
||||
4. Set the value to `vertical`
|
||||
5. Restart Cursor for the changes to take effect
|
||||
</Step>
|
||||
<Step title="Open Agent Panel">
|
||||
Click the Cursor cube icon button that opens Cursor's agent (right side view panel)
|
||||
</Step>
|
||||
<Step title="Drag to Three Dots">
|
||||
Drag the Cline icon directly onto the three dots button - it doesn't work if you just drag it to the top, it has to be the three dots
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/cursor-side-bar.gif"
|
||||
alt="Cursor Right Sidebar Setup"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
Once set up, Cline will load on the right side and you can use it as normal.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Dictation
|
||||
description:
|
||||
---
|
||||
|
||||
Cline lets you transcribe speech to text in an easy, built-in service
|
||||
|
||||
## Get Started
|
||||
|
||||
1. **Enable Dictation** in Feature Settings.
|
||||
2. **Click the microphone** in the chat input area.
|
||||
3. **Speak** - the button turns red while recording.
|
||||
4. **Click Stop Recording** when done.
|
||||
5. **Wait for transcription** - "[Transcribing...]" will appear and then the finished transcription will appear.
|
||||
|
||||
## Settings
|
||||
|
||||
Enable or disable dictation in Feature Settings by toggling "Enable Dictation." You can also change the transcription language to one of the supported languages.
|
||||
|
||||
## Requirements
|
||||
|
||||
Cline uses FFmpeg to capture your voice across all platforms:
|
||||
|
||||
- **macOS**: FFmpeg (via Homebrew: `brew install ffmpeg`)
|
||||
- **Linux**: FFmpeg (via apt: `sudo apt-get install ffmpeg`)
|
||||
- **Windows**: FFmpeg (via winget: `winget install Gyan.FFmpeg`)
|
||||
|
||||
If you don't have FFmpeg installed, Cline will automatically detect this and prompt you to install it with a single click.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Independent from Chat Provider
|
||||
|
||||
The voice transcription feature works completely independently from whatever chat provider you're using. You can use Claude, GPT-4, or any other model for your conversations, and voice transcription will always use Cline's own transcription service. As long as you have a valid Cline account with enough credits, dictation will work regardless of your chat model choice.
|
||||
|
||||
### Audio Format
|
||||
|
||||
Voice recordings are captured in WebM format using the Opus codec for optimal compression. The system records in mono (single channel) at a 16kHz sample rate, which is specifically optimized for voice recognition. The bitrate is set to 32kbps to keep file sizes efficient while maintaining good audio quality.
|
||||
|
||||
### Privacy & Security
|
||||
|
||||
Your audio is recorded locally on your machine and only the audio file itself is sent to Cline's transcription service for processing. No audio is stored anywhere after transcription is complete, and all temporary files are automatically cleaned up to protect your privacy.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`Failed to start recording` - Audio recording tools aren't installed. Cline will prompt you to install FFmpeg - just follow the chat instructions.
|
||||
|
||||
`Invalid audio format or request data` - Usually an audio recording issue. Make sure FFmpeg is properly installed and working.
|
||||
|
||||
`Authentication failed` - You need to reauthenticate your Cline account. Sign out and back in, then check your internet connection.
|
||||
|
||||
`Insufficient credits for transcription service` - Your Cline account doesn't have enough credits. Check your balance and purchase more if needed.
|
||||
|
||||
`Cannot connect to transcription service` - Connection issue. Check your internet connection and firewall settings aren't blocking Cline's servers.
|
||||
|
||||
## API Usage
|
||||
|
||||
Voice transcription uses Cline's transcription service, which requires credits from your Cline account. Currently, voice transcription is billed at $0.006 per minute of audio.
|
||||
|
||||
**Note:** We are still experimenting with this feature and pricing may change in the future.
|
||||
@@ -7,7 +7,7 @@ title: "Configuring MCP Servers"
|
||||
Utilizing MCP servers will increase your token usage. Cline offers the ability to restrict or disable MCP server functionality as desired.
|
||||
|
||||
1. Click the "MCP Servers" icon in the top navigation bar of the Cline extension.
|
||||
2. Select the "Installed" tab, and then Click the "Advanced MCP Settings" link at the bottom of that pane.
|
||||
2. Select the "Configure" tab, and then Click the "Advanced MCP Settings" link at the bottom of that pane.
|
||||
3. Cline will open a new settings window. find `Cline>Mcp:Mode` and make your selection from the dropdown menu.
|
||||
|
||||
<Frame>
|
||||
@@ -56,7 +56,7 @@ To set the maximum time to wait for a response after a tool call to the MCP serv
|
||||
Settings for all installed MCP servers are located in the `cline_mcp_settings.json` file:
|
||||
|
||||
1. Click the MCP Servers icon at the top navigation bar of the Cline pane.
|
||||
2. Select the "Installed" tab.
|
||||
2. Select the "Configure" tab.
|
||||
3. Click the "Configure MCP Servers" button at the bottom of the pane.
|
||||
|
||||
The file uses a JSON format with a `mcpServers` object containing named server configurations:
|
||||
|
||||
@@ -54,7 +54,7 @@ Please note: Smithery is maintained independently and is not affiliated with our
|
||||
|
||||
### Managing Installed MCP Servers
|
||||
|
||||
Once added, your MCP servers appear in the "Installed" tab where you can:
|
||||
Once added, your MCP servers appear in the "Configure" tab where you can:
|
||||
|
||||
#### View Server Status
|
||||
|
||||
@@ -98,7 +98,7 @@ If a server fails to connect:
|
||||
|
||||
For advanced users, Cline stores MCP server configurations in a JSON file that can be modified:
|
||||
|
||||
1. In the "Installed" tab, click "Configure MCP Servers" to access the settings file
|
||||
1. In the "Configure" tab, click "Configure MCP Servers" to access the settings file
|
||||
2. The configuration for each server follows this format:
|
||||
|
||||
```json
|
||||
|
||||
Generated
+4
-4
@@ -1386,7 +1386,7 @@
|
||||
"integrity": "sha512-/uR4hAwpcJW9+zbmZL48kKFnWLkOxhIqoGWvZzjg0CniVhR4emtQJAps80WqLAhz0iJgCQxg/axtA7leaznDzQ==",
|
||||
"license": "Elastic-2.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.3",
|
||||
"axios": "^1.12.0",
|
||||
"openapi-types": "^12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2688,9 +2688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
|
||||
+20
-14
@@ -6,15 +6,12 @@ import * as esbuild from "esbuild"
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const production = process.argv.includes("--production") || process.env["IS_DEBUG_BUILD"] === "false"
|
||||
const watch = process.argv.includes("--watch")
|
||||
const standalone = process.argv.includes("--standalone")
|
||||
const e2eBuild = process.argv.includes("--e2e-build")
|
||||
const destDir = standalone ? "dist-standalone" : "dist"
|
||||
|
||||
// Read package.json to get version for build-time injection
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
@@ -126,15 +123,29 @@ const copyWasmFiles = {
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars = { "import.meta.url": "_importMetaUrl" }
|
||||
if (production) {
|
||||
// IS_DEV is always disable in production builds.
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
// Set the environment and telemetry env vars. The API key env vars need to be populated in the GitHub
|
||||
// workflows from the secrets.
|
||||
if (process.env.CLINE_ENVIRONMENT) {
|
||||
buildEnvVars["process.env.CLINE_ENVIRONMENT"] = JSON.stringify(process.env.CLINE_ENVIRONMENT)
|
||||
}
|
||||
if (process.env.TELEMETRY_SERVICE_API_KEY) {
|
||||
buildEnvVars["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY)
|
||||
}
|
||||
if (process.env.ERROR_SERVICE_API_KEY) {
|
||||
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
|
||||
}
|
||||
// Base configuration shared between extension and standalone builds
|
||||
const baseConfig = {
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: production
|
||||
? { "import.meta.url": "_importMetaUrl", "process.env.IS_DEV": JSON.stringify(!production) }
|
||||
: { "import.meta.url": "_importMetaUrl" },
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
plugins: [
|
||||
copyWasmFiles,
|
||||
@@ -163,14 +174,9 @@ const standaloneConfig = {
|
||||
...baseConfig,
|
||||
entryPoints: ["src/standalone/cline-core.ts"],
|
||||
outfile: `${destDir}/cline-core.js`,
|
||||
// These gRPC protos need to load files from the module directory at runtime,
|
||||
// so they cannot be bundled. better-sqlite3 is a native module that also cannot be bundled.
|
||||
// These modules need to load files from the module directory at runtime,
|
||||
// so they cannot be bundled.
|
||||
external: ["vscode", "@grpc/reflection", "grpc-health-check", "better-sqlite3"],
|
||||
// Inject version at build time for standalone builds
|
||||
define: {
|
||||
...baseConfig.define,
|
||||
"process.env.CLINE_VERSION": JSON.stringify(packageJson.version),
|
||||
},
|
||||
}
|
||||
|
||||
// E2E build script configuration
|
||||
|
||||
Generated
+10
-9
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"chalk": "5.6.2",
|
||||
"commander": "^9.4.1",
|
||||
@@ -200,12 +200,13 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1667,12 +1668,12 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"chalk": "5.6.2",
|
||||
"dotenv": "^16.5.0",
|
||||
|
||||
Generated
+25
-33
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.30.3",
|
||||
"version": "3.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.30.3",
|
||||
"version": "3.32.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -35,8 +35,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"axios": "^1.12.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
@@ -6280,20 +6279,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.4.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
||||
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"license": "Unlicense",
|
||||
@@ -6341,15 +6326,6 @@
|
||||
"url": "https://bevry.me/fund"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.4.7",
|
||||
"license": "MIT"
|
||||
@@ -7302,6 +7278,7 @@
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
@@ -7315,6 +7292,7 @@
|
||||
},
|
||||
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -7336,6 +7314,7 @@
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
@@ -8287,6 +8266,7 @@
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"dev": true,
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -8555,12 +8535,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-keys": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
@@ -9130,6 +9104,7 @@
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
@@ -9566,6 +9541,7 @@
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
@@ -11153,6 +11129,7 @@
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mocha": {
|
||||
@@ -11380,6 +11357,7 @@
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
@@ -11442,6 +11420,7 @@
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.75.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
@@ -12580,6 +12559,7 @@
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
@@ -12604,6 +12584,7 @@
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
@@ -12613,6 +12594,7 @@
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -12635,10 +12617,12 @@
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -12646,6 +12630,7 @@
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
@@ -12658,6 +12643,7 @@
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
@@ -12668,6 +12654,7 @@
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
@@ -12912,6 +12899,7 @@
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"dev": true,
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
@@ -12936,6 +12924,7 @@
|
||||
},
|
||||
"node_modules/rc/node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -13815,6 +13804,7 @@
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -13833,6 +13823,7 @@
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -14750,6 +14741,7 @@
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
|
||||
+7
-3
@@ -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": "3.30.3",
|
||||
"version": "3.32.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -200,6 +200,11 @@
|
||||
"command": "cline.openWalkthrough",
|
||||
"title": "Open Walkthrough",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.reconstructTaskHistory",
|
||||
"title": "Reconstruct Task History",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
@@ -452,8 +457,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"axios": "^1.12.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
service DictationService {
|
||||
rpc startRecording(EmptyRequest) returns (RecordingResult);
|
||||
rpc stopRecording(EmptyRequest) returns (RecordedAudio);
|
||||
rpc cancelRecording(EmptyRequest) returns (RecordingResult);
|
||||
rpc getRecordingStatus(EmptyRequest) returns (RecordingStatus);
|
||||
rpc transcribeAudio(TranscribeAudioRequest) returns (Transcription);
|
||||
}
|
||||
|
||||
message TranscribeAudioRequest {
|
||||
string audio_base64 = 2;
|
||||
string language = 3;
|
||||
}
|
||||
|
||||
message RecordingResult {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message RecordedAudio {
|
||||
bool success = 1;
|
||||
string audio_base64 = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
message RecordingStatus {
|
||||
bool is_recording = 1;
|
||||
double duration_seconds = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
message Transcription {
|
||||
string text = 1;
|
||||
string error = 2;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ service FileService {
|
||||
rpc refreshRules(EmptyRequest) returns (RefreshedRules);
|
||||
|
||||
// Opens a task's conversation history file on disk
|
||||
rpc openTaskHistory(StringRequest) returns (Empty);
|
||||
rpc openDiskConversationHistory(StringRequest) returns (Empty);
|
||||
|
||||
// Toggles a workflow on or off
|
||||
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
|
||||
|
||||
+62
-39
@@ -1,15 +1,14 @@
|
||||
syntax = "proto3";
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "cline/models.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
|
||||
service StateService {
|
||||
rpc getLatestState(EmptyRequest) returns (State);
|
||||
rpc updateTerminalConnectionTimeout(UpdateTerminalConnectionTimeoutRequest) returns (UpdateTerminalConnectionTimeoutResponse);
|
||||
rpc updateTerminalReuseEnabled(BooleanRequest) returns (Empty);
|
||||
rpc updateDefaultTerminalProfile(StringRequest) returns (TerminalProfileUpdateResponse);
|
||||
rpc getAvailableTerminalProfiles(EmptyRequest) returns (TerminalProfiles);
|
||||
rpc subscribeToState(EmptyRequest) returns (stream State);
|
||||
rpc toggleFavoriteModel(StringRequest) returns (Empty);
|
||||
@@ -19,9 +18,13 @@ service StateService {
|
||||
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
|
||||
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
|
||||
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
|
||||
rpc getProcessInfo(EmptyRequest) returns (ProcessInfo);
|
||||
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
|
||||
}
|
||||
message DictationSettings {
|
||||
bool feature_enabled = 1;
|
||||
bool dictation_enabled = 2;
|
||||
string dictation_language = 3;
|
||||
}
|
||||
|
||||
message State {
|
||||
string state_json = 1;
|
||||
}
|
||||
@@ -58,6 +61,7 @@ enum OpenaiReasoningEffort {
|
||||
LOW = 0;
|
||||
MEDIUM = 1;
|
||||
HIGH = 2;
|
||||
MINIMAL = 3;
|
||||
}
|
||||
|
||||
enum McpDisplayMode {
|
||||
@@ -108,6 +112,16 @@ message TelemetrySettingRequest {
|
||||
TelemetrySettingEnum setting = 2;
|
||||
}
|
||||
|
||||
// Browser settings for UpdateSettingsRequest
|
||||
message BrowserSettingsUpdate {
|
||||
optional Viewport viewport = 1;
|
||||
optional string remote_browser_host = 2;
|
||||
optional bool remote_browser_enabled = 3;
|
||||
optional string chrome_executable_path = 4;
|
||||
optional bool disable_tool_use = 5;
|
||||
optional string custom_args = 6;
|
||||
}
|
||||
|
||||
// Message for updating settings
|
||||
message UpdateSettingsRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -128,6 +142,12 @@ message UpdateSettingsRequest {
|
||||
optional FocusChainSettings focus_chain_settings = 17;
|
||||
optional bool use_auto_condense = 18;
|
||||
optional string custom_prompt = 19;
|
||||
optional BrowserSettingsUpdate browser_settings = 20;
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional int32 auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
}
|
||||
|
||||
// Complete API Configuration message
|
||||
@@ -139,10 +159,10 @@ message ApiConfiguration {
|
||||
optional string lite_llm_base_url = 4;
|
||||
optional string lite_llm_api_key = 5;
|
||||
optional bool lite_llm_use_prompt_cache = 6;
|
||||
optional string openai_headers = 7; // JSON string
|
||||
map<string, string> open_ai_headers = 7;
|
||||
optional string anthropic_base_url = 8;
|
||||
optional string openrouter_api_key = 9;
|
||||
optional string openrouter_provider_sorting = 10;
|
||||
optional string open_router_api_key = 9;
|
||||
optional string open_router_provider_sorting = 10;
|
||||
optional string aws_access_key = 11;
|
||||
optional string aws_secret_key = 12;
|
||||
optional string aws_session_token = 13;
|
||||
@@ -155,14 +175,14 @@ message ApiConfiguration {
|
||||
optional string claude_code_path = 20;
|
||||
optional string vertex_project_id = 21;
|
||||
optional string vertex_region = 22;
|
||||
optional string openai_base_url = 23;
|
||||
optional string openai_api_key = 24;
|
||||
optional string open_ai_base_url = 23;
|
||||
optional string open_ai_api_key = 24;
|
||||
optional string ollama_base_url = 25;
|
||||
optional string ollama_api_options_ctx_num = 26;
|
||||
optional string lm_studio_base_url = 27;
|
||||
optional string gemini_api_key = 28;
|
||||
optional string gemini_base_url = 29;
|
||||
optional string openai_native_api_key = 30;
|
||||
optional string open_ai_native_api_key = 30;
|
||||
optional string deep_seek_api_key = 31;
|
||||
optional string requesty_api_key = 32;
|
||||
optional string requesty_base_url = 33;
|
||||
@@ -198,61 +218,65 @@ message ApiConfiguration {
|
||||
optional string qwen_code_oauth_path = 63;
|
||||
optional string dify_api_key = 64;
|
||||
optional string dify_base_url = 65;
|
||||
optional string oca_base_url = 66;
|
||||
optional string oca_api_key = 67;
|
||||
optional string oca_refresh_token = 68;
|
||||
|
||||
// Plan mode configurations
|
||||
optional string plan_mode_api_provider = 100;
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_model_id = 101;
|
||||
optional int32 plan_mode_thinking_budget_tokens = 102;
|
||||
optional string plan_mode_reasoning_effort = 103;
|
||||
optional string plan_mode_vscode_lm_model_selector = 104; // JSON string
|
||||
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 104;
|
||||
optional bool plan_mode_aws_bedrock_custom_selected = 105;
|
||||
optional string plan_mode_aws_bedrock_custom_model_base_id = 106;
|
||||
optional string plan_mode_openrouter_model_id = 107;
|
||||
optional string plan_mode_openrouter_model_info = 108; // JSON string
|
||||
optional string plan_mode_openai_model_id = 109;
|
||||
optional string plan_mode_openai_model_info = 110; // JSON string
|
||||
optional string plan_mode_open_router_model_id = 107;
|
||||
optional OpenRouterModelInfo plan_mode_open_router_model_info = 108;
|
||||
optional string plan_mode_open_ai_model_id = 109;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 110;
|
||||
optional string plan_mode_ollama_model_id = 111;
|
||||
optional string plan_mode_lm_studio_model_id = 112;
|
||||
optional string plan_mode_lite_llm_model_id = 113;
|
||||
optional string plan_mode_lite_llm_model_info = 114; // JSON string
|
||||
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 114;
|
||||
optional string plan_mode_requesty_model_id = 115;
|
||||
optional string plan_mode_requesty_model_info = 116; // JSON string
|
||||
optional OpenRouterModelInfo plan_mode_requesty_model_info = 116;
|
||||
optional string plan_mode_together_model_id = 117;
|
||||
optional string plan_mode_fireworks_model_id = 118;
|
||||
optional string plan_mode_sap_ai_core_model_id = 119;
|
||||
optional string plan_mode_huawei_cloud_maas_model_id = 120;
|
||||
optional string plan_mode_huawei_cloud_maas_model_info = 121;
|
||||
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 121;
|
||||
optional string plan_mode_vercel_ai_gateway_model_id = 122;
|
||||
optional string plan_mode_vercel_ai_gateway_model_info = 123;
|
||||
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 123;
|
||||
optional string plan_mode_oca_model_id = 124;
|
||||
optional OcaModelInfo plan_mode_oca_model_info = 125;
|
||||
|
||||
// Act mode configurations
|
||||
optional string act_mode_api_provider = 200;
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
optional string act_mode_api_model_id = 201;
|
||||
optional int32 act_mode_thinking_budget_tokens = 202;
|
||||
optional string act_mode_reasoning_effort = 203;
|
||||
optional string act_mode_vscode_lm_model_selector = 204; // JSON string
|
||||
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 204;
|
||||
optional bool act_mode_aws_bedrock_custom_selected = 205;
|
||||
optional string act_mode_aws_bedrock_custom_model_base_id = 206;
|
||||
optional string act_mode_openrouter_model_id = 207;
|
||||
optional string act_mode_openrouter_model_info = 208; // JSON string
|
||||
optional string act_mode_openai_model_id = 209;
|
||||
optional string act_mode_openai_model_info = 210; // JSON string
|
||||
optional string act_mode_open_router_model_id = 207;
|
||||
optional OpenRouterModelInfo act_mode_open_router_model_info = 208;
|
||||
optional string act_mode_open_ai_model_id = 209;
|
||||
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 210;
|
||||
optional string act_mode_ollama_model_id = 211;
|
||||
optional string act_mode_lm_studio_model_id = 212;
|
||||
optional string act_mode_lite_llm_model_id = 213;
|
||||
optional string act_mode_lite_llm_model_info = 214; // JSON string
|
||||
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 214;
|
||||
optional string act_mode_requesty_model_id = 215;
|
||||
optional string act_mode_requesty_model_info = 216; // JSON string
|
||||
optional OpenRouterModelInfo act_mode_requesty_model_info = 216;
|
||||
optional string act_mode_together_model_id = 217;
|
||||
optional string act_mode_fireworks_model_id = 218;
|
||||
optional string act_mode_sap_ai_core_model_id = 219;
|
||||
optional string act_mode_huawei_cloud_maas_model_id = 220;
|
||||
optional string act_mode_huawei_cloud_maas_model_info = 221;
|
||||
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 221;
|
||||
optional string act_mode_vercel_ai_gateway_model_id = 222;
|
||||
optional string act_mode_vercel_ai_gateway_model_info = 223;
|
||||
|
||||
// Favorited model IDs
|
||||
repeated string favorited_model_ids = 300;
|
||||
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 223;
|
||||
optional string act_mode_oca_model_id = 224;
|
||||
optional OcaModelInfo act_mode_oca_model_info = 225;
|
||||
|
||||
// Extension fields for Bedrock Api Keys
|
||||
optional string aws_authentication = 301;
|
||||
@@ -270,12 +294,11 @@ message FocusChainSettings {
|
||||
int32 remind_cline_interval = 2;
|
||||
}
|
||||
|
||||
message Viewport {
|
||||
int32 width = 1;
|
||||
int32 height = 2;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutResponse {
|
||||
optional int32 timeout_ms = 1;
|
||||
}
|
||||
|
||||
message ProcessInfo {
|
||||
int32 process_id = 1;
|
||||
optional string version = 2;
|
||||
optional int64 uptime_ms = 3;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { initializeDistinctId } from "./services/logging/distinctId"
|
||||
@@ -99,6 +100,9 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
* Performs cleanup when Cline is deactivated that is common to all platforms.
|
||||
*/
|
||||
export async function tearDown(): Promise<void> {
|
||||
// Clean up audio recording service to ensure no orphaned processes
|
||||
audioRecordingService.cleanup()
|
||||
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
telemetryService.dispose()
|
||||
ErrorService.get().dispose()
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export enum Environment {
|
||||
local = "local",
|
||||
}
|
||||
|
||||
interface EnvironmentConfig {
|
||||
export interface EnvironmentConfig {
|
||||
appBaseUrl: string
|
||||
apiBaseUrl: string
|
||||
mcpBaseUrl: string
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ApiProviderInfo {
|
||||
providerId: string
|
||||
model: ApiHandlerModel
|
||||
customPrompt?: string // "compact"
|
||||
autoCondenseThreshold?: number // 0-1 range
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -150,6 +150,8 @@ export class AnthropicHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
let thinkingDeltaAccumulator = ""
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk?.type) {
|
||||
case "message_start":
|
||||
@@ -182,14 +184,26 @@ export class AnthropicHandler implements ApiHandler {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
const thinking = chunk.content_block.thinking
|
||||
const signature = chunk.content_block.signature
|
||||
if (thinking && signature) {
|
||||
yield {
|
||||
type: "ant_thinking",
|
||||
thinking,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
// Content is encrypted, and we don't to pass placeholder text back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
yield {
|
||||
type: "ant_redacted_thinking",
|
||||
data: chunk.content_block.data,
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
// we may receive multiple text blocks, in which case just insert a line break between them
|
||||
@@ -209,10 +223,23 @@ export class AnthropicHandler implements ApiHandler {
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
thinkingDeltaAccumulator += chunk.delta.thinking
|
||||
break
|
||||
case "signature_delta":
|
||||
// It's used when sending the thinking block back to the API
|
||||
// API expects this in completed form, not as array of deltas
|
||||
if (thinkingDeltaAccumulator && chunk.delta.signature) {
|
||||
yield {
|
||||
type: "ant_thinking",
|
||||
thinking: thinkingDeltaAccumulator,
|
||||
signature: chunk.delta.signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
@@ -220,10 +247,6 @@ export class AnthropicHandler implements ApiHandler {
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
case "signature_delta":
|
||||
// We don't need to do anything with the signature in the client
|
||||
// It's used when sending the thinking block back to the API
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
|
||||
@@ -162,7 +162,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
|
||||
if (this.getModel().id === "cline/code-supernova") {
|
||||
if (this.getModel().id === "cline/code-supernova-1-million") {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
@@ -200,14 +200,13 @@ export class ClineHandler implements ApiHandler {
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
// TODO: replace this with firebase auth
|
||||
// TODO: use global API Host
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
}
|
||||
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
// Align with backend auth expectations
|
||||
Authorization: `Bearer ${clineAccountAuthToken}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
|
||||
@@ -122,6 +122,21 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
|
||||
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
|
||||
if (
|
||||
"reasoning_details" in delta &&
|
||||
delta.reasoning_details &&
|
||||
// @ts-ignore-next-line
|
||||
delta.reasoning_details.length && // exists and non-0
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning_details",
|
||||
reasoning_details: delta.reasoning_details,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ModelInfo, SapAiCoreModelId, sapAiCoreDefaultModelId, sapAiCoreModels }
|
||||
import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
@@ -454,6 +455,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
return this.deployments?.some((d) => d.name.split(":")[0].toLowerCase() === modelId.split(":")[0].toLowerCase()) ?? false
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
if (this.options.sapAiCoreUseOrchestrationMode) {
|
||||
yield* this.createMessageWithOrchestration(systemPrompt, messages)
|
||||
@@ -823,16 +825,13 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
// Handle metadata (token usage)
|
||||
if (data.metadata?.usage) {
|
||||
// inputTokens does not include cached write/read tokens
|
||||
let inputTokens = data.metadata.usage.inputTokens || 0
|
||||
const outputTokens = data.metadata.usage.outputTokens || 0
|
||||
|
||||
// calibrate input token
|
||||
const totalTokens = data.metadata.usage.totalTokens || 0
|
||||
const cacheReadInputTokens = data.metadata.usage.cacheReadInputTokens || 0
|
||||
const cacheWriteOutputTokens = data.metadata.usage.cacheWriteOutputTokens || 0
|
||||
if (inputTokens + outputTokens + cacheReadInputTokens + cacheWriteOutputTokens !== totalTokens) {
|
||||
inputTokens = totalTokens - outputTokens - cacheReadInputTokens - cacheWriteOutputTokens
|
||||
}
|
||||
const cacheWriteInputTokens = data.metadata.usage.cacheWriteInputTokens || 0
|
||||
inputTokens = inputTokens + cacheReadInputTokens + cacheWriteInputTokens
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
|
||||
@@ -115,7 +115,15 @@ export function convertToOpenAiMessages(
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string | undefined
|
||||
const reasoningDetails: any[] = []
|
||||
if (nonToolMessages.length > 0) {
|
||||
nonToolMessages.forEach((part) => {
|
||||
// @ts-ignore-next-line
|
||||
if (part.type === "text" && part.reasoning_details) {
|
||||
// @ts-ignore-next-line
|
||||
reasoningDetails.push(part.reasoning_details)
|
||||
}
|
||||
})
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
@@ -142,6 +150,8 @@ export function convertToOpenAiMessages(
|
||||
content,
|
||||
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
|
||||
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
|
||||
// @ts-ignore-next-line
|
||||
reasoning_details: reasoningDetails,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export type ApiStream = AsyncGenerator<ApiStreamChunk>
|
||||
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamReasoningChunk | ApiStreamUsageChunk
|
||||
export type ApiStreamChunk =
|
||||
| ApiStreamTextChunk
|
||||
| ApiStreamReasoningChunk
|
||||
| ApiStreamReasoningDetailsChunk
|
||||
| ApiStreamAnthropicThinkingChunk
|
||||
| ApiStreamAnthropicRedactedThinkingChunk
|
||||
| ApiStreamUsageChunk
|
||||
|
||||
export interface ApiStreamTextChunk {
|
||||
type: "text"
|
||||
@@ -11,6 +17,22 @@ export interface ApiStreamReasoningChunk {
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
export interface ApiStreamReasoningDetailsChunk {
|
||||
type: "reasoning_details"
|
||||
reasoning_details: any // openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
|
||||
}
|
||||
|
||||
export interface ApiStreamAnthropicThinkingChunk {
|
||||
type: "ant_thinking"
|
||||
thinking: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface ApiStreamAnthropicRedactedThinkingChunk {
|
||||
type: "ant_redacted_thinking"
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface ApiStreamUsageChunk {
|
||||
type: "usage"
|
||||
inputTokens: number
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
ensureTaskDirectoryExists,
|
||||
getSavedClineMessages,
|
||||
getTaskMetadata,
|
||||
readTaskHistoryFromState,
|
||||
writeTaskHistoryToState,
|
||||
} from "@core/storage/disk"
|
||||
import { HostProvider } from "@hosts/host-provider"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { ShowMessageType } from "@shared/proto/host/window"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import * as path from "path"
|
||||
import { ulid } from "ulid"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
interface TaskReconstructionResult {
|
||||
totalTasks: number
|
||||
reconstructedTasks: number
|
||||
skippedTasks: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs task history from existing task folders
|
||||
*/
|
||||
export async function reconstructTaskHistory(context: vscode.ExtensionContext): Promise<void> {
|
||||
try {
|
||||
// Show confirmation dialog using HostProvider
|
||||
const proceed = await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message:
|
||||
"This will rebuild your task history from existing task data. This operation will backup your current task history and attempt to reconstruct it from task folders. Continue?",
|
||||
options: {
|
||||
items: ["Yes, Reconstruct", "Cancel"],
|
||||
},
|
||||
})
|
||||
|
||||
if (proceed?.selectedOption !== "Yes, Reconstruct") {
|
||||
return
|
||||
}
|
||||
|
||||
// Show initial progress message
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Reconstructing task history...",
|
||||
})
|
||||
|
||||
const result = await performTaskHistoryReconstruction(context)
|
||||
|
||||
// Show results
|
||||
if (result.errors.length > 0) {
|
||||
const errorMessage = `Reconstruction completed with warnings:\n- Reconstructed: ${result.reconstructedTasks} tasks\n- Skipped: ${result.skippedTasks} tasks\n- Errors: ${result.errors.length}\n\nFirst few errors:\n${result.errors.slice(0, 3).join("\n")}`
|
||||
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message: errorMessage,
|
||||
})
|
||||
} else {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `Task history successfully reconstructed! Found and restored ${result.reconstructedTasks} tasks.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Failed to reconstruct task history: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function performTaskHistoryReconstruction(context: vscode.ExtensionContext): Promise<TaskReconstructionResult> {
|
||||
const result: TaskReconstructionResult = {
|
||||
totalTasks: 0,
|
||||
reconstructedTasks: 0,
|
||||
skippedTasks: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
// Backup existing task history
|
||||
await backupExistingTaskHistory(context)
|
||||
|
||||
// Get tasks directory
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
// Check if tasks directory exists
|
||||
if (!(await fileExistsAtPath(tasksDir))) {
|
||||
throw new Error("No tasks directory found. Nothing to reconstruct.")
|
||||
}
|
||||
|
||||
// Scan for task directories
|
||||
const taskIds = await scanTaskDirectories(tasksDir)
|
||||
result.totalTasks = taskIds.length
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
throw new Error("No task directories found. Nothing to reconstruct.")
|
||||
}
|
||||
|
||||
// Process each task
|
||||
const reconstructedItems: HistoryItem[] = []
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
try {
|
||||
const historyItem = await reconstructTaskHistoryItem(context, taskId)
|
||||
if (historyItem) {
|
||||
reconstructedItems.push(historyItem)
|
||||
result.reconstructedTasks++
|
||||
} else {
|
||||
result.skippedTasks++
|
||||
}
|
||||
} catch (error) {
|
||||
result.skippedTasks++
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
result.errors.push(`Task ${taskId}: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
reconstructedItems.sort((a, b) => b.ts - a.ts)
|
||||
|
||||
// Write reconstructed history
|
||||
await writeTaskHistoryToState(reconstructedItems)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function backupExistingTaskHistory(context: vscode.ExtensionContext): Promise<void> {
|
||||
try {
|
||||
const existingHistory = await readTaskHistoryFromState()
|
||||
if (existingHistory.length > 0) {
|
||||
const backupPath = path.join(context.globalStorageUri.fsPath, "state", `taskHistory.backup.${Date.now()}.json`)
|
||||
|
||||
// Ensure state directory exists
|
||||
const fs = await import("fs/promises")
|
||||
await fs.mkdir(path.dirname(backupPath), { recursive: true })
|
||||
await fs.writeFile(backupPath, JSON.stringify(existingHistory, null, 2))
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal error, just log it
|
||||
console.warn("Failed to backup existing task history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function scanTaskDirectories(tasksDir: string): Promise<string[]> {
|
||||
const fs = await import("fs/promises")
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => /^\d+$/.test(name)) // Only numeric task IDs
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to scan tasks directory: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function reconstructTaskHistoryItem(context: vscode.ExtensionContext, taskId: string): Promise<HistoryItem | null> {
|
||||
try {
|
||||
// Get task directory
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
|
||||
// Load UI messages to extract task info
|
||||
const clineMessages = await getSavedClineMessages(context, taskId)
|
||||
if (clineMessages.length === 0) {
|
||||
return null // Skip empty tasks
|
||||
}
|
||||
|
||||
// Load task metadata for token usage
|
||||
const metadata = await getTaskMetadata(context, taskId)
|
||||
|
||||
// Extract task information
|
||||
const taskInfo = extractTaskInformation(clineMessages, metadata)
|
||||
|
||||
// Create HistoryItem
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ulid: taskInfo.ulid || ulid(), // Generate new ULID if missing
|
||||
ts: taskInfo.timestamp,
|
||||
task: taskInfo.taskDescription,
|
||||
tokensIn: taskInfo.tokensIn,
|
||||
tokensOut: taskInfo.tokensOut,
|
||||
cacheWrites: taskInfo.cacheWrites,
|
||||
cacheReads: taskInfo.cacheReads,
|
||||
totalCost: taskInfo.totalCost,
|
||||
size: taskInfo.size,
|
||||
isFavorited: taskInfo.isFavorited,
|
||||
conversationHistoryDeletedRange: taskInfo.conversationHistoryDeletedRange,
|
||||
}
|
||||
|
||||
return historyItem
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to reconstruct task ${taskId}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskInfo {
|
||||
ulid?: string
|
||||
timestamp: number
|
||||
taskDescription: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
totalCost: number
|
||||
size?: number
|
||||
isFavorited?: boolean
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
}
|
||||
|
||||
function extractTaskInformation(clineMessages: ClineMessage[], metadata: any): TaskInfo {
|
||||
// Find the first user message (task description)
|
||||
const firstUserMessage = clineMessages.find((msg) => msg.type === "say" && msg.say === "text" && msg.text)
|
||||
|
||||
// Extract timestamp from first message or use task ID as fallback
|
||||
const timestamp = clineMessages.length > 0 ? clineMessages[0].ts : Date.now()
|
||||
|
||||
// Extract task description
|
||||
let taskDescription = "Untitled Task"
|
||||
if (firstUserMessage?.text) {
|
||||
// Clean up the task description
|
||||
const cleanText = firstUserMessage.text
|
||||
.replace(/<task>\s*/g, "")
|
||||
.replace(/\s*<\/task>/g, "")
|
||||
.trim()
|
||||
|
||||
const firstLine = cleanText.split("\n")[0]
|
||||
if (firstLine) {
|
||||
taskDescription = firstLine.substring(0, 100) // Limit length
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate token usage from API request messages
|
||||
let tokensIn = 0
|
||||
let tokensOut = 0
|
||||
let cacheWrites = 0
|
||||
let cacheReads = 0
|
||||
let totalCost = 0
|
||||
|
||||
// Look for api_req_started messages with token info
|
||||
const apiReqMessages = clineMessages.filter((msg) => msg.type === "say" && msg.say === "api_req_started" && msg.text)
|
||||
|
||||
for (const msg of apiReqMessages) {
|
||||
try {
|
||||
if (msg.text) {
|
||||
const apiInfo = JSON.parse(msg.text)
|
||||
if (apiInfo.tokensIn) tokensIn += apiInfo.tokensIn
|
||||
if (apiInfo.tokensOut) tokensOut += apiInfo.tokensOut
|
||||
if (apiInfo.cacheWrites) cacheWrites += apiInfo.cacheWrites
|
||||
if (apiInfo.cacheReads) cacheReads += apiInfo.cacheReads
|
||||
if (apiInfo.cost) totalCost += apiInfo.cost
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// Use metadata if available and no tokens found in messages
|
||||
if (tokensIn === 0 && tokensOut === 0 && metadata.model_usage) {
|
||||
for (const usage of metadata.model_usage) {
|
||||
tokensIn += usage.tokensIn || 0
|
||||
tokensOut += usage.tokensOut || 0
|
||||
cacheWrites += usage.cacheWrites || 0
|
||||
cacheReads += usage.cacheReads || 0
|
||||
totalCost += usage.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate approximate size (rough estimate)
|
||||
const messageSize = JSON.stringify(clineMessages).length
|
||||
const size = Math.floor(messageSize / 1024) // KB
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
taskDescription,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
cacheWrites: cacheWrites > 0 ? cacheWrites : undefined,
|
||||
cacheReads: cacheReads > 0 ? cacheReads : undefined,
|
||||
totalCost,
|
||||
size,
|
||||
}
|
||||
}
|
||||
@@ -108,15 +108,22 @@ export class ContextManager {
|
||||
/**
|
||||
* Determine whether we should compact context window, based on token counts
|
||||
*/
|
||||
shouldCompactContextWindow(clineMessages: ClineMessage[], api: ApiHandler, previousApiReqIndex: number): boolean {
|
||||
shouldCompactContextWindow(
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
previousApiReqIndex: number,
|
||||
thresholdPercentage?: number,
|
||||
): boolean {
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
return totalTokens >= maxAllowedSize
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -285,7 +285,7 @@ export class FileContextTracker {
|
||||
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const taskHistory = await readTaskHistoryFromState(context)
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
|
||||
const allStateKeys = context.workspaceState.keys()
|
||||
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { RecordingResult } from "@shared/proto/cline/dictation"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Cancels audio recording without saving or transcribing the audio
|
||||
* @param controller The controller instance
|
||||
* @returns RecordingResult indicating success or failure
|
||||
*/
|
||||
export const cancelRecording = async (controller: Controller): Promise<RecordingResult> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const recordingStatus = audioRecordingService.getRecordingStatus()
|
||||
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
|
||||
let errorMessage = ""
|
||||
let isSuccess = true
|
||||
try {
|
||||
const result = await audioRecordingService.cancelRecording()
|
||||
isSuccess = !!result?.success
|
||||
errorMessage = result?.error ?? ""
|
||||
} catch (error) {
|
||||
console.error("Error canceling recording:", error)
|
||||
isSuccess = false
|
||||
errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
}
|
||||
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
|
||||
return RecordingResult.create({
|
||||
success: isSuccess,
|
||||
error: errorMessage ?? "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RecordingStatus } from "@shared/proto/cline/dictation"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
|
||||
/**
|
||||
* Gets the current recording status
|
||||
* @returns RecordingStatus with current status
|
||||
*/
|
||||
export const getRecordingStatus = async (): Promise<RecordingStatus> => {
|
||||
try {
|
||||
const status = audioRecordingService.getRecordingStatus()
|
||||
|
||||
return RecordingStatus.create({
|
||||
isRecording: status.isRecording,
|
||||
durationSeconds: status.durationSeconds,
|
||||
error: status.error ?? "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error getting recording status:", error)
|
||||
return RecordingStatus.create({
|
||||
isRecording: false,
|
||||
durationSeconds: 0,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { RecordingResult } from "@shared/proto/cline/dictation"
|
||||
import * as os from "os"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Handles the installation of missing dependencies with Cline
|
||||
*/
|
||||
async function handleInstallWithCline(
|
||||
controller: Controller,
|
||||
dependencyName: string,
|
||||
installCommand: string,
|
||||
platform: string,
|
||||
): Promise<void> {
|
||||
const platformName = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : "Linux"
|
||||
const installTask = `Please install ${dependencyName} for voice recording on ${platformName}.\n\nRun this command:\n\`\`\`bash\n${installCommand}\n\`\`\`\n\nThis will enable voice recording functionality in Cline.`
|
||||
|
||||
// Clear any existing task and start the installation task
|
||||
await controller.clearTask()
|
||||
await controller.postStateToWebview()
|
||||
await controller.initTask(installTask)
|
||||
|
||||
HostProvider.get().logToChannel(`Started task to install ${dependencyName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles copying the installation command to clipboard
|
||||
*/
|
||||
async function handleCopyCommand(installCommand: string): Promise<void> {
|
||||
const vscode = await import("vscode")
|
||||
await vscode.env.clipboard.writeText(installCommand)
|
||||
|
||||
await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `Installation command copied to clipboard: ${installCommand}`,
|
||||
options: { items: [] },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing dependency notification and user action
|
||||
*/
|
||||
async function handleMissingDependency(
|
||||
controller: Controller,
|
||||
platform: string,
|
||||
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG],
|
||||
): Promise<void> {
|
||||
const installWithCline = "Install with Cline"
|
||||
const installManually = "Copy Command"
|
||||
const dismiss = "Dismiss"
|
||||
|
||||
const action = await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `${config.dependencyName} is required for voice recording. ${config.installDescription}`,
|
||||
options: { items: [installWithCline, installManually, dismiss] },
|
||||
})
|
||||
|
||||
if (action.selectedOption === installWithCline) {
|
||||
await handleInstallWithCline(controller, config.dependencyName, config.installCommand, platform)
|
||||
} else if (action.selectedOption === installManually) {
|
||||
await handleCopyCommand(config.installCommand)
|
||||
}
|
||||
// If dismiss, do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles sign-in errors for dictation
|
||||
*/
|
||||
async function handleSignInError(controller: Controller, errorMessage: string): Promise<void> {
|
||||
const signInAction = "Sign in to Cline"
|
||||
const action = await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Voice recording error: ${errorMessage}`,
|
||||
options: { items: [signInAction] },
|
||||
})
|
||||
|
||||
if (action.selectedOption === signInAction) {
|
||||
await controller.authService.createAuthRequest()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a generic error message
|
||||
*/
|
||||
async function showGenericError(errorMessage: string): Promise<void> {
|
||||
await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Voice recording error: ${errorMessage}`,
|
||||
options: { items: [] },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the recording error is due to missing dependencies
|
||||
*/
|
||||
function isMissingDependencyError(
|
||||
error: string | undefined,
|
||||
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG] | undefined,
|
||||
): boolean {
|
||||
return !!(error && config && error.includes(config.error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts audio recording using the Extension Host
|
||||
* @param controller The controller instance
|
||||
* @returns RecordingResult with success status
|
||||
*/
|
||||
export const startRecording = async (controller: Controller): Promise<RecordingResult> => {
|
||||
const taskId = controller.task?.taskId
|
||||
|
||||
try {
|
||||
// Verify user authentication
|
||||
const userInfo = controller.authService.getInfo()
|
||||
if (!userInfo?.user?.uid) {
|
||||
throw new Error("Please sign in to your Cline Account to use Dictation.")
|
||||
}
|
||||
|
||||
// Attempt to start recording
|
||||
const result = await audioRecordingService.startRecording()
|
||||
|
||||
// Handle successful recording start
|
||||
if (result.success) {
|
||||
telemetryService.captureVoiceRecordingStarted(taskId, process.platform)
|
||||
return RecordingResult.create({
|
||||
success: true,
|
||||
error: "",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the error is due to missing dependencies
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
|
||||
if (isMissingDependencyError(result.error, config)) {
|
||||
// Don't await - show dialog asynchronously so frontend gets immediate response
|
||||
handleMissingDependency(controller, platform, config)
|
||||
}
|
||||
|
||||
return RecordingResult.create({
|
||||
success: false,
|
||||
error: result.error || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error starting recording:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
|
||||
// Handle different error types
|
||||
if (errorMessage.includes("sign in")) {
|
||||
// Don't await - show dialog asynchronously so frontend gets immediate response
|
||||
handleSignInError(controller, errorMessage)
|
||||
} else {
|
||||
// Don't await - show dialog asynchronously so frontend gets immediate response
|
||||
showGenericError(errorMessage)
|
||||
}
|
||||
|
||||
return RecordingResult.create({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { RecordedAudio } from "@shared/proto/cline/dictation"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Stops audio recording and returns the recorded audio
|
||||
* @param controller The controller instance
|
||||
* @returns RecordedAudio with audio data
|
||||
*/
|
||||
export const stopRecording = async (controller: Controller): Promise<RecordedAudio> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const recordingStatus = audioRecordingService.getRecordingStatus()
|
||||
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
|
||||
|
||||
try {
|
||||
const result = await audioRecordingService.stopRecording()
|
||||
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, result.success, process.platform)
|
||||
|
||||
return RecordedAudio.create({
|
||||
success: result.success,
|
||||
audioBase64: result.audioBase64 ?? "",
|
||||
error: result.error ?? "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error stopping recording:", error)
|
||||
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
|
||||
|
||||
return RecordedAudio.create({
|
||||
success: false,
|
||||
audioBase64: "",
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TranscribeAudioRequest, Transcription } from "@shared/proto/cline/dictation"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getVoiceTranscriptionService } from "@/services/dictation/VoiceTranscriptionService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Transcribes audio using Cline transcription service
|
||||
* @param controller The controller instance
|
||||
* @param request TranscribeAudioRequest containing base64 audio data
|
||||
* @returns Transcription with transcribed text or error
|
||||
*/
|
||||
export const transcribeAudio = async (controller: Controller, request: TranscribeAudioRequest): Promise<Transcription> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const startTime = Date.now()
|
||||
|
||||
// Capture telemetry for transcription start
|
||||
telemetryService.captureVoiceTranscriptionStarted(taskId, request.language ?? "en")
|
||||
|
||||
try {
|
||||
// Transcribe the audio
|
||||
const result = await getVoiceTranscriptionService().transcribeAudio(request.audioBase64, request.language ?? "en")
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
if (result.error) {
|
||||
let errorType = "api_error"
|
||||
if (result.error.includes("Authentication failed")) {
|
||||
errorType = "invalid_jwt_token"
|
||||
} else if (result.error.includes("Insufficient credits")) {
|
||||
errorType = "insufficient_credits"
|
||||
} else if (result.error.includes("Invalid audio format")) {
|
||||
errorType = "invalid_audio_format"
|
||||
} else if (result.error.includes("No internet connection")) {
|
||||
errorType = "no_internet"
|
||||
} else if (result.error.includes("Cannot connect")) {
|
||||
errorType = "connection_error"
|
||||
} else if (result.error.includes("Connection timed out")) {
|
||||
errorType = "timeout_error"
|
||||
} else if (result.error.includes("Network error")) {
|
||||
errorType = "network_error"
|
||||
}
|
||||
|
||||
telemetryService.captureVoiceTranscriptionError(taskId, errorType, result.error, durationMs)
|
||||
|
||||
// Use the error message directly from the service as it's already user-friendly
|
||||
const errorMessage = result.error
|
||||
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
} else if (result.text) {
|
||||
telemetryService.captureVoiceTranscriptionCompleted(taskId, result.text.length, durationMs, request.language ?? "en")
|
||||
}
|
||||
|
||||
return Transcription.create({
|
||||
text: result.text ?? "",
|
||||
error: result.error ?? "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error transcribing audio:", error)
|
||||
const durationMs = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
|
||||
telemetryService.captureVoiceTranscriptionError(taskId, "unexpected_error", errorMessage, durationMs)
|
||||
|
||||
return Transcription.create({
|
||||
text: "",
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -9,11 +9,11 @@ import { Controller } from ".."
|
||||
* @param request The request message containing the file path in the 'value' field
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function openTaskHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
export async function openDiskConversationHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
const globalStoragePath = HostProvider.get().globalStorageFsPath
|
||||
const taskHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
|
||||
const taskConversationHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
|
||||
if (request.value) {
|
||||
openFileIntegration(taskHistoryPath)
|
||||
openFileIntegration(taskConversationHistoryPath)
|
||||
}
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import { PersistenceErrorEvent, StateManager } from "../storage/StateManager"
|
||||
import { Task } from "../task"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
|
||||
/*
|
||||
@@ -197,7 +199,6 @@ export class Controller {
|
||||
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
|
||||
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
|
||||
const terminalReuseEnabled = this.stateManager.getGlobalStateKey("terminalReuseEnabled")
|
||||
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
|
||||
@@ -599,10 +600,15 @@ export class Controller {
|
||||
// Read OpenRouter models from disk cache
|
||||
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
|
||||
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
try {
|
||||
if (await fileExistsAtPath(openRouterModelsFilePath)) {
|
||||
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
|
||||
const models = JSON.parse(fileContents)
|
||||
// Append stealth models
|
||||
return appendClineStealthModels(models)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading cached OpenRouter models:", error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -687,6 +693,7 @@ export class Controller {
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
|
||||
const dictationSettings = this.stateManager.getGlobalSettingsKey("dictationSettings")
|
||||
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
|
||||
const openaiReasoningEffort = this.stateManager.getGlobalSettingsKey("openaiReasoningEffort")
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
@@ -712,11 +719,13 @@ export class Controller {
|
||||
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
|
||||
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
|
||||
const favoritedModelIds = this.stateManager.getGlobalStateKey("favoritedModelIds")
|
||||
const lastDismissedInfoBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
|
||||
|
||||
const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
@@ -733,6 +742,12 @@ export class Controller {
|
||||
const distinctId = getDistinctId()
|
||||
const version = ExtensionRegistryInfo.version
|
||||
|
||||
// Set feature flag in dictation settings based on platform
|
||||
const updatedDictationSettings = {
|
||||
...dictationSettings,
|
||||
featureEnabled: process.platform === "darwin", // Enable dictation only on macOS
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
apiConfiguration,
|
||||
@@ -743,6 +758,7 @@ export class Controller {
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
focusChainSettings,
|
||||
dictationSettings: updatedDictationSettings,
|
||||
preferredLanguage,
|
||||
openaiReasoningEffort,
|
||||
mode,
|
||||
@@ -774,10 +790,16 @@ export class Controller {
|
||||
platform,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
autoCondenseThreshold,
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: this.workspaceManager?.getRoots() ?? [],
|
||||
primaryRootIndex: this.workspaceManager?.getPrimaryIndex() ?? 0,
|
||||
isMultiRootWorkspace: (this.workspaceManager?.getRoots().length ?? 0) > 1,
|
||||
multiRootSetting: {
|
||||
user: this.stateManager.getGlobalStateKey("multiRootEnabled"),
|
||||
featureFlag: featureFlagsService.getMultiRootEnabled(),
|
||||
},
|
||||
lastDismissedInfoBannerVersion,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import axios from "axios"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
import { DEFAULT_OCA_BASE_URL } from "@/services/auth/oca/utils/constants"
|
||||
import { createOcaHeaders, getProxyAgents } from "@/services/auth/oca/utils/utils"
|
||||
import { createOcaHeaders, getAxiosSettings } from "@/services/auth/oca/utils/utils"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Controller } from ".."
|
||||
@@ -25,12 +25,19 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
|
||||
const models: Record<string, OcaModelInfo> = {}
|
||||
let defaultModelId: string | undefined
|
||||
const ocaAccessToken = await OcaAuthService.getInstance().getAuthToken()
|
||||
if (!ocaAccessToken) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Not authenticated with OCA. Please sign in first.",
|
||||
})
|
||||
return OcaCompatibleModelInfo.create({ error: "Not authenticated with OCA" })
|
||||
}
|
||||
const baseUrl = request.value || DEFAULT_OCA_BASE_URL
|
||||
const modelsUrl = `${baseUrl}/v1/model/info`
|
||||
const headers = await createOcaHeaders(ocaAccessToken!, "models-refresh")
|
||||
try {
|
||||
Logger.log(`Making refresh oca model request with customer opc-request-id: ${headers["opc-request-id"]}`)
|
||||
const response = await axios.get(modelsUrl, { headers, ...getProxyAgents() })
|
||||
const response = await axios.get(modelsUrl, { headers, ...getAxiosSettings() })
|
||||
if (response.data?.data) {
|
||||
if (response.data.data.length === 0) {
|
||||
HostProvider.window.showMessage({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
@@ -79,7 +78,7 @@ export async function refreshOpenRouterModels(
|
||||
): Promise<OpenRouterCompatibleModelInfo> {
|
||||
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
|
||||
|
||||
let models: Record<string, OpenRouterModelInfo> = {}
|
||||
const models: Record<string, OpenRouterModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("https://openrouter.ai/api/v1/models")
|
||||
|
||||
@@ -221,22 +220,6 @@ export async function refreshOpenRouterModels(
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet41mModelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Add hardcoded stealth model
|
||||
models["cline/code-supernova"] = OpenRouterModelInfo.create({
|
||||
maxTokens: clineCodeSupernovaModelInfo.maxTokens ?? 0,
|
||||
contextWindow: clineCodeSupernovaModelInfo.contextWindow ?? 0,
|
||||
supportsImages: clineCodeSupernovaModelInfo.supportsImages ?? false,
|
||||
supportsPromptCache: clineCodeSupernovaModelInfo.supportsPromptCache ?? false,
|
||||
inputPrice: clineCodeSupernovaModelInfo.inputPrice ?? 0,
|
||||
outputPrice: clineCodeSupernovaModelInfo.outputPrice ?? 0,
|
||||
cacheWritesPrice: clineCodeSupernovaModelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: clineCodeSupernovaModelInfo.cacheReadsPrice ?? 0,
|
||||
description: clineCodeSupernovaModelInfo.description ?? "",
|
||||
thinkingConfig: clineCodeSupernovaModelInfo.thinkingConfig ?? undefined,
|
||||
supportsGlobalEndpoint: clineCodeSupernovaModelInfo.supportsGlobalEndpoint ?? undefined,
|
||||
tiers: clineCodeSupernovaModelInfo.tiers ?? [],
|
||||
})
|
||||
} else {
|
||||
console.error("Invalid response from OpenRouter API")
|
||||
}
|
||||
@@ -246,29 +229,45 @@ export async function refreshOpenRouterModels(
|
||||
console.error("Error fetching OpenRouter models:", error)
|
||||
|
||||
// If we failed to fetch models, try to read cached models
|
||||
const cachedModels = await readOpenRouterModels(controller)
|
||||
const cachedModels = await controller.readOpenRouterModels()
|
||||
if (cachedModels) {
|
||||
models = cachedModels
|
||||
return OpenRouterCompatibleModelInfo.create({ models: cachedModels })
|
||||
}
|
||||
}
|
||||
|
||||
return OpenRouterCompatibleModelInfo.create({ models })
|
||||
// Append stealth models if any
|
||||
return OpenRouterCompatibleModelInfo.create({ models: appendClineStealthModels(models) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads cached OpenRouter models from disk
|
||||
* Stealth models are models that are compatible with the OpenRouter API but not listed on the OpenRouter website or API.
|
||||
*/
|
||||
async function readOpenRouterModels(controller: Controller): Promise<Record<string, OpenRouterModelInfo> | undefined> {
|
||||
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
|
||||
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
|
||||
if (fileExists) {
|
||||
try {
|
||||
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
} catch (error) {
|
||||
console.error("Error reading cached OpenRouter models:", error)
|
||||
return undefined
|
||||
const CLINE_STEALTH_MODELS: Record<string, OpenRouterModelInfo> = {
|
||||
"cline/code-supernova-1-million": OpenRouterModelInfo.create({
|
||||
maxTokens: clineCodeSupernovaModelInfo.maxTokens ?? 0,
|
||||
contextWindow: clineCodeSupernovaModelInfo.contextWindow ?? 0,
|
||||
supportsImages: clineCodeSupernovaModelInfo.supportsImages ?? false,
|
||||
supportsPromptCache: clineCodeSupernovaModelInfo.supportsPromptCache ?? false,
|
||||
inputPrice: clineCodeSupernovaModelInfo.inputPrice ?? 0,
|
||||
outputPrice: clineCodeSupernovaModelInfo.outputPrice ?? 0,
|
||||
cacheWritesPrice: clineCodeSupernovaModelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: clineCodeSupernovaModelInfo.cacheReadsPrice ?? 0,
|
||||
description: clineCodeSupernovaModelInfo.description ?? "",
|
||||
thinkingConfig: clineCodeSupernovaModelInfo.thinkingConfig ?? undefined,
|
||||
supportsGlobalEndpoint: clineCodeSupernovaModelInfo.supportsGlobalEndpoint ?? undefined,
|
||||
tiers: clineCodeSupernovaModelInfo.tiers ?? [],
|
||||
}),
|
||||
// Add more stealth models here as needed
|
||||
}
|
||||
|
||||
export function appendClineStealthModels(
|
||||
currentModels: Record<string, OpenRouterModelInfo>,
|
||||
): Record<string, OpenRouterModelInfo> {
|
||||
// Create a shallow clone of the current models to avoid mutating the original object
|
||||
const cloned = { ...currentModels }
|
||||
for (const [modelId, modelInfo] of Object.entries(CLINE_STEALTH_MODELS)) {
|
||||
if (!cloned[modelId]) {
|
||||
cloned[modelId] = modelInfo
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return cloned
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { ProcessInfo } from "@shared/proto/cline/state"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Gets process information including PID, version, and uptime
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns ProcessInfo with process details
|
||||
*/
|
||||
export async function getProcessInfo(controller: Controller, request: EmptyRequest): Promise<ProcessInfo> {
|
||||
// Get the current state to access the version (same source as webview)
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
|
||||
return ProcessInfo.create({
|
||||
processId: process.pid,
|
||||
version: state.version || "unknown",
|
||||
uptimeMs: Math.floor(process.uptime() * 1000), // Convert seconds to milliseconds
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Empty, Int64Request } from "@shared/proto/cline/common"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Updates the info banner version to track which version the user has dismissed
|
||||
* @param controller The controller instance
|
||||
* @param request The request containing the version number
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function updateInfoBannerVersion(controller: Controller, request: Int64Request): Promise<Empty> {
|
||||
const version = Number(request.value)
|
||||
|
||||
controller.stateManager.setGlobalState("lastDismissedInfoBannerVersion", version)
|
||||
await controller.postStateToWebview()
|
||||
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildApiHandler } from "@core/api"
|
||||
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import {
|
||||
PlanActMode,
|
||||
@@ -147,7 +148,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
if (request.strictPlanModeEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("strictPlanModeEnabled", request.strictPlanModeEnabled)
|
||||
}
|
||||
|
||||
// Update yolo mode setting
|
||||
if (request.yoloModeToggled !== undefined) {
|
||||
if (controller.task) {
|
||||
@@ -156,6 +156,15 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", request.yoloModeToggled)
|
||||
}
|
||||
|
||||
if (request.dictationSettings !== undefined) {
|
||||
// Convert from protobuf format (snake_case) to TypeScript format (camelCase)
|
||||
const dictationSettings = {
|
||||
featureEnabled: request.dictationSettings.featureEnabled ?? true,
|
||||
dictationEnabled: request.dictationSettings.dictationEnabled ?? true,
|
||||
dictationLanguage: request.dictationSettings.dictationLanguage ?? "en",
|
||||
}
|
||||
controller.stateManager.setGlobalState("dictationSettings", dictationSettings)
|
||||
}
|
||||
// Update auto-condense setting
|
||||
if (request.useAutoCondense !== undefined) {
|
||||
if (controller.task) {
|
||||
@@ -273,6 +282,15 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
}
|
||||
}
|
||||
|
||||
if (request.autoCondenseThreshold !== undefined) {
|
||||
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
|
||||
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
|
||||
}
|
||||
|
||||
if (request.multiRootEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -18,12 +18,11 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
|
||||
*/
|
||||
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
try {
|
||||
// Post last cached models in case the call to endpoint fails
|
||||
controller.readOpenRouterModels().then((openRouterModels) => {
|
||||
if (openRouterModels) {
|
||||
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: openRouterModels }))
|
||||
}
|
||||
})
|
||||
// Post last cached models as soon as possible for immediate availability in the UI
|
||||
const lastCachedModels = await controller.readOpenRouterModels()
|
||||
if (lastCachedModels) {
|
||||
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: lastCachedModels }))
|
||||
}
|
||||
|
||||
// Refresh OpenRouter models from API
|
||||
refreshOpenRouterModels(controller, EmptyRequest.create()).then(async (response) => {
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import Database from "better-sqlite3"
|
||||
import { existsSync, mkdirSync, unlinkSync } from "fs"
|
||||
import * as path from "path"
|
||||
import type { InstanceLockData, SqliteLockManagerOptions } from "./types"
|
||||
|
||||
export class SqliteLockManager {
|
||||
private db!: Database.Database
|
||||
private instanceAddress: string
|
||||
private dbPath: string
|
||||
private readonly STALE_LOCK_TIMEOUT = 1 * 60 * 1000 // 1 minute in milliseconds
|
||||
|
||||
constructor(options: SqliteLockManagerOptions) {
|
||||
this.instanceAddress = options.instanceAddress
|
||||
this.dbPath = options.dbPath
|
||||
|
||||
// Ensure the directory exists before creating the database
|
||||
const dbDir = path.dirname(this.dbPath)
|
||||
try {
|
||||
mkdirSync(dbDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error(`CRITICAL ERROR: Failed to create SQLite database directory ${dbDir}:`, error)
|
||||
throw new Error(`Failed to create SQLite database directory: ${error}`)
|
||||
}
|
||||
|
||||
try {
|
||||
this.initializeDatabaseWithLockSync()
|
||||
} catch (error) {
|
||||
console.error(`CRITICAL ERROR: Failed to initialize SQLite database at ${this.dbPath}:`, error)
|
||||
throw new Error(`Failed to initialize SQLite database: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDatabaseWithLockSync(): void {
|
||||
const lockFile = `${this.dbPath}.lock`
|
||||
|
||||
// Clean up stale lock files first
|
||||
this.cleanupStaleLockSync(lockFile)
|
||||
|
||||
try {
|
||||
// Try to acquire exclusive file lock for database creation
|
||||
const fs = require("fs")
|
||||
let fd: number | null = null
|
||||
|
||||
try {
|
||||
fd = fs.openSync(lockFile, "wx") // Exclusive creation - fails if file exists
|
||||
|
||||
// Write timestamp to lock file for stale lock detection
|
||||
fs.writeFileSync(fd, Date.now().toString())
|
||||
|
||||
// Check if database already exists
|
||||
const dbExists = existsSync(this.dbPath)
|
||||
|
||||
if (!dbExists) {
|
||||
// Database doesn't exist, create it
|
||||
this.db = new Database(this.dbPath)
|
||||
this.initializeDatabase()
|
||||
} else {
|
||||
// Database exists, just open it
|
||||
this.db = new Database(this.dbPath)
|
||||
}
|
||||
} finally {
|
||||
// Always clean up the lock file
|
||||
if (fd !== null) {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
try {
|
||||
unlinkSync(lockFile)
|
||||
} catch {} // Ignore errors if file was already deleted
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === "EEXIST") {
|
||||
// Another process is initializing the database, wait and retry
|
||||
const delay = 100 + Math.random() * 100 // Add jitter
|
||||
this.sleepSync(delay)
|
||||
this.initializeDatabaseWithLockSync()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private sleepSync(ms: number) {
|
||||
// Non-spinning, synchronous sleep using Atomics.wait
|
||||
// Works in Node main thread (since v12.16+) and worker threads.
|
||||
const sab = new SharedArrayBuffer(4)
|
||||
const ia = new Int32Array(sab)
|
||||
Atomics.wait(ia, 0, 0, Math.max(0, Math.floor(ms)))
|
||||
}
|
||||
|
||||
private cleanupStaleLockSync(lockFile: string): void {
|
||||
try {
|
||||
if (!existsSync(lockFile)) {
|
||||
return // Lock file doesn't exist, nothing to clean up
|
||||
}
|
||||
|
||||
const fs = require("fs")
|
||||
|
||||
try {
|
||||
const timestampStr = fs.readFileSync(lockFile, "utf8").trim()
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
if (isNaN(timestamp) || Date.now() - timestamp > this.STALE_LOCK_TIMEOUT) {
|
||||
// Stale lock, remove it
|
||||
unlinkSync(lockFile)
|
||||
console.warn(`Removed stale database lock file: ${lockFile}`)
|
||||
}
|
||||
} catch (readError) {
|
||||
// If we can't read the timestamp, assume it's stale
|
||||
unlinkSync(lockFile)
|
||||
console.warn(`Removed unreadable database lock file: ${lockFile}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== "ENOENT") {
|
||||
// Lock file doesn't exist, which is fine
|
||||
console.warn(`Error checking lock file ${lockFile}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDatabase() {
|
||||
// Create the locks table with the unified schema (matches cli/pkg/common/schema.go)
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`)
|
||||
|
||||
// Create indexes for performance (matches cli/pkg/common/schema.go)
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this instance in the locks table
|
||||
*/
|
||||
async registerInstance(data: {
|
||||
corePort: number
|
||||
hostPort: number
|
||||
version?: string
|
||||
status?: InstanceLockData["status"]
|
||||
}): Promise<void> {
|
||||
const now = Date.now()
|
||||
const hostAddress = `localhost:${data.hostPort}`
|
||||
|
||||
// Create instance lock entry
|
||||
const insertLock = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`)
|
||||
|
||||
insertLock.run(this.instanceAddress, hostAddress, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the timestamp for this instance (touch)
|
||||
*/
|
||||
touchInstance(): void {
|
||||
const now = Date.now()
|
||||
const updateLock = this.db.prepare(`
|
||||
UPDATE locks
|
||||
SET locked_at = ?
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
updateLock.run(now, this.instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this instance from the locks table
|
||||
*/
|
||||
unregisterInstance(): void {
|
||||
const deleteLock = this.db.prepare(`
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
deleteLock.run(this.instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the registry for any instance registered on the given port
|
||||
*/
|
||||
getInstanceByPort(port: number): { instanceAddress: string; hostAddress: string } | null {
|
||||
const query = this.db.prepare(`
|
||||
SELECT held_by, lock_target
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
AND (held_by LIKE '%:' || ? OR lock_target LIKE '%:' || ?)
|
||||
`)
|
||||
|
||||
const result = query.get(port, port) as { held_by: string; lock_target: string } | undefined
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
instanceAddress: result.held_by,
|
||||
hostAddress: result.lock_target,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific instance entry from the registry
|
||||
*/
|
||||
removeInstanceByAddress(instanceAddress: string): void {
|
||||
const deleteLock = this.db.prepare(`
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
deleteLock.run(instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export type LockType = "file" | "instance" | "folder"
|
||||
|
||||
export type LockStatus = "starting" | "healthy" | "unhealthy"
|
||||
|
||||
export interface LockRow {
|
||||
id: number
|
||||
held_by: string // address:port of instance holding the lock
|
||||
lock_type: LockType
|
||||
lock_target: string // varies by type: file path, host address, or folder path
|
||||
locked_at: number // timestamp when lock was acquired
|
||||
}
|
||||
|
||||
export interface InstanceLockData {
|
||||
address: string
|
||||
core_port: number
|
||||
host_port: number
|
||||
status: LockStatus
|
||||
last_seen: string
|
||||
process_pid: number
|
||||
version?: string
|
||||
created_at: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SqliteLockManagerOptions {
|
||||
dbPath: string
|
||||
instanceAddress: string // host:port format
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export async function getToolUseToolsSection(variant: PromptVariant, context: Sy
|
||||
|
||||
// Define multi-root hint based on feature flag
|
||||
const multiRootHint = context.isMultiRootEnabled ? MULTI_ROOT_HINT : ""
|
||||
|
||||
return new TemplateEngine().resolve(template, context, {
|
||||
TASK_PROGRESS: shouldIncludeTaskProgress ? TASK_PROGRESS : "",
|
||||
FOCUS_CHAIN_ATTEMPT: shouldIncludeTaskProgress ? FOCUS_CHAIN_ATTEMPT : "",
|
||||
|
||||
@@ -22,6 +22,7 @@ const generic: ClineToolSpec = {
|
||||
name: "diff",
|
||||
required: true,
|
||||
instruction: `One or more SEARCH/REPLACE blocks following this exact format:
|
||||
|
||||
\`\`\`
|
||||
------- SEARCH
|
||||
[exact content to find]
|
||||
|
||||
@@ -30,7 +30,7 @@ const generic: ClineToolSpec = {
|
||||
{
|
||||
name: "path",
|
||||
required: true,
|
||||
instruction: `The path of the directory to search in (relative to the current working directory {{CWD}}). This directory will be recursively searched.`,
|
||||
instruction: `The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched.`,
|
||||
usage: "Directory path here",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
SettingsKey,
|
||||
} from "./state-keys"
|
||||
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
|
||||
|
||||
export interface PersistenceErrorEvent {
|
||||
error: Error
|
||||
}
|
||||
@@ -276,7 +275,7 @@ export class StateManager {
|
||||
*/
|
||||
private async setupTaskHistoryWatcher(): Promise<void> {
|
||||
try {
|
||||
const historyFile = await getTaskHistoryStateFilePath(this.context)
|
||||
const historyFile = await getTaskHistoryStateFilePath()
|
||||
|
||||
// Close any existing watcher before creating a new one
|
||||
if (this.taskHistoryWatcher) {
|
||||
@@ -296,7 +295,7 @@ export class StateManager {
|
||||
if (!this.isInitialized) {
|
||||
return
|
||||
}
|
||||
const onDisk = await readTaskHistoryFromState(this.context)
|
||||
const onDisk = await readTaskHistoryFromState()
|
||||
const cached = this.globalStateCache["taskHistory"]
|
||||
if (JSON.stringify(onDisk) !== JSON.stringify(cached)) {
|
||||
this.globalStateCache["taskHistory"] = onDisk
|
||||
@@ -765,7 +764,7 @@ export class StateManager {
|
||||
Array.from(keys).map((key) => {
|
||||
if (key === "taskHistory") {
|
||||
// Route task history persistence to file, not VS Code globalState
|
||||
return writeTaskHistoryToState(this.context, this.globalStateCache[key])
|
||||
return writeTaskHistoryToState(this.globalStateCache[key])
|
||||
}
|
||||
return this.context.globalState.update(key, this.globalStateCache[key])
|
||||
}),
|
||||
|
||||
+11
-11
@@ -99,7 +99,7 @@ export async function ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
try {
|
||||
await fs.mkdir(mcpServersDir, { recursive: true })
|
||||
} catch (_error) {
|
||||
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
return path.join(os.homedir(), "Documents", "Cline", "MCP") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
}
|
||||
return mcpServersDir
|
||||
}
|
||||
@@ -184,8 +184,8 @@ export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId:
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStateDirectoryExists(context: vscode.ExtensionContext): Promise<string> {
|
||||
const stateDir = path.join(context.globalStorageUri.fsPath, "state")
|
||||
export async function ensureStateDirectoryExists(): Promise<string> {
|
||||
const stateDir = path.join(HostProvider.get().globalStorageFsPath, "state")
|
||||
await fs.mkdir(stateDir, { recursive: true })
|
||||
return stateDir
|
||||
}
|
||||
@@ -194,18 +194,18 @@ export async function ensureCacheDirectoryExists(): Promise<string> {
|
||||
return HostProvider.getGlobalStorageDir("cache")
|
||||
}
|
||||
|
||||
export async function getTaskHistoryStateFilePath(context: vscode.ExtensionContext): Promise<string> {
|
||||
return path.join(await ensureStateDirectoryExists(context), "taskHistory.json")
|
||||
export async function getTaskHistoryStateFilePath(): Promise<string> {
|
||||
return path.join(await ensureStateDirectoryExists(), "taskHistory.json")
|
||||
}
|
||||
|
||||
export async function taskHistoryStateFileExists(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
const filePath = await getTaskHistoryStateFilePath(context)
|
||||
export async function taskHistoryStateFileExists(): Promise<boolean> {
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
return fileExistsAtPath(filePath)
|
||||
}
|
||||
|
||||
export async function readTaskHistoryFromState(context: vscode.ExtensionContext): Promise<HistoryItem[]> {
|
||||
export async function readTaskHistoryFromState(): Promise<HistoryItem[]> {
|
||||
try {
|
||||
const filePath = await getTaskHistoryStateFilePath(context)
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
const contents = await fs.readFile(filePath, "utf8")
|
||||
try {
|
||||
@@ -222,9 +222,9 @@ export async function readTaskHistoryFromState(context: vscode.ExtensionContext)
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTaskHistoryToState(context: vscode.ExtensionContext, items: HistoryItem[]): Promise<void> {
|
||||
export async function writeTaskHistoryToState(items: HistoryItem[]): Promise<void> {
|
||||
try {
|
||||
const filePath = await getTaskHistoryStateFilePath(context)
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
// Always create the file; if items is empty, write [] to ensure presence on first startup
|
||||
await fs.writeFile(filePath, JSON.stringify(items))
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { WorkspaceRoot } from "@/core/workspace/WorkspaceRoot"
|
||||
import { AutoApprovalSettings } from "@/shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "@/shared/BrowserSettings"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { DictationSettings } from "@/shared/DictationSettings"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { McpDisplayMode } from "@/shared/McpDisplayMode"
|
||||
import { McpMarketplaceCatalog } from "@/shared/mcp"
|
||||
import { Mode, OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
import { TelemetrySetting } from "@/shared/TelemetrySetting"
|
||||
import { UserInfo } from "@/shared/UserInfo"
|
||||
|
||||
export type SecretKey = keyof Secrets
|
||||
|
||||
export type GlobalStateKey = keyof GlobalState
|
||||
@@ -40,6 +40,7 @@ export interface GlobalState {
|
||||
workspaceRoots: WorkspaceRoot[] | undefined
|
||||
primaryRootIndex: number
|
||||
multiRootEnabled: boolean
|
||||
lastDismissedInfoBannerVersion: number
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -94,9 +95,11 @@ export interface Settings {
|
||||
preferredLanguage: string
|
||||
openaiReasoningEffort: OpenaiReasoningEffort
|
||||
mode: Mode
|
||||
dictationSettings: DictationSettings
|
||||
focusChainSettings: FocusChainSettings
|
||||
customPrompt: "compact" | undefined
|
||||
difyBaseUrl: string | undefined
|
||||
autoCondenseThreshold: number | undefined // number from 0 to 1
|
||||
ocaBaseUrl: string | undefined
|
||||
|
||||
// Plan mode configurations
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext)
|
||||
let finalData: HistoryItem[]
|
||||
let migrationAction: string
|
||||
|
||||
const newLocationData = await readTaskHistoryFromState(context)
|
||||
const newLocationData = await readTaskHistoryFromState()
|
||||
|
||||
if (newLocationData.length === 0) {
|
||||
// Move old data to new location
|
||||
@@ -97,9 +97,9 @@ export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext)
|
||||
}
|
||||
|
||||
// Perform migration operations sequentially - only clear old data if write succeeds
|
||||
await writeTaskHistoryToState(context, finalData)
|
||||
await writeTaskHistoryToState(finalData)
|
||||
|
||||
const successfullyWrittenData = await readTaskHistoryFromState(context)
|
||||
const successfullyWrittenData = await readTaskHistoryFromState()
|
||||
|
||||
if (!Array.isArray(successfullyWrittenData)) {
|
||||
console.error("[Storage Migration] Failed to write taskHistory to file: Written data is not an array")
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ApiProvider, fireworksDefaultModelId, type OcaModelInfo } from "@shared/api"
|
||||
import { ANTHROPIC_MIN_THINKING_BUDGET, ApiProvider, fireworksDefaultModelId, type OcaModelInfo } from "@shared/api"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@/shared/BrowserSettings"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { DEFAULT_DICTATION_SETTINGS, DictationSettings } from "@/shared/DictationSettings"
|
||||
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@/shared/FocusChainSettings"
|
||||
import { DEFAULT_MCP_DISPLAY_MODE } from "@/shared/McpDisplayMode"
|
||||
import { OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
import { readTaskHistoryFromState } from "../disk"
|
||||
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "../state-keys"
|
||||
|
||||
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
|
||||
const [
|
||||
apiKey,
|
||||
@@ -230,12 +230,18 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
context.globalState.get<GlobalStateAndSettings["openaiReasoningEffort"]>("openaiReasoningEffort")
|
||||
const preferredLanguage = context.globalState.get<GlobalStateAndSettings["preferredLanguage"]>("preferredLanguage")
|
||||
const focusChainSettings = context.globalState.get<GlobalStateAndSettings["focusChainSettings"]>("focusChainSettings")
|
||||
const dictationSettings = context.globalState.get<GlobalStateAndSettings["dictationSettings"]>("dictationSettings") as
|
||||
| DictationSettings
|
||||
| undefined
|
||||
|
||||
const mcpMarketplaceCatalog =
|
||||
context.globalState.get<GlobalStateAndSettings["mcpMarketplaceCatalog"]>("mcpMarketplaceCatalog")
|
||||
const lastDismissedInfoBannerVersion =
|
||||
context.globalState.get<GlobalStateAndSettings["lastDismissedInfoBannerVersion"]>("lastDismissedInfoBannerVersion")
|
||||
const qwenCodeOauthPath = context.globalState.get<GlobalStateAndSettings["qwenCodeOauthPath"]>("qwenCodeOauthPath")
|
||||
const customPrompt = context.globalState.get<GlobalStateAndSettings["customPrompt"]>("customPrompt")
|
||||
|
||||
const autoCondenseThreshold =
|
||||
context.globalState.get<GlobalStateAndSettings["autoCondenseThreshold"]>("autoCondenseThreshold") // number from 0 to 1
|
||||
// Get mode-related configurations
|
||||
const mode = context.globalState.get<GlobalStateAndSettings["mode"]>("mode")
|
||||
|
||||
@@ -399,7 +405,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const taskHistory = await readTaskHistoryFromState(context)
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
|
||||
// Multi-root workspace support
|
||||
const workspaceRoots = context.globalState.get<GlobalStateAndSettings["workspaceRoots"]>("workspaceRoots")
|
||||
@@ -455,7 +461,9 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
// Plan mode configurations
|
||||
planModeApiProvider: planModeApiProvider || apiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
// undefined means it was never modified, 0 means it was turned off
|
||||
// (having this on by default ensures that <thinking> text does not pollute the user's chat and is instead rendered as reasoning)
|
||||
planModeThinkingBudgetTokens: planModeThinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
@@ -489,7 +497,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
// Act mode configurations
|
||||
actModeApiProvider: actModeApiProvider || apiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeThinkingBudgetTokens: actModeThinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
@@ -523,6 +531,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
|
||||
// Other global fields
|
||||
focusChainSettings: focusChainSettings || DEFAULT_FOCUS_CHAIN_SETTINGS,
|
||||
dictationSettings: { ...DEFAULT_DICTATION_SETTINGS, ...dictationSettings },
|
||||
strictPlanModeEnabled: strictPlanModeEnabled ?? true,
|
||||
yoloModeToggled: yoloModeToggled ?? false,
|
||||
useAutoCondense: useAutoCondense ?? false,
|
||||
@@ -551,12 +560,14 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
mcpMarketplaceCatalog,
|
||||
qwenCodeOauthPath,
|
||||
customPrompt,
|
||||
autoCondenseThreshold: autoCondenseThreshold || 0.75, // default to 0.75 if not set
|
||||
lastDismissedInfoBannerVersion: lastDismissedInfoBannerVersion ?? 0,
|
||||
// Multi-root workspace support
|
||||
workspaceRoots,
|
||||
primaryRootIndex: primaryRootIndex ?? 0,
|
||||
// Feature flag - defaults to false
|
||||
// For now, always return false to disable multi-root support by default
|
||||
multiRootEnabled: multiRootEnabled ?? false,
|
||||
multiRootEnabled: !!multiRootEnabled,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[StateHelpers] Failed to read global state:", error)
|
||||
|
||||
@@ -17,12 +17,12 @@ export function getFocusChainFilePath(taskDir: string, taskId: string): string {
|
||||
export function createFocusChainMarkdownContent(taskId: string, focusChainList: string): string {
|
||||
return `# Focus Chain List for Task ${taskId}
|
||||
|
||||
<!-- Edit this markdown file to update your focus chain focusChain list -->
|
||||
<!-- Edit this markdown file to update your focus chain list -->
|
||||
<!-- Use the format: - [ ] for incomplete items and - [x] for completed items -->
|
||||
|
||||
${focusChainList}
|
||||
|
||||
<!-- Save this file and the focusChain list will be updated in the task -->`
|
||||
<!-- Save this file and the focus chain list will be updated in the task -->`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+51
-11
@@ -1,5 +1,6 @@
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type { RedactedThinkingBlock, TextBlock, ThinkingBlock } from "@anthropic-ai/sdk/resources"
|
||||
import { ApiHandler, ApiProviderInfo, buildApiHandler } from "@core/api"
|
||||
import { ApiStream } from "@core/api/transform/stream"
|
||||
import { parseAssistantMessageV2 } from "@core/assistant-message"
|
||||
@@ -56,7 +57,7 @@ import { convertClineMessageToProto } from "@shared/proto-conversions/cline-mess
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { getGitRemoteUrls, getLatestGitCommitHash } from "@utils/git"
|
||||
import { isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { arePathsEqual, getDesktopDir } from "@utils/path"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { execa } from "execa"
|
||||
@@ -80,7 +81,7 @@ import { FocusChainManager } from "./focus-chain"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { updateApiReqMsg } from "./utils"
|
||||
import { detectAvailableCliTools, updateApiReqMsg } from "./utils"
|
||||
|
||||
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
|
||||
type UserContent = Array<Anthropic.ContentBlockParam>
|
||||
@@ -1218,7 +1219,6 @@ export class Task {
|
||||
|
||||
if (userFeedback) {
|
||||
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
|
||||
await this.checkpointManager?.saveCheckpoint()
|
||||
|
||||
let fileContentString = ""
|
||||
if (userFeedback.files && userFeedback.files.length > 0) {
|
||||
@@ -1807,10 +1807,14 @@ export class Task {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
|
||||
| number
|
||||
| undefined
|
||||
shouldCompact = this.contextManager.shouldCompactContextWindow(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
this.api,
|
||||
previousApiReqIndex,
|
||||
autoCondenseThreshold,
|
||||
)
|
||||
|
||||
// There is an edge case where the summarize_task tool call completes but the user cancels the next request before it finishes
|
||||
@@ -1857,14 +1861,11 @@ export class Task {
|
||||
"Issue with processing the /newrule command. Double check that, if '.clinerules' already exists, it's a directory and not a file. Otherwise there was an issue referencing this file/directory.",
|
||||
)
|
||||
}
|
||||
// Compact prompt is tailored for models with small context window where environment details would often
|
||||
// overflow the context window
|
||||
const useCompactPrompt = customPrompt === "compact"
|
||||
|
||||
userContent = parsedUserContent
|
||||
// add environment details as its own text block, separate from tool results
|
||||
// do not add environment details to the message which we are compacting the context window
|
||||
if (!shouldCompact && !useCompactPrompt) {
|
||||
if (!shouldCompact) {
|
||||
userContent.push({ type: "text", text: environmentDetails })
|
||||
}
|
||||
|
||||
@@ -1875,10 +1876,11 @@ export class Task {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const useCompactPrompt = customPrompt === "compact" && isLocalModel(this.getCurrentProviderInfo())
|
||||
const [parsedUserContent, environmentDetails, clinerulesError] = await this.loadContext(
|
||||
userContent,
|
||||
includeFileDetails,
|
||||
customPrompt === "compact",
|
||||
useCompactPrompt,
|
||||
)
|
||||
|
||||
if (clinerulesError === true) {
|
||||
@@ -1997,6 +1999,8 @@ export class Task {
|
||||
const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk)
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
const reasoningDetails = []
|
||||
const antThinkingContent: (Anthropic.Messages.RedactedThinkingBlock | Anthropic.Messages.ThinkingBlock)[] = []
|
||||
this.taskState.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
try {
|
||||
@@ -2021,6 +2025,24 @@ export class Task {
|
||||
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
|
||||
}
|
||||
break
|
||||
// for cline/openrouter providers
|
||||
case "reasoning_details":
|
||||
reasoningDetails.push(chunk.reasoning_details)
|
||||
break
|
||||
// for anthropic providers
|
||||
case "ant_thinking":
|
||||
antThinkingContent.push({
|
||||
type: "thinking",
|
||||
thinking: chunk.thinking,
|
||||
signature: chunk.signature,
|
||||
})
|
||||
break
|
||||
case "ant_redacted_thinking":
|
||||
antThinkingContent.push({
|
||||
type: "redacted_thinking",
|
||||
data: chunk.data,
|
||||
})
|
||||
break
|
||||
case "text": {
|
||||
if (reasoningMessage && assistantMessage.length === 0) {
|
||||
// complete reasoning message
|
||||
@@ -2150,7 +2172,19 @@ export class Task {
|
||||
|
||||
await this.messageStateHandler.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
content: [
|
||||
// This is critical for maintaining the model’s reasoning flow and conversation integrity.
|
||||
// "When providing thinking blocks, the entire sequence of consecutive thinking blocks must match the outputs generated by the model during the original request; you cannot rearrange or modify the sequence of these blocks." The signature_delta is used to verify that the thinking was generated by Claude, and the thinking blocks will be ignored if it's incorrect or missing.
|
||||
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks
|
||||
...antThinkingContent,
|
||||
{
|
||||
type: "text",
|
||||
text: assistantMessage,
|
||||
// reasoning_details only exists for cline/openrouter providers
|
||||
// @ts-ignore-next-line
|
||||
reasoning_details: reasoningDetails.length > 0 ? reasoningDetails : undefined,
|
||||
},
|
||||
] as Array<RedactedThinkingBlock | ThinkingBlock | TextBlock>,
|
||||
})
|
||||
|
||||
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
|
||||
@@ -2198,8 +2232,8 @@ export class Task {
|
||||
})
|
||||
|
||||
const baseErrorMessage =
|
||||
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output."
|
||||
const errorText = reqId ? `${baseErrorMessage} (reqId: ${reqId})` : baseErrorMessage
|
||||
"Invalid API Response: The provider returned an empty or unparsable response. This is a provider-side issue where the model failed to generate valid output or returned tool calls that Cline cannot process. Retrying the request may help resolve this issue."
|
||||
const errorText = reqId ? `${baseErrorMessage} (Request ID: ${reqId})` : baseErrorMessage
|
||||
|
||||
await this.say("error", errorText)
|
||||
await this.messageStateHandler.addToApiConversationHistory({
|
||||
@@ -2527,6 +2561,12 @@ export class Task {
|
||||
if (latestGitHash) {
|
||||
details += `\n\n# Latest Git Commit Hash\n${latestGitHash}`
|
||||
}
|
||||
|
||||
// Add detected CLI tools
|
||||
const availableCliTools = await detectAvailableCliTools()
|
||||
if (availableCliTools.length > 0) {
|
||||
details += `\n\n# Detected CLI Tools\nThese are some of the tools on the user's machine, and may be useful if needed to accomplish the task: ${availableCliTools.join(", ")}. This list is not exhaustive, and other tools may be available.`
|
||||
}
|
||||
}
|
||||
|
||||
// Add context window usage information
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { resolveWorkspacePath } from "@core/workspace"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getCwd, getDesktopDir, isLocatedInPath } from "@/utils/path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getCwd, getDesktopDir, isLocatedInPath, isLocatedInWorkspace } from "@/utils/path"
|
||||
|
||||
export class AutoApprove {
|
||||
private stateManager: StateManager
|
||||
// Cache for workspace paths - populated on first access and reused for the task lifetime
|
||||
// NOTE: This assumes that the task has a fixed set of workspace roots(which is currently true).
|
||||
private workspacePathsCache: { paths: string[] } | null = null
|
||||
private isMultiRootScenarioCache: boolean | null = null
|
||||
|
||||
constructor(stateManager: StateManager) {
|
||||
this.stateManager = stateManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace information with caching to avoid repeated API calls
|
||||
* Cache is task-scoped since each task gets a new AutoApprove instance
|
||||
*/
|
||||
private async getWorkspaceInfo(): Promise<{
|
||||
workspacePaths: { paths: string[] }
|
||||
isMultiRootScenario: boolean
|
||||
}> {
|
||||
// Check if we already have cached values
|
||||
if (this.workspacePathsCache === null || this.isMultiRootScenarioCache === null) {
|
||||
// First time - fetch and cache for the lifetime of this task
|
||||
this.workspacePathsCache = await HostProvider.workspace.getWorkspacePaths({})
|
||||
this.isMultiRootScenarioCache = featureFlagsService.getMultiRootEnabled() && this.workspacePathsCache.paths.length > 1
|
||||
}
|
||||
|
||||
return {
|
||||
workspacePaths: this.workspacePathsCache,
|
||||
isMultiRootScenario: this.isMultiRootScenarioCache,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// Returns bool for most tools, and tuple for tools with nested settings
|
||||
shouldAutoApproveTool(toolName: ClineDefaultTool): boolean | [boolean, boolean] {
|
||||
@@ -76,14 +103,23 @@ export class AutoApprove {
|
||||
|
||||
let isLocalRead: boolean = false
|
||||
if (autoApproveActionpath) {
|
||||
const cwd = await getCwd(getDesktopDir())
|
||||
// When called with a string cwd, resolveWorkspacePath returns a string
|
||||
const absolutePath = resolveWorkspacePath(
|
||||
cwd,
|
||||
autoApproveActionpath,
|
||||
"AutoApprove.shouldAutoApproveToolWithPath",
|
||||
) as string
|
||||
isLocalRead = isLocatedInPath(cwd, absolutePath)
|
||||
// Use cached workspace info instead of fetching every time
|
||||
const { isMultiRootScenario } = await this.getWorkspaceInfo()
|
||||
|
||||
if (isMultiRootScenario) {
|
||||
// Multi-root: check if file is in ANY workspace
|
||||
isLocalRead = await isLocatedInWorkspace(autoApproveActionpath)
|
||||
} else {
|
||||
// Single-root: use existing logic
|
||||
const cwd = await getCwd(getDesktopDir())
|
||||
// When called with a string cwd, resolveWorkspacePath returns a string
|
||||
const absolutePath = resolveWorkspacePath(
|
||||
cwd,
|
||||
autoApproveActionpath,
|
||||
"AutoApprove.shouldAutoApproveToolWithPath",
|
||||
) as string
|
||||
isLocalRead = isLocatedInPath(cwd, absolutePath)
|
||||
}
|
||||
} else {
|
||||
// If we do not get a path for some reason, default to a (safer) false return
|
||||
isLocalRead = false
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { regexSearchFiles } from "@services/ripgrep"
|
||||
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import * as path from "path"
|
||||
import { formatResponse } from "@/core/prompts/responses"
|
||||
import { parseWorkspaceInlinePath } from "@/core/workspace/utils/parseWorkspaceInlinePath"
|
||||
import { WorkspacePathAdapter } from "@/core/workspace/WorkspacePathAdapter"
|
||||
import { resolveWorkspacePath } from "@/core/workspace/WorkspaceResolver"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
@@ -25,6 +28,150 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which paths to search based on workspace configuration and hints
|
||||
*/
|
||||
private determineSearchPaths(
|
||||
config: TaskConfig,
|
||||
parsedPath: string,
|
||||
workspaceHint: string | undefined,
|
||||
originalPath: string,
|
||||
): Array<{ absolutePath: string; workspaceName?: string; workspaceRoot?: string }> {
|
||||
if (config.isMultiRootEnabled && config.workspaceManager) {
|
||||
const adapter = new WorkspacePathAdapter({
|
||||
cwd: config.cwd,
|
||||
isMultiRootEnabled: true,
|
||||
workspaceManager: config.workspaceManager,
|
||||
})
|
||||
|
||||
if (workspaceHint) {
|
||||
// Search only in the specified workspace
|
||||
const absolutePath = adapter.resolvePath(parsedPath, workspaceHint)
|
||||
const workspaceRoots = adapter.getWorkspaceRoots()
|
||||
const root = workspaceRoots.find((r) => r.name === workspaceHint)
|
||||
return [{ absolutePath, workspaceName: workspaceHint, workspaceRoot: root?.path }]
|
||||
} else {
|
||||
// As a fallback, perform the search across all available workspaces.
|
||||
// Typically, models should provide explicit hints to target specific workspaces for searching.
|
||||
const allPaths = adapter.getAllPossiblePaths(parsedPath)
|
||||
const workspaceRoots = adapter.getWorkspaceRoots()
|
||||
return allPaths.map((absPath, index) => ({
|
||||
absolutePath: absPath,
|
||||
workspaceName: workspaceRoots[index]?.name || path.basename(workspaceRoots[index]?.path || absPath),
|
||||
workspaceRoot: workspaceRoots[index]?.path,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
// Single-workspace mode (backward compatible)
|
||||
const pathResult = resolveWorkspacePath(config, originalPath, "SearchFilesTool.execute")
|
||||
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
|
||||
return [{ absolutePath, workspaceRoot: config.cwd }]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single search operation in a workspace
|
||||
*/
|
||||
private async executeSearch(
|
||||
config: TaskConfig,
|
||||
absolutePath: string,
|
||||
workspaceName: string | undefined,
|
||||
workspaceRoot: string | undefined,
|
||||
regex: string,
|
||||
filePattern: string | undefined,
|
||||
) {
|
||||
try {
|
||||
// Use workspace root for relative path calculation, fallback to cwd
|
||||
const basePathForRelative = workspaceRoot || config.cwd
|
||||
|
||||
const workspaceResults = await regexSearchFiles(
|
||||
basePathForRelative,
|
||||
absolutePath,
|
||||
regex,
|
||||
filePattern,
|
||||
config.services.clineIgnoreController,
|
||||
)
|
||||
|
||||
// Parse the result count from the first line
|
||||
const firstLine = workspaceResults.split("\n")[0]
|
||||
const resultMatch = firstLine.match(/Found (\d+) result/)
|
||||
const resultCount = resultMatch ? parseInt(resultMatch[1], 10) : 0
|
||||
|
||||
return {
|
||||
workspaceName,
|
||||
workspaceResults,
|
||||
resultCount,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
// If search fails in one workspace, return error info
|
||||
console.error(`Search failed in ${absolutePath}:`, error)
|
||||
return {
|
||||
workspaceName,
|
||||
workspaceResults: "",
|
||||
resultCount: 0,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats search results based on workspace configuration
|
||||
*/
|
||||
private formatSearchResults(
|
||||
config: TaskConfig,
|
||||
searchResults: Array<{
|
||||
workspaceName?: string
|
||||
workspaceResults: string
|
||||
resultCount: number
|
||||
success: boolean
|
||||
}>,
|
||||
searchPaths: Array<{ absolutePath: string; workspaceName?: string }>,
|
||||
): string {
|
||||
const allResults: string[] = []
|
||||
let totalResultCount = 0
|
||||
|
||||
for (const { workspaceName, workspaceResults, resultCount, success } of searchResults) {
|
||||
if (!success || !workspaceResults) {
|
||||
continue
|
||||
}
|
||||
|
||||
totalResultCount += resultCount
|
||||
|
||||
// If multi-workspace and we have results, annotate with workspace name
|
||||
if (config.isMultiRootEnabled && searchPaths.length > 1 && workspaceName) {
|
||||
// Check if this workspace has results (resultCount > 0)
|
||||
if (resultCount > 0) {
|
||||
// Skip the "Found X results" line and add workspace annotation
|
||||
const lines = workspaceResults.split("\n")
|
||||
// Skip first two lines (count and empty line) if they exist
|
||||
const resultsWithoutHeader = lines.length > 2 ? lines.slice(2).join("\n") : workspaceResults
|
||||
|
||||
if (resultsWithoutHeader.trim()) {
|
||||
allResults.push(`## Workspace: ${workspaceName}\n${resultsWithoutHeader}`)
|
||||
}
|
||||
}
|
||||
// Don't add anything for workspaces with 0 results in multi-workspace mode
|
||||
} else if (!config.isMultiRootEnabled || searchPaths.length === 1) {
|
||||
// Single workspace mode or single workspace search
|
||||
allResults.push(workspaceResults)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine results
|
||||
if (config.isMultiRootEnabled && searchPaths.length > 1) {
|
||||
// Multi-workspace search result
|
||||
if (totalResultCount === 0) {
|
||||
return "Found 0 results."
|
||||
} else {
|
||||
return `Found ${totalResultCount === 1 ? "1 result" : `${totalResultCount.toLocaleString()} results`} across ${searchPaths.length} workspace${searchPaths.length > 1 ? "s" : ""}.\n\n${allResults.join("\n\n")}`
|
||||
}
|
||||
} else {
|
||||
// Single workspace result
|
||||
return allResults[0] || "Found 0 results."
|
||||
}
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const relPath = block.params.path
|
||||
const regex = block.params.regex
|
||||
@@ -74,25 +221,30 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Resolve the absolute path based on multi-workspace configuration
|
||||
const pathResult = resolveWorkspacePath(config, relDirPath!, "SearchFilesTool.execute")
|
||||
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
|
||||
// Parse workspace hint from the path
|
||||
const { workspaceHint, relPath: parsedPath } = parseWorkspaceInlinePath(relDirPath!)
|
||||
|
||||
// Execute the actual regex search operation
|
||||
const results = await regexSearchFiles(
|
||||
config.cwd,
|
||||
absolutePath,
|
||||
regex,
|
||||
filePattern,
|
||||
config.services.clineIgnoreController,
|
||||
// Determine which paths to search
|
||||
const searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
|
||||
|
||||
// Execute searches in all relevant workspaces in parallel
|
||||
const searchPromises = searchPaths.map(({ absolutePath, workspaceName, workspaceRoot }) =>
|
||||
this.executeSearch(config, absolutePath, workspaceName, workspaceRoot, regex, filePattern),
|
||||
)
|
||||
|
||||
// Wait for all searches to complete
|
||||
const searchResults = await Promise.all(searchPromises)
|
||||
|
||||
// Format and combine results
|
||||
const results = this.formatSearchResults(config, searchResults, searchPaths)
|
||||
|
||||
const sharedMessageProps = {
|
||||
tool: "searchFiles",
|
||||
path: getReadablePath(config.cwd, relDirPath!),
|
||||
content: results,
|
||||
regex: regex,
|
||||
filePattern: filePattern,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relDirPath!),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(parsedPath),
|
||||
} satisfies ClineSayTool
|
||||
|
||||
const completeMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiHandler } from "@core/api"
|
||||
import { execSync } from "child_process"
|
||||
import { showSystemNotification } from "@/integrations/notifications"
|
||||
import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMessage"
|
||||
import { calculateApiCostAnthropic } from "@/utils/cost"
|
||||
@@ -59,3 +60,75 @@ export const updateApiReqMsg = async (params: UpdateApiReqMsgParams) => {
|
||||
} satisfies ClineApiReqInfo),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Common CLI tools that developers frequently use
|
||||
*/
|
||||
const CLI_TOOLS = [
|
||||
"gh",
|
||||
"git",
|
||||
"docker",
|
||||
"podman",
|
||||
"kubectl",
|
||||
"aws",
|
||||
"gcloud",
|
||||
"az",
|
||||
"terraform",
|
||||
"pulumi",
|
||||
"npm",
|
||||
"yarn",
|
||||
"pnpm",
|
||||
"pip",
|
||||
"cargo",
|
||||
"go",
|
||||
"curl",
|
||||
"jq",
|
||||
"make",
|
||||
"cmake",
|
||||
"python",
|
||||
"node",
|
||||
"psql",
|
||||
"mysql",
|
||||
"redis-cli",
|
||||
"sqlite3",
|
||||
"mongosh",
|
||||
"code",
|
||||
"grep",
|
||||
"sed",
|
||||
"awk",
|
||||
"brew",
|
||||
"apt",
|
||||
"yum",
|
||||
"gradle",
|
||||
"mvn",
|
||||
"bundle",
|
||||
"dotnet",
|
||||
"helm",
|
||||
"ansible",
|
||||
"wget",
|
||||
]
|
||||
|
||||
/**
|
||||
* Detect which CLI tools are available in the system PATH
|
||||
* Uses 'which' command on Unix-like systems and 'where' on Windows
|
||||
*/
|
||||
export async function detectAvailableCliTools(): Promise<string[]> {
|
||||
const availableCommands: string[] = []
|
||||
const isWindows = process.platform === "win32"
|
||||
const checkCommand = isWindows ? "where" : "which"
|
||||
|
||||
for (const command of CLI_TOOLS) {
|
||||
try {
|
||||
// Use execSync to check if the command exists
|
||||
execSync(`${checkCommand} ${command}`, {
|
||||
stdio: "ignore", // Don't output to console
|
||||
timeout: 1000, // 1 second timeout to avoid hanging
|
||||
})
|
||||
availableCommands.push(command)
|
||||
} catch (error) {
|
||||
// Command not found, skip it
|
||||
}
|
||||
}
|
||||
|
||||
return availableCommands
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* TypeScript equivalent of the Go common.RetryOperation utility
|
||||
* Performs an operation with retry logic and timeout handling
|
||||
*/
|
||||
export async function retryOperation<T>(maxRetries: number, timeoutPerAttempt: number, operation: () => Promise<T>): Promise<T> {
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Create a timeout promise
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Operation timeout")), timeoutPerAttempt),
|
||||
)
|
||||
|
||||
// Race the operation against timeout
|
||||
const result = await Promise.race([operation(), timeoutPromise])
|
||||
return result // Success - return result
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// Brief delay before retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Operation failed after ${maxRetries} attempts: ${lastError?.message}`)
|
||||
}
|
||||
@@ -59,6 +59,10 @@ export class WorkspacePathAdapter {
|
||||
}
|
||||
|
||||
if (root) {
|
||||
// If no relative path specified, return the workspace root itself
|
||||
if (!relativePath) {
|
||||
return root.path
|
||||
}
|
||||
return path.join(root.path, relativePath)
|
||||
}
|
||||
|
||||
@@ -68,6 +72,10 @@ export class WorkspacePathAdapter {
|
||||
// Default to primary workspace
|
||||
const primaryRoot = manager.getPrimaryRoot()
|
||||
if (primaryRoot) {
|
||||
// If no relative path specified, return the workspace root itself
|
||||
if (!relativePath) {
|
||||
return primaryRoot.path
|
||||
}
|
||||
return path.join(primaryRoot.path, relativePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -487,6 +487,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
}),
|
||||
)
|
||||
|
||||
// Register the reconstructTaskHistory command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.ReconstructTaskHistory, async () => {
|
||||
const { reconstructTaskHistory } = await import("./core/commands/reconstructTaskHistory")
|
||||
await reconstructTaskHistory(context)
|
||||
telemetryService.captureButtonClick("command_reconstructTaskHistory")
|
||||
}),
|
||||
)
|
||||
|
||||
// Register the generateGitCommitMessage command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.GenerateCommit, async (scm) => {
|
||||
|
||||
Vendored
+2
-1
@@ -60,7 +60,8 @@ export abstract class BaseGrpcClient<TClient> {
|
||||
|
||||
protected getClient(): TClient {
|
||||
if (!this.client || !this.channel) {
|
||||
this.channel = createChannel(this.address)
|
||||
const channelOptions = { "grpc.enable_http_proxy": 0 }
|
||||
this.channel = createChannel(this.address, undefined, channelOptions)
|
||||
this.client = this.createClient(this.channel)
|
||||
}
|
||||
return this.client
|
||||
|
||||
@@ -50,7 +50,6 @@ export class ServiceRegistry {
|
||||
}
|
||||
|
||||
this.methodMetadata[methodName] = { isStreaming, ...metadata }
|
||||
console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import { MessageStateHandler } from "@core/task/message-state"
|
||||
import { showChangedFilesDiff } from "@core/task/multifile-diff"
|
||||
import { VcsType, WorkspaceRootManager } from "@core/workspace"
|
||||
import { WorkspaceRootManager } from "@core/workspace"
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
@@ -35,7 +35,7 @@ import { ICheckpointManager } from "./types"
|
||||
* Only created when multiple roots are detected and feature flag is enabled.
|
||||
*
|
||||
* This implementation follows Option B: Simple All-Workspace Approach
|
||||
* - Checkpoints all Git-enabled workspaces every time
|
||||
* - Creates checkpoints instance for each input workspace root
|
||||
* - Commits run in parallel in the background (non-blocking)
|
||||
* - Maintains backward compatibility with single-root expectations
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialize checkpoint trackers for all Git-enabled roots
|
||||
* Initialize checkpoint trackers for all workspace roots
|
||||
* This is called separately to avoid blocking the Task constructor
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
@@ -78,13 +78,10 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
|
||||
const startTime = performance.now()
|
||||
const roots = this.workspaceManager.getRoots()
|
||||
const gitRoots = roots.filter((root) => root.vcs === VcsType.Git)
|
||||
console.log(
|
||||
`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots (${gitRoots.length} Git-enabled)`,
|
||||
)
|
||||
console.log(`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots`)
|
||||
|
||||
// Initialize all Git-enabled roots in parallel
|
||||
const initPromises = gitRoots.map(async (root) => {
|
||||
// Initialize all workspace roots in parallel
|
||||
const initPromises = roots.map(async (root) => {
|
||||
try {
|
||||
console.log(`[MultiRootCheckpointManager] Creating tracker for ${root.name} at ${root.path}`)
|
||||
const tracker = await CheckpointTracker.create(this.taskId, this.enableCheckpoints, root.path)
|
||||
@@ -112,7 +109,7 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
telemetryService.captureMultiRootCheckpoint(
|
||||
this.taskId,
|
||||
"initialized",
|
||||
gitRoots.length,
|
||||
roots.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
performance.now() - startTime,
|
||||
|
||||
@@ -25,6 +25,7 @@ const ClineCommands = {
|
||||
Walkthrough: prefix + ".openWalkthrough",
|
||||
GenerateCommit: prefix + ".generateGitCommitMessage",
|
||||
AbortCommit: prefix + ".abortGitCommitMessage",
|
||||
ReconstructTaskHistory: prefix + ".reconstructTaskHistory",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@shared/ClineAccount"
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import { AuthService } from "../auth/AuthService"
|
||||
|
||||
export class ClineAccountService {
|
||||
@@ -46,10 +47,12 @@ export class ClineAccountService {
|
||||
* @throws Error if the API key is not found or the request fails
|
||||
*/
|
||||
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
const url = `${this._baseUrl}${endpoint}`
|
||||
|
||||
const url = new URL(endpoint, this._baseUrl).toString() // Validate URL
|
||||
// IMPORTANT: Prefixed with 'workos:' so backend can route verification to WorkOS provider
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error("No Cline account auth token found")
|
||||
}
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
@@ -145,7 +148,7 @@ export class ClineAccountService {
|
||||
*/
|
||||
async fetchMe(): Promise<UserResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UserResponse>(`/api/v1/users/me`)
|
||||
const data = await this.authenticatedRequest<UserResponse>(CLINE_API_ENDPOINT.USER_INFO)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data (RPC):", error)
|
||||
@@ -223,7 +226,7 @@ export class ClineAccountService {
|
||||
// Call API to switch account
|
||||
try {
|
||||
// make XHR request to switch account
|
||||
const _response = await this.authenticatedRequest<string>(`/api/v1/users/active-account`, {
|
||||
const _response = await this.authenticatedRequest<string>(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -240,4 +243,22 @@ export class ClineAccountService {
|
||||
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribes audio using the Cline transcription service
|
||||
* @param audioBase64 - Base64 encoded audio data
|
||||
* @param language - Optional language hint for transcription
|
||||
* @returns Promise with transcribed text or error
|
||||
*/
|
||||
async transcribeAudio(audioBase64: string, language = "en"): Promise<{ text: string }> {
|
||||
const response = await this.authenticatedRequest<{ text: string }>(`/api/v1/chat/transcriptions`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
audioData: audioBase64,
|
||||
language: language,
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,29 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { featureFlagsService } from "../feature-flags"
|
||||
import { ClineAuthProvider } from "./providers/ClineAuthProvider"
|
||||
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
|
||||
|
||||
const DefaultClineAccountURI = `${clineEnvConfig.appBaseUrl}/auth`
|
||||
let authProviders: any[] = []
|
||||
import { IAuthProvider } from "./providers/IAuthProvider"
|
||||
|
||||
export type ServiceConfig = {
|
||||
URI?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const availableAuthProviders = {
|
||||
firebase: FirebaseAuthProvider,
|
||||
// Add other providers here as needed
|
||||
}
|
||||
|
||||
export interface ClineAuthInfo {
|
||||
/**
|
||||
* accessToken
|
||||
*/
|
||||
idToken: string
|
||||
/**
|
||||
* Short-lived refresh token
|
||||
*/
|
||||
refreshToken?: string
|
||||
/**
|
||||
* Access token expiration time
|
||||
* When expired, the access token needs to be refreshed using the refresh token.
|
||||
*/
|
||||
expiresAt?: number
|
||||
userInfo: ClineAccountUserInfo
|
||||
}
|
||||
|
||||
@@ -37,6 +43,10 @@ export interface ClineAccountUserInfo {
|
||||
* Cline app base URL, used for webview UI and other client-side operations
|
||||
*/
|
||||
appBaseUrl?: string
|
||||
/**
|
||||
* WorkOS IDP ID if user logged in via SSO
|
||||
*/
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export interface ClineAccountOrganization {
|
||||
@@ -47,15 +57,13 @@ export interface ClineAccountOrganization {
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// TODO: Add logic to handle multiple webviews getting auth updates.
|
||||
|
||||
export class AuthService {
|
||||
protected static instance: AuthService | null = null
|
||||
protected _config: ServiceConfig
|
||||
protected _authenticated: boolean = false
|
||||
protected _clineAuthInfo: ClineAuthInfo | null = null
|
||||
protected _provider: { provider: FirebaseAuthProvider } | null = null
|
||||
protected _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler<AuthState>]>()
|
||||
protected _provider: IAuthProvider | null = null
|
||||
protected _activeAuthStatusUpdateHandlers = new Set<StreamingResponseHandler<AuthState>>()
|
||||
protected _handlerToController = new Map<StreamingResponseHandler<AuthState>, Controller>()
|
||||
protected _controller: Controller
|
||||
|
||||
/**
|
||||
@@ -63,36 +71,8 @@ export class AuthService {
|
||||
* @param controller - Optional reference to the Controller instance.
|
||||
*/
|
||||
protected constructor(controller: Controller) {
|
||||
const providerName = "firebase"
|
||||
this._config = { URI: DefaultClineAccountURI }
|
||||
|
||||
// Fetch AuthProviders
|
||||
// TODO: Deliver this config from the backend securely
|
||||
// ex. https://app.cline.bot/api/v1/auth/providers
|
||||
|
||||
const authProvidersConfigs = [
|
||||
{
|
||||
name: "firebase",
|
||||
config: clineEnvConfig.firebase,
|
||||
},
|
||||
]
|
||||
|
||||
// Merge authProviders with availableAuthProviders
|
||||
authProviders = authProvidersConfigs.map((provider) => {
|
||||
const providerName = provider.name
|
||||
const ProviderClass = availableAuthProviders[providerName as keyof typeof availableAuthProviders]
|
||||
if (!ProviderClass) {
|
||||
throw new Error(`Auth provider "${providerName}" is not available`)
|
||||
}
|
||||
return {
|
||||
name: providerName,
|
||||
config: provider.config,
|
||||
provider: new ProviderClass(provider.config),
|
||||
}
|
||||
})
|
||||
|
||||
this._setProvider(authProviders.find((authProvider) => authProvider.name === providerName).name)
|
||||
|
||||
// Default to firebase for now
|
||||
this._setProvider("firebase")
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
@@ -126,7 +106,7 @@ export class AuthService {
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
get authProvider(): any {
|
||||
get authProvider(): IAuthProvider | null {
|
||||
return this._provider
|
||||
}
|
||||
|
||||
@@ -134,29 +114,52 @@ export class AuthService {
|
||||
this._setProvider(providerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication token with the appropriate prefix.
|
||||
* Refreshing it if necessary.
|
||||
*/
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
if (!this._clineAuthInfo) {
|
||||
return null
|
||||
}
|
||||
const idToken = this._clineAuthInfo.idToken
|
||||
const shouldRefreshIdToken = await this._provider?.provider.shouldRefreshIdToken(idToken)
|
||||
if (shouldRefreshIdToken) {
|
||||
// Retrieves the stored id token and refreshes it, then updates this._clineAuthInfo
|
||||
await this.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
if (!this._clineAuthInfo) {
|
||||
try {
|
||||
const clineAccountAuthToken = this._clineAuthInfo?.idToken
|
||||
if (!this._clineAuthInfo || !clineAccountAuthToken) {
|
||||
// Not authenticated
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (await this._provider?.shouldRefreshIdToken(clineAccountAuthToken, this._clineAuthInfo.expiresAt)) {
|
||||
console.log("Provider indicates token needs refresh")
|
||||
const updatedAuthInfo = await this._provider?.retrieveClineAuthInfo(this._controller)
|
||||
if (updatedAuthInfo) {
|
||||
this._clineAuthInfo = updatedAuthInfo
|
||||
this._authenticated = true
|
||||
} else {
|
||||
this._clineAuthInfo = null
|
||||
this._authenticated = false
|
||||
}
|
||||
await this.sendAuthStatusUpdate()
|
||||
}
|
||||
// IMPORTANT: Prefix with 'workos:' so backend can route verification to WorkOS provider
|
||||
const prefix = this._provider?.name === "cline" ? "workos:" : ""
|
||||
return clineAccountAuthToken ? `${prefix}${clineAccountAuthToken}` : null
|
||||
} catch (error) {
|
||||
console.error("Error getting auth token:", error)
|
||||
return null
|
||||
}
|
||||
return this._clineAuthInfo.idToken
|
||||
}
|
||||
|
||||
protected _setProvider(providerName: string): void {
|
||||
const providerConfig = authProviders.find((provider) => provider.name === providerName)
|
||||
if (!providerConfig) {
|
||||
throw new Error(`Auth provider "${providerName}" not found`)
|
||||
// Only ClineAuthProvider is supported going forward
|
||||
// Keeping the providerName param for forward compatibility/telemetrye
|
||||
switch (providerName) {
|
||||
case "cline":
|
||||
this._provider = new ClineAuthProvider(clineEnvConfig)
|
||||
break
|
||||
case "firebase":
|
||||
default:
|
||||
this._provider = new FirebaseAuthProvider(clineEnvConfig)
|
||||
break
|
||||
}
|
||||
|
||||
this._provider = providerConfig
|
||||
}
|
||||
|
||||
getInfo(): AuthState {
|
||||
@@ -187,17 +190,14 @@ export class AuthService {
|
||||
return String.create({ value: "Already authenticated" })
|
||||
}
|
||||
|
||||
if (!this._config.URI) {
|
||||
throw new Error("Authentication URI is not configured")
|
||||
if (!this._provider) {
|
||||
return String.create({ value: "Authentication provider is not configured" })
|
||||
}
|
||||
|
||||
const callbackHost = await HostProvider.get().getCallbackUrl()
|
||||
const callbackUrl = `${callbackHost}/auth`
|
||||
|
||||
// Use URL object for more graceful query construction
|
||||
const authUrl = new URL(this._config.URI)
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
|
||||
const authUrl = await this._provider.getAuthRequest(callbackUrl)
|
||||
const authUrlString = authUrl.toString()
|
||||
|
||||
await openExternal(authUrlString)
|
||||
@@ -219,14 +219,14 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthCallback(token: string, provider: string): Promise<void> {
|
||||
async handleAuthCallback(authorizationCode: string, provider: string): Promise<void> {
|
||||
if (!this._provider) {
|
||||
throw new Error("Auth provider is not set")
|
||||
}
|
||||
|
||||
try {
|
||||
this._clineAuthInfo = await this._provider.provider.signIn(this._controller, token, provider)
|
||||
this._authenticated = true
|
||||
this._clineAuthInfo = await this._provider.signIn(this._controller, authorizationCode, provider)
|
||||
this._authenticated = this._clineAuthInfo?.idToken !== undefined
|
||||
|
||||
await this.sendAuthStatusUpdate()
|
||||
} catch (error) {
|
||||
@@ -244,16 +244,16 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the authentication token from the extension's storage.
|
||||
* Restores the authentication data from the extension's storage.
|
||||
* This is typically called when the extension is activated.
|
||||
*/
|
||||
async restoreRefreshTokenAndRetrieveAuthInfo(): Promise<void> {
|
||||
if (!this._provider || !this._provider.provider) {
|
||||
if (!this._provider) {
|
||||
throw new Error("Auth provider is not set")
|
||||
}
|
||||
|
||||
try {
|
||||
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._controller)
|
||||
this._clineAuthInfo = await this._provider.retrieveClineAuthInfo(this._controller)
|
||||
if (this._clineAuthInfo) {
|
||||
this._authenticated = true
|
||||
await this.sendAuthStatusUpdate()
|
||||
@@ -286,10 +286,12 @@ export class AuthService {
|
||||
console.log("Subscribing to authStatusUpdate")
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
this._activeAuthStatusUpdateSubscriptions.add([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.add(responseStream)
|
||||
this._handlerToController.set(responseStream, controller)
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
this._handlerToController.delete(responseStream)
|
||||
}
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
if (requestId) {
|
||||
@@ -302,7 +304,8 @@ export class AuthService {
|
||||
} catch (error) {
|
||||
console.error("Error sending initial auth status:", error)
|
||||
// Remove the subscription if there was an error
|
||||
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
this._handlerToController.delete(responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,35 +313,41 @@ export class AuthService {
|
||||
* Send an authStatusUpdate event to all active subscribers
|
||||
*/
|
||||
async sendAuthStatusUpdate(): Promise<void> {
|
||||
// Send the event to all active subscribers
|
||||
const promises = Array.from(this._activeAuthStatusUpdateSubscriptions).map(async ([controller, responseStream]) => {
|
||||
try {
|
||||
const authInfo: AuthState = this.getInfo()
|
||||
// Compute once per broadcast
|
||||
const authInfo: AuthState = this.getInfo()
|
||||
const uniqueControllers = new Set<Controller>()
|
||||
|
||||
// Send the event to all active subscribers
|
||||
const streamSends = Array.from(this._activeAuthStatusUpdateHandlers).map(async (responseStream) => {
|
||||
const controller = this._handlerToController.get(responseStream)
|
||||
if (controller) {
|
||||
uniqueControllers.add(controller)
|
||||
}
|
||||
try {
|
||||
await responseStream(
|
||||
authInfo,
|
||||
false, // Not the last message
|
||||
)
|
||||
|
||||
// Identify the user in telemetry if available
|
||||
// Fetch the feature flags for the user
|
||||
if (this._clineAuthInfo?.userInfo?.id) {
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
featureFlagsService.reset()
|
||||
await featureFlagsService.poll()
|
||||
}
|
||||
|
||||
// Update the state in the webview
|
||||
if (controller) {
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending authStatusUpdate event:", error)
|
||||
// Remove the subscription if there was an error
|
||||
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
this._handlerToController.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
await Promise.all(streamSends)
|
||||
|
||||
// Identify the user in telemetry if available
|
||||
if (this._clineAuthInfo?.userInfo?.id) {
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
// Reset feature flags to ensure they are fetched for the new/logged in user
|
||||
featureFlagsService.reset()
|
||||
}
|
||||
// Poll feature flags to ensure they are up to date for all users
|
||||
await featureFlagsService.poll()
|
||||
|
||||
// Update state in webviews once per unique controller
|
||||
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { String } from "@shared/proto/cline/common"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import type { UserResponse } from "@/shared/ClineAccount"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import { AuthService } from "./AuthService"
|
||||
|
||||
// TODO: Consider adding a mock auth provider implementing IAuthProvider for more realistic testing
|
||||
export class AuthServiceMock extends AuthService {
|
||||
protected constructor(controller: Controller) {
|
||||
super(controller)
|
||||
@@ -13,8 +14,9 @@ export class AuthServiceMock extends AuthService {
|
||||
throw new Error("AuthServiceMock should only be used in local environment for testing purposes.")
|
||||
}
|
||||
|
||||
this._config = { URI: clineEnvConfig.apiBaseUrl }
|
||||
this._setProvider("firebase")
|
||||
// Support both auth providers, default to firebase for compatibility
|
||||
const authProvider = process.env.E2E_TEST_AUTH_PROVIDER || "firebase"
|
||||
this._setProvider(authProvider)
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
@@ -53,16 +55,20 @@ export class AuthServiceMock extends AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch user data from mock server
|
||||
const meUri = new URL("/api/v1/users/me", clineEnvConfig.apiBaseUrl)
|
||||
// Use token exchange endpoint like ClineAuthProvider
|
||||
const tokenExchangeUri = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, clineEnvConfig.apiBaseUrl)
|
||||
const tokenType = "personal"
|
||||
const testToken = `test-${tokenType}-token`
|
||||
const response = await fetch(meUri, {
|
||||
method: "GET",
|
||||
const testCode = `test-${tokenType}-token`
|
||||
|
||||
const response = await fetch(tokenExchangeUri, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${testToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: testCode,
|
||||
grantType: "authorization_code",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -75,30 +81,32 @@ export class AuthServiceMock extends AuthService {
|
||||
throw new Error("Invalid response from mock server")
|
||||
}
|
||||
|
||||
const userData = responseData.data as UserResponse
|
||||
const authData = responseData.data
|
||||
|
||||
// Convert UserResponse to ClineAuthInfo format
|
||||
// Convert to ClineAuthInfo format matching ClineAuthProvider
|
||||
this._clineAuthInfo = {
|
||||
idToken: testToken,
|
||||
idToken: authData.accessToken,
|
||||
refreshToken: authData.refreshToken,
|
||||
expiresAt: new Date(authData.expiresAt).getTime() / 1000,
|
||||
userInfo: {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
displayName: userData.displayName,
|
||||
createdAt: userData.createdAt,
|
||||
organizations: userData.organizations.map((org) => ({
|
||||
active: org.active,
|
||||
memberId: org.memberId,
|
||||
name: org.name,
|
||||
organizationId: org.organizationId,
|
||||
roles: org.roles,
|
||||
})),
|
||||
id: authData.userInfo.clineUserId || authData.userInfo.subject,
|
||||
email: authData.userInfo.email,
|
||||
displayName: authData.userInfo.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
organizations: authData.organizations,
|
||||
appBaseUrl: clineEnvConfig.appBaseUrl,
|
||||
subject: authData.userInfo.subject,
|
||||
},
|
||||
}
|
||||
|
||||
console.log(`Successfully authenticated with mock server as ${userData.displayName} (${userData.email})`)
|
||||
console.log(`Successfully authenticated with mock server as ${authData.userInfo.name} (${authData.userInfo.email})`)
|
||||
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.handleAuthCallback(testToken, "mock")
|
||||
|
||||
// Use appropriate provider name for callback
|
||||
const providerName = this._provider?.name || "mock"
|
||||
// Simulate handling the auth callback as if from a real provider
|
||||
await visibleWebview?.controller.handleAuthCallback(authData.accessToken, providerName)
|
||||
} catch (error) {
|
||||
console.error("Error signing in with mock server:", error)
|
||||
this._authenticated = false
|
||||
|
||||
@@ -2,7 +2,7 @@ import { OcaAuthState, OcaUserInfo } from "@shared/proto/cline/oca_account"
|
||||
import axios from "axios"
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { getProxyAgents } from "@/services/auth/oca/utils/utils"
|
||||
import { getAxiosSettings } from "@/services/auth/oca/utils/utils"
|
||||
|
||||
import { generateCodeVerifier, generateRandomString, pkceChallengeFromVerifier } from "../utils/utils"
|
||||
|
||||
@@ -93,7 +93,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
try {
|
||||
const { idcs_url, client_id } = this._config
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getProxyAgents() })
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getAxiosSettings() })
|
||||
const tokenEndpoint = discovery.data.token_endpoint
|
||||
const params: any = {
|
||||
grant_type: "refresh_token",
|
||||
@@ -102,7 +102,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams(params), {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
...getProxyAgents(),
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
const accessToken = tokenResponse.data.access_token
|
||||
const userInfo: OcaUserInfo = await this.getUserAccountInfo(accessToken)
|
||||
@@ -159,7 +159,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
const { code_verifier, nonce, redirect_uri } = entry
|
||||
OcaAuthProvider.pkceStateMap.delete(state)
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getProxyAgents() })
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getAxiosSettings() })
|
||||
const tokenEndpoint = discovery.data.token_endpoint
|
||||
const params: any = {
|
||||
grant_type: "authorization_code",
|
||||
@@ -170,7 +170,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams(params), {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
...getProxyAgents(),
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
// Step 1: Nonce validation
|
||||
const idToken = tokenResponse.data.id_token
|
||||
@@ -179,6 +179,8 @@ export class OcaAuthProvider {
|
||||
if (decoded.nonce !== nonce) {
|
||||
throw new Error("OIDC nonce verification failed")
|
||||
}
|
||||
} else {
|
||||
throw new Error("No ID token received from OCA")
|
||||
}
|
||||
|
||||
// Step 2: Get access_token (this is what you'll use for APIs)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import crypto from "crypto"
|
||||
import fs from "fs"
|
||||
import { type JwtPayload, jwtDecode } from "jwt-decode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import {
|
||||
DEFAULT_IDCS_CLIENT_ID,
|
||||
DEFAULT_IDCS_PORT_CANDIDATES,
|
||||
@@ -70,11 +73,6 @@ export function pkceChallengeFromVerifier(verifier: string): string {
|
||||
.replace(/=+$/, "")
|
||||
}
|
||||
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import { type JwtPayload, jwtDecode } from "jwt-decode"
|
||||
import * as vscode from "vscode"
|
||||
import { name, version } from "../../../../../package.json"
|
||||
|
||||
/**
|
||||
* Generates a compliant customer opc-request-id segment.
|
||||
*
|
||||
@@ -117,32 +115,26 @@ export async function generateOpcRequestId(taskId: string, token: string): Promi
|
||||
|
||||
export async function createOcaHeaders(accessToken: string, taskId: string): Promise<Record<string, string>> {
|
||||
const opcRequestId = await generateOpcRequestId(taskId, accessToken)
|
||||
const host = await HostProvider.env.getHostVersion({})
|
||||
const clineVersion = ExtensionRegistryInfo.version
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
client: "Cline",
|
||||
"client-version": `${name}-${version}`,
|
||||
"client-ide": vscode.env.appName,
|
||||
"client-ide-version": vscode.version,
|
||||
"client-version": `${clineVersion}`,
|
||||
"client-ide": host.platform || "unknown",
|
||||
"client-ide-version": host.version || "unknown",
|
||||
"opc-request-id": opcRequestId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy helpers for HTTPS/HTTP proxies via environment variables.
|
||||
* - Prioritizes HTTPS_PROXY over HTTP_PROXY
|
||||
* - Returns axios-compatible agent options when a proxy is configured
|
||||
*
|
||||
* @returns Axios settings including fetch adapter for compatibility
|
||||
*/
|
||||
export function getProxyUrl(): string | undefined {
|
||||
return process.env.HTTPS_PROXY || process.env.HTTP_PROXY
|
||||
}
|
||||
|
||||
export function getProxyAgents(): { httpAgent?: any; httpsAgent?: any } {
|
||||
const proxyUrl = getProxyUrl()
|
||||
if (!proxyUrl) return {}
|
||||
const agent = new HttpsProxyAgent(proxyUrl)
|
||||
return { httpAgent: agent as any, httpsAgent: agent as any }
|
||||
export function getAxiosSettings(): { adapter?: any } {
|
||||
return { adapter: "fetch" as any }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { clineEnvConfig, EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import type { ClineAuthInfo } from "../AuthService"
|
||||
import { IAuthProvider } from "./IAuthProvider"
|
||||
|
||||
interface ClineAuthApiUser {
|
||||
subject: string | null
|
||||
email: string
|
||||
name: string
|
||||
clineUserId: string | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
// Unified API response data shape for token exchange/refresh
|
||||
interface ClineAuthResponseData {
|
||||
/**
|
||||
* Auth token to be used for authenticated requests
|
||||
*/
|
||||
accessToken: string
|
||||
/**
|
||||
* Refresh token to be used for refreshing the access token
|
||||
*/
|
||||
refreshToken?: string
|
||||
/**
|
||||
* Token type
|
||||
* E.g. "Bearer"
|
||||
*/
|
||||
tokenType: string
|
||||
/**
|
||||
* Access token expiration time in ISO 8601 format
|
||||
* E.g. "2025-09-17T04:32:24.842636548Z"
|
||||
*/
|
||||
expiresAt: string
|
||||
/**
|
||||
* User information associated with the token
|
||||
*/
|
||||
userInfo: ClineAuthApiUser
|
||||
}
|
||||
|
||||
export interface ClineAuthApiTokenExchangeResponse {
|
||||
success: boolean
|
||||
data: ClineAuthResponseData
|
||||
}
|
||||
|
||||
export interface ClineAuthApiTokenRefreshResponse {
|
||||
success: boolean
|
||||
data: ClineAuthResponseData
|
||||
}
|
||||
|
||||
export class ClineAuthProvider implements IAuthProvider {
|
||||
readonly name = "cline"
|
||||
private _config
|
||||
|
||||
constructor(config: EnvironmentConfig) {
|
||||
this._config = config
|
||||
}
|
||||
|
||||
get config(): any {
|
||||
return this._config
|
||||
}
|
||||
|
||||
set config(value: any) {
|
||||
this._config = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the access token needs to be refreshed (expired or about to expire).
|
||||
* Since the new flow doesn't support refresh tokens, this will return true if token is expired.
|
||||
* @param _refreshToken - The existing refresh token to check.
|
||||
* @returns {Promise<boolean>} True if the token is expired or about to expire.
|
||||
*/
|
||||
async shouldRefreshIdToken(_refreshToken: string, expiresAt?: number): Promise<boolean> {
|
||||
try {
|
||||
// expiresAt is in seconds
|
||||
const expirationTime = expiresAt || 0
|
||||
const currentTime = Date.now() / 1000
|
||||
const next5Min = currentTime + 5 * 60
|
||||
|
||||
// Check if token is expired or will expire in the next 5 minutes
|
||||
return expirationTime < next5Min // Access token is expired or about to expire
|
||||
} catch (error) {
|
||||
Logger.error("Error checking token expiration:", error)
|
||||
return true // If we can't decode the token, assume it needs refresh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Cline auth info using the stored access token.
|
||||
* @param controller - The controller instance to access stored secrets.
|
||||
* @returns {Promise<ClineAuthInfo | null>} A promise that resolves with the auth info or null.
|
||||
*/
|
||||
async retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
// Get the stored auth data from secure storage
|
||||
const storedAuthDataString = controller.stateManager.getSecretKey("clineAccountId")
|
||||
|
||||
if (!storedAuthDataString) {
|
||||
Logger.debug("No stored authentication data found")
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse the stored auth data
|
||||
let storedAuthData: ClineAuthInfo
|
||||
try {
|
||||
storedAuthData = JSON.parse(storedAuthDataString)
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stored auth data:", e)
|
||||
controller.stateManager.setSecret("clineAccountId", undefined)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!storedAuthData.refreshToken || !storedAuthData?.idToken) {
|
||||
console.error("No valid token found in stored authentication data")
|
||||
controller.stateManager.setSecret("clineAccountId", undefined)
|
||||
return null
|
||||
}
|
||||
|
||||
if (await this.shouldRefreshIdToken(storedAuthData.refreshToken, storedAuthData.expiresAt)) {
|
||||
// Try to refresh the token using the refresh token
|
||||
const authInfo = await this.refreshToken(storedAuthData.refreshToken)
|
||||
return authInfo || null
|
||||
}
|
||||
|
||||
// Is the token valid?
|
||||
if (storedAuthData.idToken && storedAuthData.refreshToken && storedAuthData.userInfo.id) {
|
||||
return storedAuthData
|
||||
}
|
||||
|
||||
// Verify the token structure
|
||||
const tokenParts = storedAuthData.idToken.split(".")
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new Error("Invalid token format")
|
||||
}
|
||||
|
||||
// Decode the token to verify it's a valid JWT
|
||||
const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString("utf-8"))
|
||||
if (payload.external_id) {
|
||||
storedAuthData.userInfo.id = payload.external_id
|
||||
}
|
||||
|
||||
console.log("Successfully retrieved and validated stored auth token")
|
||||
return storedAuthData
|
||||
} catch (error) {
|
||||
console.error("Error retrieving stored authentication credential:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an access token using a refresh token.
|
||||
* @param refreshToken - The refresh token.
|
||||
* @returns {Promise<ClineAuthInfo>} The new access token and user info.
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<ClineAuthInfo> {
|
||||
try {
|
||||
// Get the callback URL that was used during the initial auth request
|
||||
const endpoint = new URL(CLINE_API_ENDPOINT.REFRESH_TOKEN, this._config.apiBaseUrl)
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken, // short_lived_auth_code
|
||||
grantType: "refresh_token", // must be "authorization_code"
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData?.error || "Invalid or expired authorization code"
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: ClineAuthApiTokenExchangeResponse = await response.json()
|
||||
|
||||
if (!data.success || !data.data.refreshToken || !data.data.accessToken) {
|
||||
throw new Error("Failed to exchange authorization code for access token")
|
||||
}
|
||||
|
||||
return {
|
||||
idToken: data.data.accessToken,
|
||||
// data.data.expiresAt example: "2025-09-17T03:43:57Z"; store in seconds
|
||||
expiresAt: new Date(data.data.expiresAt).getTime() / 1000,
|
||||
refreshToken: data.data.refreshToken || refreshToken,
|
||||
userInfo: {
|
||||
createdAt: new Date().toISOString(),
|
||||
email: data.data.userInfo.email || "",
|
||||
id: data.data.userInfo.clineUserId || "",
|
||||
displayName: data.data.userInfo.name || "",
|
||||
organizations: [],
|
||||
appBaseUrl: this._config.appBaseUrl,
|
||||
subject: data.data.userInfo.subject || "",
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthRequest(callbackUrl: string): Promise<string> {
|
||||
const authUrl = new URL(CLINE_API_ENDPOINT.AUTH, clineEnvConfig.apiBaseUrl)
|
||||
authUrl.searchParams.set("client_type", "extension")
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
// Ensure the redirect_uri is properly encoded and included
|
||||
authUrl.searchParams.set("redirect_uri", callbackUrl)
|
||||
|
||||
// The server will respond with a 302 redirect to the OAuth provider
|
||||
// We need to follow the redirect and get the final URL
|
||||
let response: Response
|
||||
try {
|
||||
// Set redirect: 'manual' to handle the redirect manually
|
||||
response = await fetch(authUrl.toString(), {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
credentials: "include", // Important for cookies if needed
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
// If we get a redirect status (3xx), get the Location header
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const redirectUrl = response.headers.get("Location")
|
||||
if (!redirectUrl) {
|
||||
throw new Error("No redirect URL found in the response")
|
||||
}
|
||||
|
||||
return redirectUrl
|
||||
}
|
||||
|
||||
// If we didn't get a redirect, try to parse the response as JSON
|
||||
const responseData = await response.json()
|
||||
if (responseData.redirect_url) {
|
||||
return responseData.redirect_url
|
||||
}
|
||||
|
||||
throw new Error("Unexpected response from auth server")
|
||||
} catch (error) {
|
||||
console.error("Error during authentication request:", error)
|
||||
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`)
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
// Get the callback URL that was used during the initial auth request
|
||||
const callbackHost = await HostProvider.get().getCallbackUrl()
|
||||
const callbackUrl = `${callbackHost}/auth`
|
||||
|
||||
// Exchange the authorization code for tokens
|
||||
const tokenUrl = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, clineEnvConfig.apiBaseUrl)
|
||||
|
||||
const response = await fetch(tokenUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
client_type: "extension",
|
||||
redirect_uri: callbackUrl,
|
||||
provider: provider,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error_description || "Failed to exchange authorization code for tokens")
|
||||
}
|
||||
|
||||
const responseJSON = await response.json()
|
||||
console.log("Token data received:", responseJSON)
|
||||
|
||||
const responseType: ClineAuthApiTokenExchangeResponse = responseJSON
|
||||
const tokenData = responseType.data
|
||||
|
||||
if (!tokenData.accessToken || !tokenData.refreshToken || !tokenData.userInfo) {
|
||||
throw new Error("Invalid token response from server")
|
||||
}
|
||||
|
||||
// Store the tokens and user info
|
||||
const clineAuthInfo = {
|
||||
idToken: tokenData.accessToken,
|
||||
refreshToken: tokenData.refreshToken,
|
||||
userInfo: {
|
||||
id: tokenData.userInfo.clineUserId || "",
|
||||
email: tokenData.userInfo.email || "",
|
||||
displayName: tokenData.userInfo.name || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
organizations: [],
|
||||
},
|
||||
expiresAt: new Date(tokenData.expiresAt).getTime() / 1000, // "2025-09-17T04:32:24.842636548Z"
|
||||
}
|
||||
|
||||
controller.stateManager.setSecret("clineAccountId", JSON.stringify(clineAuthInfo))
|
||||
|
||||
return clineAuthInfo
|
||||
} catch (error) {
|
||||
console.error("Error handling auth callback:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,19 @@ import axios from "axios"
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { GithubAuthProvider, GoogleAuthProvider, getAuth, type OAuthCredential, signInWithCredential, User } from "firebase/auth"
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { clineEnvConfig, EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ErrorService } from "@/services/error"
|
||||
import type { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
|
||||
import { IAuthProvider } from "./IAuthProvider"
|
||||
|
||||
export class FirebaseAuthProvider {
|
||||
private _config: any
|
||||
export class FirebaseAuthProvider implements IAuthProvider {
|
||||
readonly name = "firebase"
|
||||
readonly callbackEndpoint = "/auth"
|
||||
|
||||
constructor(config: any) {
|
||||
private _config: EnvironmentConfig
|
||||
|
||||
constructor(config: EnvironmentConfig) {
|
||||
this._config = config || {}
|
||||
}
|
||||
|
||||
@@ -22,7 +26,7 @@ export class FirebaseAuthProvider {
|
||||
this._config = value
|
||||
}
|
||||
|
||||
async shouldRefreshIdToken(existingIdToken: string): Promise<boolean> {
|
||||
async shouldRefreshIdToken(existingIdToken: string, _expiresAt?: number): Promise<boolean> {
|
||||
const decodedToken = jwtDecode(existingIdToken)
|
||||
const exp = decodedToken.exp || 0 // 1752297633
|
||||
const expirationTime = exp * 1000
|
||||
@@ -48,23 +52,11 @@ export class FirebaseAuthProvider {
|
||||
}
|
||||
try {
|
||||
// Exchange refresh token for new access token using Firebase's secure token endpoint
|
||||
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
|
||||
const firebaseApiKey = this._config.apiKey
|
||||
const googleAccessTokenResponse = await axios.post(
|
||||
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
)
|
||||
const { idToken } = await this.refreshToken(userRefreshToken)
|
||||
|
||||
// console.log("googleAccessTokenResponse", googleAccessTokenResponse)
|
||||
|
||||
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
|
||||
const idToken = googleAccessTokenResponse.data.id_token
|
||||
// const idTokenExpirationDate = new Date(Date.now() + googleAccessTokenResponse.data.expires_in * 1000)
|
||||
if (!idToken) {
|
||||
throw new Error("No ID token received from refresh token exchange")
|
||||
}
|
||||
|
||||
// Now retrieve the user info from the backend (this was an easy solution to keep providing user profile details like name and email, but we should move to using the fetchMe() function instead)
|
||||
// Fetch user info from Cline API
|
||||
@@ -79,26 +71,39 @@ export class FirebaseAuthProvider {
|
||||
const userInfo: ClineAccountUserInfo = userResponse.data.data
|
||||
|
||||
return { idToken, userInfo }
|
||||
|
||||
// let userObject = JSON.parse(credentialJSON)
|
||||
// let user = User.
|
||||
// userObject = User.constructor._fromJSON(auth, user2);
|
||||
// const credentialData: AuthCredential = OAuthCredential.fromJSON(credentialJSON) as AuthCredential
|
||||
// const userCredential = await this._signInWithCredential(context, credentialData)
|
||||
// return userCredential.user
|
||||
} catch (error) {
|
||||
console.error("Firebase restore token error", error)
|
||||
ErrorService.get().logMessage("Firebase restore token error", "error")
|
||||
ErrorService.get().logException(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in the user using Firebase authentication with a custom token.
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the sign-in fails.
|
||||
*/
|
||||
async refreshToken(userRefreshToken: string): Promise<Partial<ClineAuthInfo>> {
|
||||
// Exchange refresh token for new access token using Firebase's secure token endpoint
|
||||
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
|
||||
const firebaseApiKey = this._config.firebase.apiKey
|
||||
const googleAccessTokenResponse = await axios.post(
|
||||
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
|
||||
// Store user data
|
||||
return { idToken: googleAccessTokenResponse.data.id_token }
|
||||
}
|
||||
|
||||
getAuthRequest(callbackUrl: string): Promise<string> {
|
||||
// Use URL object for more graceful query construction
|
||||
const authUrl = new URL(`${clineEnvConfig.appBaseUrl}/auth`)
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
|
||||
return Promise.resolve(authUrl.toString())
|
||||
}
|
||||
|
||||
async signIn(controller: Controller, token: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
let credential: OAuthCredential
|
||||
@@ -113,7 +118,7 @@ export class FirebaseAuthProvider {
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
// we've received the short-lived tokens from google/github, now we need to sign in to firebase with them
|
||||
const firebaseConfig = Object.assign({}, this._config)
|
||||
const firebaseConfig = Object.assign({}, this._config.firebase)
|
||||
const app = initializeApp(firebaseConfig)
|
||||
const auth = getAuth(app)
|
||||
// this signs the user into firebase sdk internally
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ClineAuthInfo } from "../AuthService"
|
||||
|
||||
export interface IAuthProvider {
|
||||
readonly name: string
|
||||
config: EnvironmentConfig
|
||||
shouldRefreshIdToken(token: string, expiresAt?: number): Promise<boolean>
|
||||
retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null>
|
||||
refreshToken(refreshToken: string): Promise<Partial<ClineAuthInfo>>
|
||||
getAuthRequest(callbackUrl: string): Promise<string>
|
||||
signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null>
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { ChildProcess, spawn } from "node:child_process"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioRecordingService {
|
||||
private recordingProcess: ChildProcess | null = null
|
||||
private startTime: number = 0
|
||||
private outputFile: string = ""
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Determines if recording is currently active by checking process state
|
||||
*/
|
||||
private get isRecording(): boolean {
|
||||
return this.recordingProcess !== null && !this.recordingProcess.killed && this.recordingProcess.exitCode === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the recording state variables
|
||||
*/
|
||||
private resetRecordingState(): void {
|
||||
this.recordingProcess = null
|
||||
this.startTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the temporary audio file
|
||||
*/
|
||||
private async cleanupTempFile(): Promise<void> {
|
||||
if (this.outputFile && fs.existsSync(this.outputFile)) {
|
||||
try {
|
||||
fs.unlinkSync(this.outputFile)
|
||||
Logger.info("Temporary audio file cleaned up")
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to cleanup temporary audio file: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
this.outputFile = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates the recording process gracefully
|
||||
*/
|
||||
private async terminateProcess(): Promise<void> {
|
||||
if (!this.recordingProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.info("Terminating recording process...")
|
||||
this.recordingProcess.kill("SIGINT")
|
||||
|
||||
// Wait for the process to finish with timeout
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
Logger.warn("Process termination timed out after 5 seconds")
|
||||
resolve()
|
||||
}, 5000)
|
||||
|
||||
this.recordingProcess?.on("exit", (code) => {
|
||||
clearTimeout(timeoutId)
|
||||
Logger.info(`Recording process exited with code: ${code}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs comprehensive cleanup of recording resources
|
||||
* @param options - Cleanup options
|
||||
* @param options.keepFile - If true, preserves the temporary file
|
||||
*/
|
||||
private async performCleanup(options?: { keepFile?: boolean }): Promise<void> {
|
||||
await this.terminateProcess()
|
||||
this.resetRecordingState()
|
||||
|
||||
if (!options?.keepFile) {
|
||||
await this.cleanupTempFile()
|
||||
}
|
||||
}
|
||||
|
||||
async startRecording(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Defensive cleanup before starting - ensures clean state
|
||||
if (this.recordingProcess || this.outputFile) {
|
||||
Logger.info("Performing pre-recording cleanup of stale resources...")
|
||||
await this.performCleanup()
|
||||
}
|
||||
|
||||
if (this.isRecording) {
|
||||
return { success: false, error: "Already recording" }
|
||||
}
|
||||
|
||||
// Check if recording software is available
|
||||
const checkResult = this.checkRecordingDependencies()
|
||||
if (!checkResult.available) {
|
||||
return { success: false, error: checkResult.error }
|
||||
}
|
||||
|
||||
// Create temporary file for audio output
|
||||
const tempDir = os.tmpdir()
|
||||
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
|
||||
|
||||
Logger.info("Starting audio recording...")
|
||||
|
||||
// Get the recording program path
|
||||
const recordProgram = this.getRecordProgram()
|
||||
if (!recordProgram) {
|
||||
return { success: false, error: "Recording program not found" }
|
||||
}
|
||||
Logger.info(`Using recording program: ${recordProgram.path}`)
|
||||
|
||||
// Set up recording arguments
|
||||
const args = recordProgram.getArgs(this.outputFile)
|
||||
|
||||
// Spawn the recording process
|
||||
this.recordingProcess = spawn(recordProgram.path, args)
|
||||
this.startTime = Date.now()
|
||||
|
||||
// Handle process errors
|
||||
this.recordingProcess.on("error", (error) => {
|
||||
Logger.error(`Recording process error: ${error.message}`)
|
||||
this.resetRecordingState()
|
||||
})
|
||||
|
||||
// Handle process exit
|
||||
this.recordingProcess.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
Logger.warn(`Recording process exited with code: ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
this.recordingProcess.stderr?.on("data", (data) => {
|
||||
const message = data.toString().trim()
|
||||
if (message && !message.includes("In:") && !message.includes("Out:")) {
|
||||
Logger.info(`Recording stderr: ${message}`)
|
||||
}
|
||||
})
|
||||
|
||||
Logger.info("Audio recording started successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
await this.performCleanup()
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to start audio recording: " + errorMessage)
|
||||
return { success: false, error: `Failed to start recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording(): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
||||
try {
|
||||
if (!this.isRecording) {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Stopping audio recording...")
|
||||
|
||||
// Terminate the process but keep the file for reading
|
||||
await this.terminateProcess()
|
||||
this.resetRecordingState()
|
||||
|
||||
// Wait a moment for file to be fully written
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// Read the audio file and convert to base64
|
||||
if (!fs.existsSync(this.outputFile)) {
|
||||
return { success: false, error: "Recording file not found" }
|
||||
}
|
||||
|
||||
const audioBuffer = fs.readFileSync(this.outputFile)
|
||||
const audioBase64 = audioBuffer.toString("base64")
|
||||
|
||||
// Clean up temporary file after reading
|
||||
await this.cleanupTempFile()
|
||||
|
||||
Logger.info("Audio recording stopped and converted to base64")
|
||||
return { success: true, audioBase64 }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to stop audio recording: " + errorMessage)
|
||||
|
||||
// Ensure cleanup happens even on error
|
||||
await this.performCleanup()
|
||||
|
||||
return { success: false, error: `Failed to stop recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
async cancelRecording(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.isRecording) {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Canceling audio recording...")
|
||||
|
||||
// Perform full cleanup including file deletion
|
||||
await this.performCleanup()
|
||||
|
||||
Logger.info("Audio recording canceled successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to cancel audio recording: " + errorMessage)
|
||||
|
||||
// Ensure cleanup happens even on error
|
||||
await this.performCleanup()
|
||||
|
||||
return { success: false, error: `Failed to cancel recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
getRecordingStatus(): { isRecording: boolean; durationSeconds: number; error?: string } {
|
||||
const durationSeconds = this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
|
||||
return {
|
||||
isRecording: this.isRecording,
|
||||
durationSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
private checkRecordingDependencies(): { available: boolean; error?: string } {
|
||||
const program = this.getRecordProgram()
|
||||
if (!program) {
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
const error = config ? config.error : `Audio recording is not supported on platform: ${platform}`
|
||||
return { available: false, error }
|
||||
}
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
private getRecordProgram(): { path: string; getArgs: (outputFile: string) => string[] } | undefined {
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 1. Check if the command is in the system's PATH
|
||||
const pathDirs = (process.env.PATH || "").split(path.delimiter)
|
||||
for (const dir of pathDirs) {
|
||||
const fullPath = path.join(dir, config.command)
|
||||
if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
|
||||
return { path: fullPath, getArgs: config.getArgs }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check fallback paths if not in PATH
|
||||
for (const p of config.fallbackPaths) {
|
||||
if (fs.existsSync(p) && isExecutable(p)) {
|
||||
return { path: p, getArgs: config.getArgs }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Public cleanup method for service shutdown
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Use async cleanup but don't await since this is often called in sync contexts
|
||||
this.performCleanup().catch((error) => {
|
||||
Logger.error("Error during cleanup: " + (error instanceof Error ? error.message : String(error)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const audioRecordingService = new AudioRecordingService()
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import axios from "axios"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
|
||||
// Network error matchers using Map for O(1) lookup
|
||||
const NETWORK_ERROR_MAP = new Map<string, string>([
|
||||
["enotfound", "No internet connection. Please check your network and try again."],
|
||||
["econnrefused", "Cannot connect to transcription service. Please check your internet connection."],
|
||||
["etimedout", "Connection timed out. Please check your internet connection and try again."],
|
||||
["econnreset", "Connection timed out. Please check your internet connection and try again."],
|
||||
["network error", "Network error. Please check your internet connection."],
|
||||
])
|
||||
|
||||
// HTTP status code error messages using Map for O(1) lookup
|
||||
const STATUS_ERROR_MAP = new Map<number, string>([
|
||||
[401, "Authentication failed. Please reauthenticate your Cline account"],
|
||||
[402, "Insufficient credits for transcription service."],
|
||||
[500, "Transcription server error. Please try again later."],
|
||||
])
|
||||
|
||||
// Special 400 error patterns that need custom handling
|
||||
const BAD_REQUEST_ERROR_PATTERNS = [
|
||||
{
|
||||
patterns: ["insufficient balance", "insufficient credits"],
|
||||
message: "Insufficient credits for transcription service.",
|
||||
},
|
||||
{
|
||||
patterns: ["invalid audio", "invalid format"],
|
||||
message: "Invalid audio format. Please try recording again.",
|
||||
},
|
||||
]
|
||||
|
||||
export class VoiceTranscriptionService {
|
||||
private readonly clineAccountService: ClineAccountService
|
||||
|
||||
constructor() {
|
||||
this.clineAccountService = ClineAccountService.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses transcription errors and returns user-friendly error messages
|
||||
* @param error The error object from the transcription attempt
|
||||
* @returns An object with the error message
|
||||
*/
|
||||
private parseTranscriptionError(error: unknown): { error: string } {
|
||||
// Handle axios errors with proper status code mapping
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
// Extract error message from server response - check both 'error' and 'message' fields
|
||||
const rawMessage = error.response?.data?.error || error.response?.data?.message || error.message
|
||||
const lowerMessage = rawMessage.toLowerCase()
|
||||
|
||||
// Check for network errors using the Map (these don't have status codes)
|
||||
for (const [keyword, response] of NETWORK_ERROR_MAP) {
|
||||
if (lowerMessage.includes(keyword)) {
|
||||
return { error: response }
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have a simple status code mapping
|
||||
if (status && STATUS_ERROR_MAP.has(status)) {
|
||||
return { error: STATUS_ERROR_MAP.get(status)! }
|
||||
}
|
||||
|
||||
// Handle special 400 errors with pattern matching
|
||||
if (status === 400) {
|
||||
// Check for specific error patterns
|
||||
for (const { patterns, message } of BAD_REQUEST_ERROR_PATTERNS) {
|
||||
if (patterns.some((pattern) => lowerMessage.includes(pattern))) {
|
||||
return { error: message }
|
||||
}
|
||||
}
|
||||
|
||||
// Check for limit exceeded messages (preserve original message)
|
||||
if (lowerMessage.includes("exceeds") && lowerMessage.includes("limit")) {
|
||||
return { error: rawMessage }
|
||||
}
|
||||
|
||||
// For other 400 errors, show the server's message if available, otherwise use generic
|
||||
return { error: rawMessage || "Invalid audio format or request data." }
|
||||
}
|
||||
|
||||
// Default case for unhandled status codes
|
||||
return {
|
||||
error: "Transcription failed. Please try again later or raise an issue on https://github.com/cline/cline/issues",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-axios errors (general network errors)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const lowerErrorMessage = errorMessage.toLowerCase()
|
||||
|
||||
// Check network errors using the Map
|
||||
for (const [keyword, response] of NETWORK_ERROR_MAP) {
|
||||
if (lowerErrorMessage.includes(keyword)) {
|
||||
return { error: response }
|
||||
}
|
||||
}
|
||||
|
||||
return { error: `Network error: ${errorMessage}` }
|
||||
}
|
||||
|
||||
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
|
||||
try {
|
||||
Logger.info("Transcribing audio with Cline transcription service...")
|
||||
|
||||
// Check if using organization account for telemetry
|
||||
const userInfo = await this.clineAccountService.fetchMe()
|
||||
const activeOrg = userInfo?.organizations?.find((org) => org.active)
|
||||
const isOrgAccount = !!activeOrg
|
||||
|
||||
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
|
||||
|
||||
Logger.info("Transcription successful")
|
||||
|
||||
// Capture telemetry with account type - use dynamic import to avoid circular dependency
|
||||
const { telemetryService } = await import("@/services/telemetry")
|
||||
telemetryService.captureVoiceTranscriptionCompleted(
|
||||
undefined, // taskId
|
||||
result.text?.length,
|
||||
undefined, // duration
|
||||
language,
|
||||
isOrgAccount,
|
||||
)
|
||||
|
||||
return { text: result.text }
|
||||
} catch (error) {
|
||||
Logger.error("Voice transcription error:", error)
|
||||
return this.parseTranscriptionError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily construct the service to avoid circular import initialization issues
|
||||
let _voiceTranscriptionServiceInstance: VoiceTranscriptionService | null = null
|
||||
export function getVoiceTranscriptionService(): VoiceTranscriptionService {
|
||||
if (!_voiceTranscriptionServiceInstance) {
|
||||
_voiceTranscriptionServiceInstance = new VoiceTranscriptionService()
|
||||
}
|
||||
return _voiceTranscriptionServiceInstance
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
|
||||
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
|
||||
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
|
||||
*/
|
||||
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain"
|
||||
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation"
|
||||
|
||||
/**
|
||||
* Enum for terminal output failure reasons
|
||||
@@ -76,6 +76,7 @@ export class TelemetryService {
|
||||
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
|
||||
["checkpoints", true], // Checkpoints telemetry enabled
|
||||
["browser", true], // Browser telemetry enabled
|
||||
["dictation", true], // Dictation telemetry enabled
|
||||
["focus_chain", true], // Focus Chain telemetry enabled
|
||||
])
|
||||
|
||||
@@ -88,6 +89,19 @@ export class TelemetryService {
|
||||
TELEMETRY_ENABLED: "user.telemetry_enabled",
|
||||
EXTENSION_ACTIVATED: "user.extension_activated",
|
||||
},
|
||||
DICTATION: {
|
||||
// Tracks when voice recording is started
|
||||
RECORDING_STARTED: "voice.recording_started",
|
||||
// Tracks when voice recording is stopped
|
||||
RECORDING_STOPPED: "voice.recording_stopped",
|
||||
// Tracks when voice transcription is started
|
||||
TRANSCRIPTION_STARTED: "voice.transcription_started",
|
||||
// Tracks when voice transcription is completed successfully
|
||||
TRANSCRIPTION_COMPLETED: "voice.transcription_completed",
|
||||
// Tracks when voice transcription fails
|
||||
TRANSCRIPTION_ERROR: "voice.transcription_error",
|
||||
// Tracks when voice feature is enabled or disabled in settings
|
||||
},
|
||||
// Workspace-related events for multi-root support
|
||||
WORKSPACE: {
|
||||
// Track workspace initialization
|
||||
@@ -285,7 +299,126 @@ export class TelemetryService {
|
||||
setDistinctId(userInfo.id)
|
||||
}
|
||||
}
|
||||
// Dictation events
|
||||
/**
|
||||
* Records when voice recording is started
|
||||
* @param taskId Optional task identifier if recording was started during a task
|
||||
* @param platform The platform where recording is happening (macOS, Windows, Linux)
|
||||
*/
|
||||
public captureVoiceRecordingStarted(taskId?: string, platform?: string) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.RECORDING_STARTED,
|
||||
properties: {
|
||||
taskId,
|
||||
platform: platform ?? process.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice recording is stopped
|
||||
* @param taskId Optional task identifier if recording was stopped during a task
|
||||
* @param durationMs Duration of the recording in milliseconds
|
||||
* @param success Whether the recording was successful
|
||||
* @param platform The platform where recording happened
|
||||
*/
|
||||
public captureVoiceRecordingStopped(taskId?: string, durationMs?: number, success?: boolean, platform?: string) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.RECORDING_STOPPED,
|
||||
properties: {
|
||||
taskId,
|
||||
durationMs,
|
||||
success,
|
||||
platform: platform ?? process.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription is started
|
||||
* @param taskId Optional task identifier if transcription was started during a task
|
||||
* @param language Language hint provided for transcription
|
||||
*/
|
||||
public captureVoiceTranscriptionStarted(taskId?: string, language?: string) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_STARTED,
|
||||
properties: {
|
||||
taskId,
|
||||
language,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription is completed successfully
|
||||
* @param taskId Optional task identifier if transcription was completed during a task
|
||||
* @param transcriptionLength Length of the transcribed text
|
||||
* @param durationMs Time taken for transcription in milliseconds
|
||||
* @param language Language used for transcription
|
||||
* @param isOrgAccount Whether the transcription was done using an organization account
|
||||
*/
|
||||
public captureVoiceTranscriptionCompleted(
|
||||
taskId?: string,
|
||||
transcriptionLength?: number,
|
||||
durationMs?: number,
|
||||
language?: string,
|
||||
isOrgAccount?: boolean,
|
||||
) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_COMPLETED,
|
||||
properties: {
|
||||
taskId,
|
||||
transcriptionLength,
|
||||
durationMs,
|
||||
language,
|
||||
accountType: isOrgAccount ? "organization" : "personal",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription fails
|
||||
* @param taskId Optional task identifier if transcription failed during a task
|
||||
* @param errorType Type of error that occurred (e.g., "no_openai_key", "api_error", "network_error")
|
||||
* @param errorMessage The error message
|
||||
* @param durationMs Time taken before failure in milliseconds
|
||||
*/
|
||||
public captureVoiceTranscriptionError(taskId?: string, errorType?: string, errorMessage?: string, durationMs?: number) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_ERROR,
|
||||
properties: {
|
||||
taskId,
|
||||
errorType,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Task events
|
||||
/**
|
||||
* Records when a new task/conversation is started
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { ErrorService } from "../error"
|
||||
import { Logger } from "../logging/Logger"
|
||||
import { SharedUriHandler } from "./SharedUriHandler"
|
||||
|
||||
describe("SharedUriHandler", () => {
|
||||
@@ -9,9 +11,27 @@ describe("SharedUriHandler", () => {
|
||||
let handleOpenRouterCallbackStub: sinon.SinonStub
|
||||
let handleAuthCallbackStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock Logger methods to avoid HostProvider dependency
|
||||
sandbox.stub(Logger, "info").returns()
|
||||
sandbox.stub(Logger, "error").returns()
|
||||
// Mock ErrorService to avoid telemetry dependency
|
||||
const mockErrorService = {
|
||||
logMessage: sandbox.stub(),
|
||||
logException: sandbox.stub(),
|
||||
toClineError: sandbox.stub(),
|
||||
isEnabled: sandbox.stub().returns(false),
|
||||
getSettings: sandbox.stub().returns({ enabled: false, hostEnabled: false }),
|
||||
getProvider: sandbox.stub(),
|
||||
dispose: sandbox.stub().resolves(),
|
||||
}
|
||||
sandbox.stub(ErrorService, "initialize").resolves(mockErrorService as any)
|
||||
sandbox.stub(ErrorService, "get").returns(mockErrorService as any)
|
||||
|
||||
await ErrorService.initialize()
|
||||
|
||||
handleOpenRouterCallbackStub = sandbox.stub().resolves()
|
||||
handleAuthCallbackStub = sandbox.stub().resolves()
|
||||
const mockWebviewProvider = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { Logger } from "../logging/Logger"
|
||||
|
||||
/**
|
||||
* Shared URI handler that processes both VSCode URI events and HTTP server callbacks
|
||||
@@ -18,16 +19,19 @@ export class SharedUriHandler {
|
||||
const queryString = parsedUrl.search.slice(1) // Remove leading '?'
|
||||
const query = new URLSearchParams(queryString.replace(/\+/g, "%2B"))
|
||||
|
||||
console.log("SharedUriHandler: Processing URI:", {
|
||||
path: path,
|
||||
query: query,
|
||||
scheme: parsedUrl.protocol,
|
||||
})
|
||||
Logger.info(
|
||||
"SharedUriHandler: Processing URI:" +
|
||||
JSON.stringify({
|
||||
path: path,
|
||||
query: query,
|
||||
scheme: parsedUrl.protocol,
|
||||
}),
|
||||
)
|
||||
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
|
||||
if (!visibleWebview) {
|
||||
console.warn("SharedUriHandler: No visible webview found")
|
||||
Logger.warn("SharedUriHandler: No visible webview found")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -44,15 +48,15 @@ export class SharedUriHandler {
|
||||
}
|
||||
case "/auth": {
|
||||
const provider = query.get("provider")
|
||||
const token = query.get("idToken")
|
||||
|
||||
console.log("SharedUriHandler: Auth callback received:", { path: path, provider: provider })
|
||||
Logger.info(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
|
||||
|
||||
const token = query.get("refreshToken") || query.get("idToken") || query.get("code")
|
||||
if (token) {
|
||||
await visibleWebview.controller.handleAuthCallback(token, provider)
|
||||
return true
|
||||
}
|
||||
console.warn("SharedUriHandler: Missing idToken parameter for auth callback")
|
||||
Logger.warn("SharedUriHandler: Missing idToken parameter for auth callback")
|
||||
return false
|
||||
}
|
||||
case "/auth/oca": {
|
||||
@@ -69,11 +73,11 @@ export class SharedUriHandler {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
console.warn(`SharedUriHandler: Unknown path: ${path}`)
|
||||
Logger.warn(`SharedUriHandler: Unknown path: ${path}`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SharedUriHandler: Error processing URI:", error)
|
||||
Logger.error("SharedUriHandler: Error processing URI:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ClineFeatureSetting {
|
||||
// Setting is enabled or disabled by user
|
||||
user: boolean
|
||||
// Setting is enabled or disabled by feature flag
|
||||
featureFlag: boolean
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface DictationSettings {
|
||||
featureEnabled: boolean // Feature flag - whether dictation feature is available
|
||||
dictationEnabled: boolean // User preference - whether user has enabled dictation
|
||||
dictationLanguage: string
|
||||
}
|
||||
|
||||
export const DEFAULT_DICTATION_SETTINGS: DictationSettings = {
|
||||
featureEnabled: false, // Feature flag, will be set by the extension based on platform
|
||||
dictationEnabled: false, // Default is false while this service is in Experimental status
|
||||
dictationLanguage: "en",
|
||||
}
|
||||
|
||||
export interface LanguageItem {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export const SUPPORTED_DICTATION_LANGUAGES: LanguageItem[] = [
|
||||
{ name: "English", code: "en" },
|
||||
{ name: "Spanish (Español)", code: "es" },
|
||||
{ name: "Chinese (中文)", code: "zh" },
|
||||
{ name: "Japanese (日本語)", code: "ja" },
|
||||
{ name: "Afrikaans", code: "af" },
|
||||
{ name: "Arabic (العربية)", code: "ar" },
|
||||
{ name: "Armenian (Հայերեն)", code: "hy" },
|
||||
{ name: "Azerbaijani (Azərbaycan)", code: "az" },
|
||||
{ name: "Belarusian (Беларуская)", code: "be" },
|
||||
{ name: "Bosnian (Bosanski)", code: "bs" },
|
||||
{ name: "Bulgarian (Български)", code: "bg" },
|
||||
{ name: "Catalan (Català)", code: "ca" },
|
||||
{ name: "Croatian (Hrvatski)", code: "hr" },
|
||||
{ name: "Czech (Čeština)", code: "cs" },
|
||||
{ name: "Danish (Dansk)", code: "da" },
|
||||
{ name: "Dutch (Nederlands)", code: "nl" },
|
||||
{ name: "Estonian (Eesti)", code: "et" },
|
||||
{ name: "Finnish (Suomi)", code: "fi" },
|
||||
{ name: "French (Français)", code: "fr" },
|
||||
{ name: "Galician (Galego)", code: "gl" },
|
||||
{ name: "German (Deutsch)", code: "de" },
|
||||
{ name: "Greek (Ελληνικά)", code: "el" },
|
||||
{ name: "Hebrew (עברית)", code: "he" },
|
||||
{ name: "Hindi (हिन्दी)", code: "hi" },
|
||||
{ name: "Hungarian (Magyar)", code: "hu" },
|
||||
{ name: "Icelandic (Íslenska)", code: "is" },
|
||||
{ name: "Indonesian (Bahasa Indonesia)", code: "id" },
|
||||
{ name: "Italian (Italiano)", code: "it" },
|
||||
{ name: "Kannada (ಕನ್ನಡ)", code: "kn" },
|
||||
{ name: "Kazakh (Қазақша)", code: "kk" },
|
||||
{ name: "Korean (한국어)", code: "ko" },
|
||||
{ name: "Latvian (Latviešu)", code: "lv" },
|
||||
{ name: "Lithuanian (Lietuvių)", code: "lt" },
|
||||
{ name: "Macedonian (Македонски)", code: "mk" },
|
||||
{ name: "Malay (Bahasa Melayu)", code: "ms" },
|
||||
{ name: "Marathi (मराठी)", code: "mr" },
|
||||
{ name: "Maori (Te Reo Māori)", code: "mi" },
|
||||
{ name: "Nepali (नेपाली)", code: "ne" },
|
||||
{ name: "Norwegian (Norsk)", code: "no" },
|
||||
{ name: "Persian (فارسی)", code: "fa" },
|
||||
{ name: "Polish (Polski)", code: "pl" },
|
||||
{ name: "Portuguese (Português)", code: "pt" },
|
||||
{ name: "Romanian (Română)", code: "ro" },
|
||||
{ name: "Russian (Русский)", code: "ru" },
|
||||
{ name: "Serbian (Српски)", code: "sr" },
|
||||
{ name: "Slovak (Slovenčina)", code: "sk" },
|
||||
{ name: "Slovenian (Slovenščina)", code: "sl" },
|
||||
{ name: "Swahili (Kiswahili)", code: "sw" },
|
||||
{ name: "Swedish (Svenska)", code: "sv" },
|
||||
{ name: "Tagalog", code: "tl" },
|
||||
{ name: "Tamil (தமிழ்)", code: "ta" },
|
||||
{ name: "Thai (ไทย)", code: "th" },
|
||||
{ name: "Turkish (Türkçe)", code: "tr" },
|
||||
{ name: "Ukrainian (Українська)", code: "uk" },
|
||||
{ name: "Urdu (اردو)", code: "ur" },
|
||||
{ name: "Vietnamese (Tiếng Việt)", code: "vi" },
|
||||
{ name: "Welsh (Cymraeg)", code: "cy" },
|
||||
]
|
||||
@@ -4,14 +4,15 @@ import { WorkspaceRoot } from "../core/workspace"
|
||||
import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { ApiConfiguration } from "./api"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ClineFeatureSetting } from "./ClineFeatureSetting"
|
||||
import { ClineRulesToggles } from "./cline-rules"
|
||||
import { DictationSettings } from "./DictationSettings"
|
||||
import { FocusChainSettings } from "./FocusChainSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpDisplayMode } from "./McpDisplayMode"
|
||||
import { Mode, OpenaiReasoningEffort } from "./storage/types"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
type: "grpc_response" // New type for gRPC responses
|
||||
@@ -70,12 +71,16 @@ export interface ExtensionState {
|
||||
yoloModeToggled?: boolean
|
||||
useAutoCondense?: boolean
|
||||
focusChainSettings: FocusChainSettings
|
||||
dictationSettings: DictationSettings
|
||||
customPrompt?: string
|
||||
autoCondenseThreshold?: number
|
||||
favoritedModelIds: string[]
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: WorkspaceRoot[]
|
||||
primaryRootIndex: number
|
||||
isMultiRootWorkspace: boolean
|
||||
multiRootSetting: ClineFeatureSetting
|
||||
lastDismissedInfoBannerVersion: number
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
||||
+12
-1
@@ -262,6 +262,7 @@ export const CLAUDE_SONNET_4_1M_TIERS = [
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
|
||||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-20250514"
|
||||
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
|
||||
export const anthropicModels = {
|
||||
"claude-sonnet-4-20250514:1m": {
|
||||
maxTokens: 8192,
|
||||
@@ -596,7 +597,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
|
||||
// Cline custom model - code-supernova
|
||||
export const clineCodeSupernovaModelInfo: ModelInfo = {
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
@@ -2509,6 +2510,16 @@ export const nebiusDefaultModelId = "Qwen/Qwen2.5-32B-Instruct-fast" satisfies N
|
||||
export type XAIModelId = keyof typeof xaiModels
|
||||
export const xaiDefaultModelId: XAIModelId = "grok-4"
|
||||
export const xaiModels = {
|
||||
"grok-4-fast-reasoning": {
|
||||
maxTokens: 30000,
|
||||
contextWindow: 2000000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.2,
|
||||
cacheReadsPrice: 0.05,
|
||||
outputPrice: 0.5,
|
||||
description: "xAI's Grok 4 Fast (free) multimodal model with 2M context.",
|
||||
},
|
||||
"grok-4": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 262144,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export const AUDIO_PROGRAM_CONFIG = {
|
||||
darwin: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"avfoundation",
|
||||
"-i",
|
||||
":default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
dependencyName: "FFmpeg",
|
||||
installCommand: "brew install ffmpeg",
|
||||
error: "FFmpeg is required for voice recording but is not installed on your system.",
|
||||
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
|
||||
},
|
||||
linux: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"alsa",
|
||||
"-i",
|
||||
"default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
dependencyName: "FFmpeg",
|
||||
installCommand: "sudo apt-get update && sudo apt-get install -y ffmpeg",
|
||||
error: "FFmpeg is required for voice recording but is not installed on your system.",
|
||||
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
|
||||
},
|
||||
win32: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: [
|
||||
"C:\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"wasapi",
|
||||
"-i",
|
||||
"audio=default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
dependencyName: "FFmpeg",
|
||||
installCommand: "winget install Gyan.FFmpeg",
|
||||
error: "FFmpeg is required for voice recording but is not installed on your system.",
|
||||
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
enum CLINE_API_AUTH_ENDPOINTS {
|
||||
AUTH = "/api/v1/auth/authorize",
|
||||
REFRESH_TOKEN = "/api/v1/auth/refresh",
|
||||
}
|
||||
|
||||
enum CLINE_API_ENDPOINT_V1 {
|
||||
TOKEN_EXCHANGE = "/api/v1/auth/token",
|
||||
USER_INFO = "/api/v1/users/me",
|
||||
ACTIVE_ACCOUNT = "/api/v1/users/active-account",
|
||||
}
|
||||
|
||||
export const CLINE_API_ENDPOINT = {
|
||||
...CLINE_API_AUTH_ENDPOINTS,
|
||||
...CLINE_API_ENDPOINT_V1,
|
||||
}
|
||||
+1
-1
@@ -111,4 +111,4 @@ export interface McpDownloadResponse {
|
||||
requiresApiKey: boolean
|
||||
}
|
||||
|
||||
export type McpViewTab = "marketplace" | "addRemote" | "installed"
|
||||
export type McpViewTab = "marketplace" | "addRemote" | "configure"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum FeatureFlag {
|
||||
CUSTOM_INSTRUCTIONS = "custom-instructions",
|
||||
DEV_ENV_POSTHOG = "dev-env-posthog",
|
||||
DICTATION = "dictation",
|
||||
FOCUS_CHAIN_CHECKLIST = "focus_chain_checklist",
|
||||
MULTI_ROOT_WORKSPACE = "multi_root_workspace",
|
||||
}
|
||||
|
||||
+30
-234
@@ -2,211 +2,66 @@ import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvid
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
|
||||
import { WebviewProviderType } from "@shared/webview/types"
|
||||
import { retryOperation } from "@utils/retry"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { checkPortAvailability } from "./port-checker"
|
||||
import { startProtobusService, waitForHostBridgeReady } from "./protobus-service"
|
||||
import { log, SETTINGS_SUBFOLDER } from "./utils"
|
||||
import { createExtensionContext } from "./vscode-context"
|
||||
|
||||
// Default ports
|
||||
export const DEFAULT_PROTOBUS_PORT = 26040
|
||||
export const DEFAULT_HOSTBRIDGE_PORT = 26041
|
||||
|
||||
// Parse command line arguments
|
||||
interface CliArgs {
|
||||
port?: number
|
||||
hostBridgePort?: number
|
||||
config?: string
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
function parseArgs(): CliArgs {
|
||||
const args: CliArgs = {}
|
||||
const argv = process.argv.slice(2)
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
switch (arg) {
|
||||
case "--port":
|
||||
case "-p":
|
||||
args.port = parseInt(argv[++i], 10)
|
||||
break
|
||||
case "--host-bridge-port":
|
||||
args.hostBridgePort = parseInt(argv[++i], 10)
|
||||
break
|
||||
case "--config":
|
||||
case "-c":
|
||||
args.config = argv[++i]
|
||||
break
|
||||
case "--help":
|
||||
case "-h":
|
||||
args.help = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Cline Core - Standalone Server
|
||||
|
||||
Usage: node cline-core.js [options]
|
||||
|
||||
Options:
|
||||
-p, --port <port> Port for the main gRPC service (default: ${DEFAULT_PROTOBUS_PORT})
|
||||
--host-bridge-port <port> Port for the host bridge service (default: ${DEFAULT_HOSTBRIDGE_PORT})
|
||||
-c, --config <path> Directory for Cline data storage (default: ~/.cline)
|
||||
-h, --help Show this help message
|
||||
|
||||
Environment Variables:
|
||||
PROTOBUS_ADDRESS Override the main service address (format: host:port)
|
||||
HOSTBRIDGE_ADDRESS Override the host bridge address (format: host:port)
|
||||
`)
|
||||
}
|
||||
import { waitForHostBridgeReady } from "./hostbridge-client"
|
||||
import { startProtobusService } from "./protobus-service"
|
||||
import { log } from "./utils"
|
||||
import { DATA_DIR, EXTENSION_DIR, extensionContext } from "./vscode-context"
|
||||
|
||||
async function main() {
|
||||
// Parse command line arguments
|
||||
const args = parseArgs()
|
||||
|
||||
// Show help if requested
|
||||
if (args.help) {
|
||||
showHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Configure ports from arguments
|
||||
let protobusPort = DEFAULT_PROTOBUS_PORT
|
||||
let hostBridgePort = DEFAULT_HOSTBRIDGE_PORT
|
||||
|
||||
if (args.port) {
|
||||
protobusPort = args.port
|
||||
// If only port is specified, calculate hostbridge port as port + 1000
|
||||
if (!args.hostBridgePort) {
|
||||
hostBridgePort = protobusPort + 1000
|
||||
}
|
||||
}
|
||||
if (args.hostBridgePort) {
|
||||
hostBridgePort = args.hostBridgePort
|
||||
}
|
||||
|
||||
// Set environment variables for the services to use
|
||||
if (!process.env.PROTOBUS_ADDRESS) {
|
||||
process.env.PROTOBUS_ADDRESS = `localhost:${protobusPort}`
|
||||
}
|
||||
if (!process.env.HOSTBRIDGE_ADDRESS) {
|
||||
process.env.HOSTBRIDGE_ADDRESS = `localhost:${hostBridgePort}`
|
||||
}
|
||||
|
||||
// Configure Cline directory from arguments
|
||||
const clineDir = args.config || `${os.homedir()}/.cline`
|
||||
|
||||
log("\n\n\nStarting cline-core service...\n\n\n")
|
||||
log(`Using Protobus port: ${protobusPort}`)
|
||||
log(`Using Host Bridge port: ${hostBridgePort}`)
|
||||
log(`Using Cline directory: ${clineDir}`)
|
||||
|
||||
// Initialize SQLite lock manager for instance registration
|
||||
const dbPath = `${clineDir}/${SETTINGS_SUBFOLDER}/locks.db`
|
||||
// Use host:port everywhere (no scheme)
|
||||
const fullAddress = `localhost:${protobusPort}`
|
||||
let lockManager: SqliteLockManager | undefined
|
||||
try {
|
||||
lockManager = new SqliteLockManager({
|
||||
dbPath,
|
||||
instanceAddress: fullAddress,
|
||||
})
|
||||
await waitForHostBridgeReady()
|
||||
|
||||
// Check port availability before proceeding
|
||||
log(`Checking port availability for ${protobusPort}...`)
|
||||
const portCheck = await checkPortAvailability(protobusPort, lockManager)
|
||||
|
||||
if (!portCheck.canProceed) {
|
||||
log(`STARTUP BLOCKED: ${portCheck.error}`)
|
||||
lockManager.close()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await lockManager.registerInstance({
|
||||
corePort: protobusPort,
|
||||
hostPort: hostBridgePort,
|
||||
version: process.env.CLINE_VERSION,
|
||||
status: "starting",
|
||||
})
|
||||
log(`Registered instance in SQLite locks: ${fullAddress}`)
|
||||
} catch (err) {
|
||||
log(`CRITICAL ERROR: Failed to register instance in SQLite locks: ${String(err)}`)
|
||||
log(`This is a fatal error - cline-core cannot start without proper instance registration`)
|
||||
if (lockManager) {
|
||||
try {
|
||||
lockManager.close()
|
||||
} catch {}
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForHostBridgeReady()
|
||||
log("HostBridge is serving; continuing startup")
|
||||
} catch (err) {
|
||||
log(`ERROR: HostBridge error: ${String(err)}`)
|
||||
// Cleanup lock manager entry if startup fails
|
||||
if (lockManager) {
|
||||
try {
|
||||
lockManager.unregisterInstance()
|
||||
lockManager.close()
|
||||
} catch {}
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create extension context with the configured directory
|
||||
const extensionContext = createExtensionContext(clineDir)
|
||||
|
||||
setupHostProvider(extensionContext)
|
||||
// The host bridge should be available before creating the host provider because it depends on the host bridge.
|
||||
setupHostProvider()
|
||||
|
||||
// Set up global error handlers to prevent process crashes
|
||||
setupGlobalErrorHandlers(lockManager)
|
||||
setupGlobalErrorHandlers()
|
||||
|
||||
const webviewProvider = await initialize(extensionContext)
|
||||
|
||||
// Enable the localhost HTTP server that handles auth redirects.
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
|
||||
startProtobusService(webviewProvider.controller)
|
||||
|
||||
// Mark instance healthy after services are up
|
||||
try {
|
||||
lockManager?.touchInstance()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setupHostProvider(extensionContext: any) {
|
||||
function setupHostProvider() {
|
||||
const createWebview = (_: WebviewProviderType): WebviewProvider => {
|
||||
return new ExternalWebviewProvider(extensionContext, WebviewProviderType.SIDEBAR)
|
||||
}
|
||||
const createDiffView = (): DiffViewProvider => {
|
||||
return new ExternalDiffViewProvider()
|
||||
}
|
||||
const getCallbackUri = (): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUri()
|
||||
const getCallbackUrl = (): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUrl()
|
||||
}
|
||||
// cline-core expects the binaries to be unpacked in the directory where it is running.
|
||||
const getBinaryLocation = async (name: string): Promise<string> => path.join(process.cwd(), name)
|
||||
|
||||
HostProvider.initialize(createWebview, createDiffView, new ExternalHostBridgeClientManager(), log, getCallbackUri)
|
||||
HostProvider.initialize(
|
||||
createWebview,
|
||||
createDiffView,
|
||||
new ExternalHostBridgeClientManager(),
|
||||
log,
|
||||
getCallbackUrl,
|
||||
getBinaryLocation,
|
||||
EXTENSION_DIR,
|
||||
DATA_DIR,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up global error handlers to prevent the process from crashing
|
||||
* on unhandled exceptions and promise rejections
|
||||
*/
|
||||
function setupGlobalErrorHandlers(lockManager?: SqliteLockManager) {
|
||||
function setupGlobalErrorHandlers() {
|
||||
// Handle unhandled exceptions
|
||||
process.on("uncaughtException", (error: Error) => {
|
||||
log(`ERROR: Uncaught exception: ${error.message}`)
|
||||
@@ -231,74 +86,15 @@ function setupGlobalErrorHandlers(lockManager?: SqliteLockManager) {
|
||||
// Graceful shutdown handlers
|
||||
process.on("SIGINT", () => {
|
||||
log("Received SIGINT, shutting down gracefully...")
|
||||
shutdownGracefully(lockManager)
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
log("Received SIGTERM, shutting down gracefully...")
|
||||
shutdownGracefully(lockManager)
|
||||
tearDown()
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Request host bridge shutdown with retry logic and timeout handling.
|
||||
* Uses best-effort approach - logs failures but doesn't block shutdown.
|
||||
*/
|
||||
async function requestHostBridgeShutdown(): Promise<void> {
|
||||
try {
|
||||
await retryOperation(3, 2000, async () => {
|
||||
await HostProvider.env.shutdown({})
|
||||
})
|
||||
log("Host bridge shutdown requested successfully")
|
||||
} catch (error) {
|
||||
log(`Warning: Failed to request host bridge shutdown: ${error}`)
|
||||
log("Proceeding with cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shutdown the cline-core process by:
|
||||
* 1. Calling shutdown RPC on the paired host bridge
|
||||
* 2. Cleaning up the lock manager entry
|
||||
* 3. Tearing down services
|
||||
* 4. Exiting the process
|
||||
*/
|
||||
async function shutdownGracefully(lockManager?: SqliteLockManager) {
|
||||
try {
|
||||
// Step 1: Tell the paired host bridge to shut down
|
||||
log("Requesting host bridge shutdown...")
|
||||
if (HostProvider.isInitialized()) {
|
||||
await requestHostBridgeShutdown()
|
||||
} else {
|
||||
log("Warning: HostProvider not initialized, cannot request shutdown")
|
||||
}
|
||||
|
||||
// Step 2: Clean up lock manager entry
|
||||
log("Cleaning up lock manager entry...")
|
||||
try {
|
||||
lockManager?.unregisterInstance()
|
||||
lockManager?.close()
|
||||
log("Lock manager entry cleaned up successfully")
|
||||
} catch (error) {
|
||||
log(`Warning: Failed to clean up lock manager: ${error}`)
|
||||
}
|
||||
|
||||
// Step 3: Tear down services
|
||||
log("Tearing down services...")
|
||||
try {
|
||||
tearDown()
|
||||
log("Services torn down successfully")
|
||||
} catch (error) {
|
||||
log(`Warning: Failed to tear down services: ${error}`)
|
||||
}
|
||||
|
||||
log("Graceful shutdown completed")
|
||||
} catch (error) {
|
||||
log(`Error during graceful shutdown: ${error}`)
|
||||
} finally {
|
||||
// Step 4: Exit the process
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -32,7 +32,8 @@ function createHealthClient(address: string) {
|
||||
const healthDef = protoLoader.loadSync(health.protoPath)
|
||||
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
|
||||
const Health = grpcObj.grpc.health.v1.Health
|
||||
return new Health(address, grpc.credentials.createInsecure())
|
||||
const opts: grpc.ChannelOptions = { "grpc.enable_http_proxy": 0 }
|
||||
return new Health(address, grpc.credentials.createInsecure(), opts)
|
||||
}
|
||||
|
||||
async function checkHealthOnce(client: any): Promise<boolean> {
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
import * as protoLoader from "@grpc/proto-loader"
|
||||
import * as health from "grpc-health-check"
|
||||
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
|
||||
import { log } from "./utils"
|
||||
|
||||
const SERVING_STATUS = 1
|
||||
|
||||
interface PortCheckResult {
|
||||
canProceed: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a gRPC health client for the given address
|
||||
*/
|
||||
function createHealthClient(address: string): any {
|
||||
const healthDef = protoLoader.loadSync(health.protoPath)
|
||||
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
|
||||
const Health = grpcObj.grpc.health.v1.Health
|
||||
return new Health(address, grpc.credentials.createInsecure())
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a single health check on the given address
|
||||
*/
|
||||
async function checkHealthOnce(address: string): Promise<{ success: boolean; status?: number; error?: Error }> {
|
||||
const client = createHealthClient(address)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
client.close?.()
|
||||
} catch {}
|
||||
resolve({ success: false, error: new Error("Health check timeout") })
|
||||
}, 5000) // 5 second timeout
|
||||
|
||||
client.check({ service: "" }, (err: unknown, resp: any) => {
|
||||
clearTimeout(timeout)
|
||||
try {
|
||||
client.close?.()
|
||||
} catch {}
|
||||
|
||||
if (err) {
|
||||
resolve({ success: false, error: err as Error })
|
||||
} else {
|
||||
resolve({ success: true, status: resp?.status })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to shut down a host bridge instance
|
||||
*/
|
||||
async function shutdownHostBridge(hostAddress: string): Promise<boolean> {
|
||||
try {
|
||||
log(`Attempting to shutdown host bridge at ${hostAddress}`)
|
||||
|
||||
// This would need to be implemented - we need a way to send shutdown to a specific host
|
||||
// For now, we'll just log that we would do this
|
||||
log(`Would send shutdown command to host bridge at ${hostAddress}`)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`Failed to shutdown host bridge at ${hostAddress}: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a port is available for binding, following the registry-first approach
|
||||
*/
|
||||
export async function checkPortAvailability(port: number, lockManager: SqliteLockManager): Promise<PortCheckResult> {
|
||||
log(`Checking port availability for port ${port}`)
|
||||
|
||||
// Step 1: Check registry first
|
||||
const registryEntry = lockManager.getInstanceByPort(port)
|
||||
|
||||
if (!registryEntry) {
|
||||
log(`No registry entry found for port ${port}, free to bind`)
|
||||
return { canProceed: true }
|
||||
}
|
||||
|
||||
log(`Found registry entry for port ${port}: instance=${registryEntry.instanceAddress}, host=${registryEntry.hostAddress}`)
|
||||
|
||||
// Step 2: Perform health check on the registered instance
|
||||
const coreAddress = registryEntry.instanceAddress
|
||||
|
||||
const performHealthCheck = async (): Promise<{ success: boolean; status?: number; error?: Error }> => {
|
||||
return await checkHealthOnce(coreAddress)
|
||||
}
|
||||
|
||||
// First health check attempt
|
||||
let healthResult = await performHealthCheck()
|
||||
|
||||
if (!healthResult.success) {
|
||||
// Health check ERROR - not our process
|
||||
log(`Health check failed for ${coreAddress}: ${healthResult.error?.message}`)
|
||||
log(`This indicates a non-Cline process is using port ${port}`)
|
||||
|
||||
// Attempt to shutdown the registered host bridge
|
||||
const shutdownSuccess = await shutdownHostBridge(registryEntry.hostAddress)
|
||||
if (shutdownSuccess) {
|
||||
log(`Successfully requested shutdown of host bridge ${registryEntry.hostAddress}`)
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
|
||||
log(`Removed stale registry entry for ${registryEntry.instanceAddress}`)
|
||||
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Port ${port} is occupied by a non-Cline process. Registry has been cleaned up.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Health check succeeded - it's our process
|
||||
if (healthResult.status === SERVING_STATUS) {
|
||||
// Healthy Cline instance already running
|
||||
log(`Healthy Cline instance already running on port ${port}`)
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `A healthy Cline instance is already running on port ${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Health check succeeded but status is not SERVING - unhealthy Cline instance
|
||||
log(`Unhealthy Cline instance detected on port ${port} (status: ${healthResult.status}), retrying in 1 second`)
|
||||
|
||||
// Wait 1 second and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// Second health check attempt
|
||||
healthResult = await performHealthCheck()
|
||||
|
||||
if (!healthResult.success) {
|
||||
// Now it's erroring - something changed
|
||||
log(`Health check now failing after retry for ${coreAddress}: ${healthResult.error?.message}`)
|
||||
|
||||
// Clean up registry since the instance is no longer responding
|
||||
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
|
||||
log(`Removed non-responsive registry entry for ${registryEntry.instanceAddress}`)
|
||||
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Port ${port} had an unhealthy Cline instance that is now non-responsive. Registry cleaned up.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (healthResult.status === SERVING_STATUS) {
|
||||
// Instance recovered
|
||||
log(`Cline instance on port ${port} has recovered and is now healthy`)
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Cline instance on port ${port} has recovered and is now serving`,
|
||||
}
|
||||
}
|
||||
|
||||
// Still unhealthy after retry
|
||||
log(`Cline instance on port ${port} remains unhealthy after retry (status: ${healthResult.status})`)
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Cline instance on port ${port} is unhealthy and did not recover after retry`,
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@ import * as fs from "fs"
|
||||
import * as health from "grpc-health-check"
|
||||
import { StreamingCallbacks } from "@/hosts/host-provider-types"
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const log = (...args: unknown[]) => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
@@ -23,7 +20,6 @@ const log = (...args: unknown[]) => {
|
||||
|
||||
function getPackageDefinition() {
|
||||
// Load service definitions.
|
||||
// When running as standalone, the descriptor set is in the same directory as the standalone.js file
|
||||
const descriptorSet = fs.readFileSync("proto/descriptor_set.pb")
|
||||
const options = { longs: Number } // Encode int64 fields as numbers
|
||||
const descriptorDefs = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet, options)
|
||||
@@ -55,4 +51,4 @@ async function asyncIteratorToCallbacks<T>(stream: AsyncIterable<T>, callbacks:
|
||||
}
|
||||
}
|
||||
|
||||
export { getPackageDefinition, log, asyncIteratorToCallbacks, SETTINGS_SUBFOLDER }
|
||||
export { getPackageDefinition, log, asyncIteratorToCallbacks }
|
||||
|
||||
@@ -1,89 +1,69 @@
|
||||
import { mkdirSync } from "fs"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Extension, ExtensionContext } from "vscode"
|
||||
import { ExtensionKind, ExtensionMode } from "vscode"
|
||||
import { URI } from "vscode-uri"
|
||||
// @ts-ignore
|
||||
import { StandaloneTerminalManager } from "../../standalone/runtime-files/vscode/enhanced-terminal"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { log } from "./utils"
|
||||
import { EnvironmentVariableCollection, MementoStore, SecretStore } from "./vscode-context-utils"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
function getPackageVersion(): string {
|
||||
// Use build-time injected version (only method)
|
||||
return process.env.CLINE_VERSION || "unknown"
|
||||
log("Running standalone cline", ExtensionRegistryInfo.version)
|
||||
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
|
||||
|
||||
export const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
export const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspace")
|
||||
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
export const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: Extension<void> = {
|
||||
id: ExtensionRegistryInfo.id,
|
||||
isActive: true,
|
||||
extensionPath: EXTENSION_DIR,
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
|
||||
exports: undefined, // There are no API exports in the standalone version.
|
||||
activate: async () => {},
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
|
||||
const VERSION = getPackageVersion()
|
||||
log("Running standalone cline ", VERSION)
|
||||
const extensionContext: ExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
function createExtensionContext(clineDir: string): ExtensionContext {
|
||||
const DATA_DIR = path.join(clineDir, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || path.join(clineDir, "core", VERSION)
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
// Set up KV stores.
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
// Set up URIs.
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR, // Deprecated, not used in cline.
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR, // Deprecated, not used in cline.
|
||||
|
||||
// Static package.json data for extension context (no filesystem reading)
|
||||
function getExtensionPackageJson(): any {
|
||||
return {
|
||||
name: "claude-dev",
|
||||
displayName: "Cline",
|
||||
version: VERSION,
|
||||
publisher: "saoudrizwan",
|
||||
}
|
||||
}
|
||||
// Logs are global per extension, not per workspace.
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR, // Deprecated, not used in cline.
|
||||
|
||||
const extension: Extension<void> = {
|
||||
id: "saoudrizwan.claude-dev",
|
||||
isActive: true,
|
||||
extensionPath: EXTENSION_DIR,
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
packageJSON: getExtensionPackageJson(),
|
||||
exports: undefined, // There are no API exports in the standalone version.
|
||||
activate: async () => {},
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR, // Deprecated, not used in cline.
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
const extensionContext: ExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
subscriptions: [], // These need to be destroyed when the extension is deactivated.
|
||||
|
||||
// Set up KV stores.
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
environmentVariableCollection: new EnvironmentVariableCollection(),
|
||||
|
||||
// Set up URIs.
|
||||
storageUri: URI.file(DATA_DIR),
|
||||
storagePath: DATA_DIR, // Deprecated, not used in cline.
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR, // Deprecated, not used in cline.
|
||||
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR, // Deprecated, not used in cline.
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR, // Deprecated, not used in cline.
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [], // These need to be destroyed when the extension is deactivated.
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection(),
|
||||
|
||||
// TODO(sjf): Workspace state needs to be per project/workspace.
|
||||
workspaceState: new MementoStore(path.join(DATA_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return extensionContext
|
||||
// Workspace state is per project/workspace when WORKSPACE_STORAGE_DIR is provided by the host.
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
// Initialize the standalone terminal manager for use by Task instances
|
||||
const standaloneTerminalManager = new StandaloneTerminalManager()
|
||||
|
||||
// Set it as a global so Task constructor can access it
|
||||
;(global as any).standaloneTerminalManager = standaloneTerminalManager
|
||||
|
||||
console.log("Finished loading vscode context...")
|
||||
|
||||
export { createExtensionContext }
|
||||
export { extensionContext }
|
||||
|
||||
@@ -48,19 +48,6 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
|
||||
const chatInputBox = sidebar.getByTestId("chat-input")
|
||||
await expect(chatInputBox).toBeVisible()
|
||||
|
||||
// Verify the help improve banner is visible and can be closed.
|
||||
const telemetryBanner = sidebar.getByText("Help Improve Cline")
|
||||
await expect(telemetryBanner).toBeVisible()
|
||||
await sidebar.getByText("settings").click() // Click on the settings link in the banner
|
||||
await expect(sidebar.getByText("General Settings")).toBeVisible() // Default view should be set to General tab
|
||||
await sidebar.getByTestId("tab-api-config").click()
|
||||
await expect(sidebar.locator("h4").getByText("API Configuration")).toBeVisible()
|
||||
await sidebar.getByTestId("tab-about").click()
|
||||
await expect(sidebar.getByRole("heading", { name: "About" }).locator("div").first()).toBeVisible()
|
||||
|
||||
// Exit the Settings view by clicking the Done button
|
||||
await sidebar.getByRole("button", { name: "Done" }).click()
|
||||
|
||||
// Verify the release banner is visible for new installs and can be closed.
|
||||
const releaseBanner = sidebar.getByRole("heading", {
|
||||
name: /^🎉 New in v\d/,
|
||||
@@ -68,5 +55,4 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
|
||||
await expect(releaseBanner).toBeVisible()
|
||||
await sidebar.getByTestId("close-button").locator("span").first().click()
|
||||
await expect(releaseBanner).not.toBeVisible()
|
||||
await expect(telemetryBanner).not.toBeVisible()
|
||||
})
|
||||
|
||||
+48
-72
@@ -1,89 +1,65 @@
|
||||
import { expect } from "@playwright/test"
|
||||
import { E2E_WORKSPACE_TYPES, e2e } from "./utils/helpers"
|
||||
import { e2e } from "./utils/helpers"
|
||||
|
||||
e2e.describe("Chat - can send messages and switch between modes", () => {
|
||||
E2E_WORKSPACE_TYPES.forEach(({ title, workspaceType }) => {
|
||||
e2e.extend({
|
||||
workspaceType,
|
||||
})(title, async ({ helper, sidebar, page }) => {
|
||||
// Sign in
|
||||
await helper.signin(sidebar)
|
||||
e2e("Chat - can send messages and switch between modes", async ({ helper, sidebar, page }) => {
|
||||
// Sign in
|
||||
await helper.signin(sidebar)
|
||||
|
||||
// Submit a message
|
||||
const inputbox = sidebar.getByTestId("chat-input")
|
||||
await expect(inputbox).toBeVisible()
|
||||
await inputbox.fill("Hello, Cline!")
|
||||
await expect(inputbox).toHaveValue("Hello, Cline!")
|
||||
await sidebar.getByTestId("send-button").click({ delay: 100 })
|
||||
await expect(inputbox).toHaveValue("")
|
||||
// Submit a message
|
||||
const inputbox = sidebar.getByTestId("chat-input")
|
||||
await expect(inputbox).toBeVisible()
|
||||
await inputbox.fill("Hello, Cline!")
|
||||
await expect(inputbox).toHaveValue("Hello, Cline!")
|
||||
await sidebar.getByTestId("send-button").click({ delay: 100 })
|
||||
await expect(inputbox).toHaveValue("")
|
||||
|
||||
// Loading State initially
|
||||
await expect(sidebar.getByText("API Request...")).toBeVisible()
|
||||
// Loading State initially
|
||||
await expect(sidebar.getByText("API Request...")).toBeVisible()
|
||||
|
||||
// The request should eventually fail
|
||||
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
|
||||
// Starting a new task should clear the current chat view and show the recent tasks
|
||||
await sidebar.getByRole("button", { name: "New Task" }).click()
|
||||
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
|
||||
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
|
||||
|
||||
await expect(inputbox).toBeVisible()
|
||||
// Makes sure the act and plan switches are working correctly
|
||||
// Aria-checked state should be true for Act and false for Plan
|
||||
const actButton = sidebar.getByRole("switch", { name: "Act" })
|
||||
const planButton = sidebar.getByRole("switch", { name: "Plan" })
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "Retry" })).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "Start New Task" })).toBeVisible()
|
||||
await expect(actButton).toBeChecked()
|
||||
await expect(planButton).not.toBeChecked()
|
||||
|
||||
// Starting a new task should clear the current chat view and show the recent tasks
|
||||
await sidebar.getByRole("button", { name: "Start New Task" }).click()
|
||||
await expect(sidebar.getByText("API Request Failed")).not.toBeVisible()
|
||||
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
|
||||
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
|
||||
await actButton.click()
|
||||
await expect(actButton).not.toBeChecked()
|
||||
await expect(planButton).toBeChecked()
|
||||
|
||||
// Makes sure the act and plan switches are working correctly
|
||||
// Aria-checked state should be true for Act and false for Plan
|
||||
const actButton = sidebar.getByRole("switch", { name: "Act" })
|
||||
const planButton = sidebar.getByRole("switch", { name: "Plan" })
|
||||
// === slash commands preserve following text ===
|
||||
await expect(inputbox).toHaveValue("")
|
||||
// Type partial slash command to trigger menu
|
||||
await inputbox.pressSequentially("/new", { delay: 100 })
|
||||
|
||||
await expect(actButton).toBeChecked()
|
||||
await expect(planButton).not.toBeChecked()
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("/newtask ")
|
||||
|
||||
await actButton.click()
|
||||
await expect(actButton).not.toBeChecked()
|
||||
await expect(planButton).toBeChecked()
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("/newtask following text should be preserved")
|
||||
|
||||
await inputbox.fill("Plan mode submission")
|
||||
await sidebar.getByTestId("send-button").click()
|
||||
// === @ mentions preserve following text ===
|
||||
await inputbox.fill("")
|
||||
await expect(inputbox).toHaveValue("")
|
||||
|
||||
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
|
||||
// Type partial @ mention to trigger menu
|
||||
await inputbox.pressSequentially("@prob")
|
||||
|
||||
// === slash commands preserve following text ===
|
||||
await inputbox.fill("")
|
||||
await expect(inputbox).toHaveValue("")
|
||||
await inputbox.focus()
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("@problems ")
|
||||
|
||||
// Type partial slash command to trigger menu
|
||||
await inputbox.pressSequentially("/new")
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("@problems following text should be preserved")
|
||||
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("/newtask ")
|
||||
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("/newtask following text should be preserved")
|
||||
|
||||
// === @ mentions preserve following text ===
|
||||
await inputbox.fill("")
|
||||
await expect(inputbox).toHaveValue("")
|
||||
await inputbox.focus()
|
||||
|
||||
// Type partial @ mention to trigger menu
|
||||
await inputbox.pressSequentially("@prob")
|
||||
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("@problems ")
|
||||
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("@problems following text should be preserved")
|
||||
|
||||
await page.close()
|
||||
})
|
||||
})
|
||||
await page.close()
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export const E2E_REGISTERED_MOCK_ENDPOINTS = {
|
||||
"/users/{userId}/usages",
|
||||
"/users/{userId}/payments",
|
||||
],
|
||||
POST: ["/chat/completions"],
|
||||
POST: ["/chat/completions", "/auth/token"],
|
||||
PUT: ["/users/active-account"],
|
||||
},
|
||||
"/.test": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user