Compare commits

..
Author SHA1 Message Date
Arafatkatze 7190d0fe10 example of common data 2025-11-03 23:34:26 -08:00
abeatrix b5d0f6faf9 update AuthServiceMock 2025-11-03 16:34:58 -08:00
abeatrix 7c3909d8e8 feat: add loading screen to onboarding flow
- Add loading state (step 2) during authentication process
- Simplify welcomeViewCompleted logic by removing auth service check
- Move welcomeViewCompleted state update to auth success handler
- Display loading spinner during sign-in to improve UX
- Ensure auth status update occurs in finally block for reliability

The loading screen provides visual feedback while authentication completes, preventing users from seeing incomplete UI states during the sign-in process.
2025-11-03 15:43:48 -08:00
abeatrix 48abfd4536 Fix search box reset state 2025-11-03 13:03:24 -08:00
abeatrix 9c4a18b170 clean up 2025-11-03 12:38:05 -08:00
abeatrix e99e5b990e remove unused description field 2025-11-03 12:37:25 -08:00
abeatrix 9ed7ef54dd Update search box placeholder text 2025-11-03 12:31:33 -08:00
abeatrix 59bcb08466 style(onboarding): adjust max-width constraint placement
Move max-width constraint from parent container to content wrapper div to improve responsive layout behavior. This ensures the full width is utilized at the top level while constraining only the scrollable content area.

Changes:
- Added w-full to root container for proper width handling
- Removed max-w-lg from middle container
- Applied max-w-lg to content wrapper instead
2025-11-03 12:30:11 -08:00
abeatrix 72d8d53d1f update badge 2025-10-31 15:06:16 -07:00
abeatrix 309e9546f1 update e2e test 2025-10-31 15:05:03 -07:00
abeatrix dd3fda8a3b search result info text 2025-10-31 13:28:23 -07:00
abeatrix 51cfd464cd spacing 2025-10-31 13:15:38 -07:00
abeatrix 7ced24fd13 Update langauge 2025-10-31 13:09:34 -07:00
abeatrix 21dae24b0a async onboarding 2025-10-31 13:04:05 -07:00
abeatrix 92eb399c33 clean up 2025-10-31 12:48:37 -07:00
abeatrix b4590f2a21 improve model selection with names and improved search
- Add name field to ModelInfo interface for better model identification
- Populate model name from OpenRouter API response
- Improve model search filtering to exclude embedding models
- Add case-insensitive search for better UX
- Enhance UI with badges for model capabilities and pricing
- Update styling for better visual hierarchy and selected state
- Display model names in search results for clarity

This improves the onboarding experience by making model selection more informative and user-friendly with better search capabilities and visual feedback.
2025-10-31 12:27:29 -07:00
abeatrix ce72bb6b3a Merge branch 'main' into bee/onboarding 2025-10-31 08:54:39 -07:00
CandiedUniverse dfd113a6e5 🪝Hooks: Gorgeous UI [ENG-994] (#7126)
* feat(hooks): Initial implementation of hooks UI using background terminal UI

* Hooks UI improvements

* Hook discovery improvements

* Separate hooks UI into separate files

* Fix failing tests

* Add missing docs

* Hooks hardening and handling edge cases

* Improvements to hooks error messaging

* Improve TaskCancel hook UI

* Simplify and improve console.log() output from hooks

* Changes from usability feedback

* test: verify linting fixes

* fix: resolve cancel functionality in PostToolUse hook using flag pattern

* fix: add cancel button support for TaskResume and UserPromptSubmit hooks

* Getting things working again

* Fixing cancel behaviors

* Trying another fix

* Demoed

* Add examples to .clinerules/hooks/ directory

* Refactored away loadTaskStateWithoutWorkflow

* Fix unsafe return in finally block

Refactored PostToolUse hook execution to avoid using return statement
in finally block, which the linter flags as unsafe. Instead of early
return, wrapped the hook logic in a conditional check for abort status.

This resolves the biome lint error:
lint/correctness/noUnsafeFinally - Unsafe usage of 'return' in finally block

* Remove unnecessary files from branch

* remove cancelHookExecution() in favor of cancelTask()

* Remove unnecessary polling pattern for hook cancellation

* Fixing cancel behavior

* Fixing cancel behavior

* Fix resume

* Sqaush commits to improve merge tool behavior

Plan out the work needed to resolve race conditions

Fix race conditions (1)

Fix race conditions (2)

Fix race conditions (3)

Fix race conditions (4)

Fix race conditions (5)

Fix unit tests

Updated implementation plan doc

Code quality improvement

Temporary logging to troubleshoot race conditions in hooks UI blocks

Fix streaming output for hooks

Remove in-progress planning file

Revert breakage

Remove unneeded arg output in PreToolUse hook UI

Fix race condition with PreToolUse and PostToolUse hooks

Fix PreToolUse attempt_completion use case

Fix ChatTextArea prompt input

Fixed part of the rersume bug

Clean-up before code review

Change cancel button to abort button

Fixed TaskCancel behavior

Fixing resume behavior (partially fixed)

Make notch arrow visible in hooks block up expand/contract toggle

Prevent cancel/abort from clearing unsent user message

Change 'Cancelled' to 'Aborted' in hook UI block

Use color styles consistent with background terminal

Fix npm compile issue

Remove unneeded hot-cold tracking from hooks

Remove unused constant as per PR feedback

Minor changes

Skip combineHookSequences() if hooks feature setting not enabled

Improve mutex pattern for general use and fix additional race condition

Refactor to reduce duplication (keep it DRY)

Refactor to reduce duplication (keep it DRY) part 2

Fix tests

Skip some tests on Windows (not yet supported)

Hooks unsupported on Windows

Fix broken unit tests

* Fix broken unit tests

* Bee and Eve fixing 'API Request...' test in e2e tests

* Add comment as per ellipsis-dev PR feedback

* Changes as per PR feedback from Evan

* Remove if that always resolves to true

* Don't use vscode API; use cross-compatible solutions instead

* Remove mention of obsolete clineAsk handler
2025-10-31 05:50:18 -07:00
github-actions[bot] 3698d2356c v3.35.0 Release Notes (#7127)
- Add native tool calling support with configurable setting.
- Auto-approve is now always-on with a redesigned expanding menu. Settings simplified and notifications moved to General Settings.
- added zai-glm-4.6 as a Cerebras model
- Created GPT5 family specific system prompt template
- Fix: show reasoning budget slider to models with valid thinking config
- Requesty base URL, and API key fixes
- Delete all Auth Tokens when logging out
- Support for <think> tags for models that prefer that over <thinking>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 18:41:23 -07:00
Bee ff20c4addc feat: add native tool call setting with feature flag support (#7193)
* feat: add native tool call setting with feature flag support

Add a new native tool call setting that allows users to enable/disable native tool calling functionality. The setting is controlled by both a user preference and a feature flag, providing flexibility for gradual rollout. Changes include:
- Added native_tool_call_enabled field to UpdateSettingsRequest proto
- Implemented state management for the setting in controller and storage
- Added UI toggle in feature settings section
- Updated task execution logic to check both user setting and feature flag
- Extended extension state interface to include the new setting

* Add changeset
2025-10-30 17:47:35 -07:00
Bee 8eeeabb966 fix: ensure hasThinkingBudget validates non-empty thinkingConfig object (#7192)
Previously, hasThinkingBudget would return true for empty objects ({}),
causing incorrect behavior. Now checks both for truthiness and that the
object contains at least one key.

Also updates thinkingConfig assignment logic to avoid setting empty
objects and converts imports to type-only imports for better tree-shaking.
2025-10-30 16:22:51 -07:00
Saoud Rizwan 9b1dc5bd92 Improve auto-approve menu design and remove unnecessary options (#7180) 2025-10-30 16:10:14 -07:00
CandiedUniverse 1cfff0a45f Hide hooks toggle from appearing in the the Feature Settings (#7191) 2025-10-30 15:44:31 -07:00
Bee e7d00dec2d refactor(ui): improve tooltip and task header responsiveness (#7186)
Refine TaskHeader controls so actions wrap naturally and the cost
label scales down on narrow viewports.
Update tooltip content to wrap flexibly, tweak padding,
add collision padding, and cap width on small screens.
Introduce xs and xxs breakpoint variables to support responsive styles.
2025-10-30 11:41:23 -07:00
Bee 2c5748ccfd fix: set free pricing for featured models in Cline provider (#7179)
* fix: set free pricing for featured models in Cline provider

Add isFree flag to featured models and ensure free models have $0 pricing when using Cline provider. This updates the OpenRouterModelPicker to automatically set input, output, and cache prices to zero for free featured models, preventing confusing info display to users.

* update

* fix
2025-10-30 11:41:14 -07:00
BeeandJose R. Perez 5196adce33 fix: adjust mode toggle hover and colors (#7177)
* fix: adjust mode toggle hover and colors

- add hover state to plan/act toggle buttons with toolbar hover color
- set inactive text color to text-foreground
- align toggle styling with previous design

* feat: toggle spacing fix and transparent color

* SwitchContainer to tailwind

* revert

---------

Co-authored-by: Jose R. Perez <trupix@gmail.com>
2025-10-30 10:20:30 -07:00
celestial-vault d95d86f329 updateApiConfiguration v2 (#7160)
* add new version of updateApiConfiguration that splits options and secrets

* remove console log

* remove unnecessary modelInfo conversion
2025-10-29 22:39:51 -06:00
Bee f6eb3aa386 update: GPT-5 native tools apply_patch to write_to_file (#7178)
- extend replace_in_file and write_to_file specs with a native GPT-5 variant to support the model
- swap native GPT-5 config to use write_to_file and replace_in_file tools instead of apply_patch tool
2025-10-29 19:56:29 -07:00
Bee 5c4b9e54c2 Bee/mcp server key (#7175)
* refactor: use hashed keys for MCP server names in native tool identifiers

- Replace server names with unique hashed keys in MCP tool names to avoid length issues and ensure uniqueness.
- Add McpHub methods to generate and retrieve server names from keys.
- Update McpServer type to include uid field.
- Modify tool-use-handler to resolve server names from keys during tool execution.

* Add server name to tool description

* Fix collision case

* clean up on error
2025-10-29 18:59:29 -07:00
Juan Pablo Flores 29a1d08685 docs: updates Cursor AI Panel drag instructions and updates Gif (#7176)
* docs: clarify Cursor AI Panel drag instructions and fix image path

* docs: replace "AI Panel" with "AI Pane" in Cursor right-sidebar instructions
2025-10-29 18:58:48 -07:00
Tomás Barreiro 9664ddd106 Ensure WorkOS Auth token deletion when logging out (#7174)
* Ensure WorkOS Auth token deletion when logging out

* Add changeset

* refactor
2025-10-30 02:19:33 +01:00
Daniel Steigman 3c1327b115 fix(cli): Fix config set overwriting all settings instead of merging (#6980)
* fix(cli): Fix config set overwriting all settings instead of merging

ENG-1115

This fix resolves the issue where 'cline config set' would overwrite all
settings instead of merging with existing values.

Changes:
1. CLI (cli/pkg/cli/config.go): Changed setCommand to use
   UpdateSettingsPartial() instead of UpdateSettings()

2. Server (src/core/controller/state/updateSettingsCli.ts): Added defensive
   checks for defaultTerminalProfile to prevent undefined errors

3. Added merge.go with proper settings merge logic

The fix follows git-style behavior where 'cline config set key=value'
merges with existing settings, preserving all other values.

Tested and verified:
- Setting max-requests to 999 works
- Setting edit-files-externally preserves max-requests
- Multiple successive config sets preserve all previous values

* fix: use optional proto bools to fix config set field overwrite

Root cause: Non-optional bool fields in AutoApprovalSettings proto were
transmitting zero values (false) even when not set by user, causing the
server-side merge to overwrite existing settings.

Solution: Made 'enabled' and 'enableNotifications' fields optional in proto,
matching the pattern used by AutoApprovalActions fields. This allows proper
server-side merge detection using 'field !== undefined' checks.

Changes:
- proto/cline/state.proto: Added optional keyword to two bool fields
- cli/pkg/cli/task/settings_parser.go: Use boolPtr() for optional fields
- cli/pkg/cli/task/manager.go: Fix UpdateTaskAutoApprovalAction to use boolPtr()
- Removed client-side merge logic (merge.go, UpdateSettingsPartial method)
- Simplified config.go to use UpdateSettings() directly

This approach is simpler and more maintainable than the previous client-side
merge solution, relying on the existing server-side merge logic that already
handles undefined values correctly.

* fix: Address celestial-vault feedback on proto optional fields

- Remove unnecessary error suppression in updateSettingsCli.ts
- Make max_requests optional in AutoApprovalSettings proto
- Update Go parser to use int32Ptr() for optional max_requests
- Remove hardcoded MaxRequests default in UpdateTaskAutoApprovalAction

This prevents the CLI from overwriting user-configured maxRequests values
and maintains consistency with other optional fields (enabled, enableNotifications).

* fix: Remove error suppression for missing terminal manager in updateSettingsCli

Addresses celestial-vault's feedback on ENG-1115 PR.

Previously, the check 'if (controller.task && controller.task.terminalManager)'
silently suppressed errors when a task existed but terminalManager was missing.

Now properly throws an error if task exists without terminalManager (error case),
while allowing terminal profile updates when no task is running (normal case).
2025-10-29 17:40:10 -07:00
Sarah Fortune bb993e4a9a Remove duplicated code for getting the MCP catalog from the server (#7163)
Combine almost identitical methods for getting the MCP catalog.

We are always using silent=true, so remove the param and silent=false code path.
2025-10-29 17:01:15 -07:00
Sarah Fortune 473b3d0204 Update remote config schema to use URL for MCP ID (#7168) 2025-10-29 17:01:03 -07:00
abeatrix d6529a81e8 Add search box 2025-10-29 16:33:54 -07:00
BeeandJose R. Perez 7e68614631 refactor(ui): update BrowserSessionRow URL bar spacing (#7158)
* refactor(ui): update BrowserSessionRow URL bar spacing

Replace inline styles with Tailwind CSS classes for the URL bar component in BrowserSessionRow. This change:
- Removes the `urlTextStyle` CSSProperties object
- Converts inline style objects to Tailwind utility classes using `cn()`
- Maintains the same visual appearance and conditional styling
- Improves code maintainability and consistency with the project's styling approach

* width

---------

Co-authored-by: Jose R. Perez <trupix@gmail.com>
2025-10-29 16:10:34 -07:00
abeatrix 52e621bfa1 Update model list 2025-10-29 16:08:12 -07:00
BeeandJose R. Perez a19e9907d5 fix(ui): hide tooltip arrow for auto approve menu item (#7157)
* fix(ui): hide tooltip arrow for auto approve menu item

Remove the arrow from the tooltip in AutoApproveMenuItem by setting showArrow={false} on TooltipContent. This improves the visual appearance of the tooltip for auto-approve action descriptions.

* clean up

---------

Co-authored-by: Jose R. Perez <trupix@gmail.com>
2025-10-29 16:03:02 -07:00
canvrno 19f74cac01 Add domain to openai compatible telemetry (#7170)
* OpenAI compatible domain telemetry

* Updated tests for openAiCompatibleDomain inclusion in telemtry
2025-10-29 15:51:50 -07:00
BeeandJose R. Perez b877abc708 fix(ui): align toolbar buttons and improve modal visibility control (#7159)
* fix(ui): align toolbar buttons and improve modal visibility control

- Remove inconsistent top margin (mt-0.5, mt-1) from toolbar buttons for consistent vertical alignment
- Add explicit `open` prop to ServersToggleModal Popover for better visibility state control
- Replace inline style with Tailwind class (mb-2.5) for consistent spacing
- Restructure ClineRulesToggleModal layout with proper flex container hierarchy
- Standardize icon sizing and remove redundant flex classes
- Remove trailing whitespace in ServersToggleModal

These changes ensure toolbar buttons align properly and modal visibility states are managed consistently across the chat interface.

* remove space

---------

Co-authored-by: Jose R. Perez <trupix@gmail.com>
2025-10-29 15:12:48 -07:00
Thibault JaiguandJohn Costa c90e64e763 feat: add Requesty OAuth with custom base URL support (#6953)
* feat: add Requesty OAuth with custom base URL support

* fix: changing base url to undefined when user unselected base url checkbox

* chore: adding change set

* chore: minor style changes to requesty provider

---------

Co-authored-by: John Costa <john@requesty.ai>
2025-10-29 14:47:11 -07:00
Bee e02e1eca7f adjust see more styles (#7150) 2025-10-29 14:44:48 -07:00
Bee f8925fb8fd fix: improve CheckpointError layout and Alert component flex behavior (#7148)
* fix: improve CheckpointError layout and Alert component flex behavior

- Add explicit sizing and spacing classes to AlertTriangleIcon in CheckpointError
- Refactor Alert component to use flex layout instead of absolute positioning
- Reduce padding from p-4 to p-2 and adjust gap spacing for tighter layout
- Remove absolute positioning from dismiss button and icon elements
- Update AlertTitle to use grow class for proper flex behavior

This fixes layout issues with the CheckpointError component on flex containers by replacing absolute positioning with flexbox, resulting in more predictable and responsive behavior.

* alert styles

* title
2025-10-29 14:44:39 -07:00
Zhongying Qiao ac81aeaf4e feat: Add banner api calls and evaluate banner display rule sets locally (#7087)
* scaffold banner api calls

* feat: add basic json rules local evaludation

* use another way to fetch ide name and version

* refine rules as well as rules evaluations in BannerService

* refactor: refactor BannerService so it is unit testable

* feat: add tests to banner service

* refactor: use separate initialize and get for banner service

* refactor: use Logger.error to log errors

* refactor: rename personal to personal only

* fix: move import to the top

* fix: remove unnecessary  wrapper

* move banner initialization to common.ts

* fix test

* fix: move import statement to the top in common.ts

* fix: remove feature targeting from rulesets

* fix: remove this._controller check in BannerService

* fix: add getProviderName in AuthService

* fix: include the owner role, treat it same as admin in applying banner rules
2025-10-29 14:03:33 -07:00
Ara f12b5a1573 fix: Removing language selection from voice mode to support aqua voice (#7086)
* fix: Removing language selection from voice mode to support aqua voice

* fix: Removing language selection from voice mode to support aqua voice

* fix: Removing language selection from voice mode to support aqua voice

* fix: bug fixes

* fix: bug fixes
2025-10-29 13:29:22 -07:00
abeatrix e110259167 Debug buttons 2025-10-29 13:21:01 -07:00
abeatrix 8ef3a3b735 feat(auth): new onboarding UI
- Add optional `strict` parameter to `createAuthRequest()` to prevent opening new auth windows when already authenticated
- Update onboarding flow with new UI text and button labels ("Login to Cline", "I have my own key", "Ready")
- Add new onboarding data models and step configuration for improved user experience
- Update all e2e tests to reflect new button labels and authentication flow
- Remove obsolete `closeBanners` utility function
- Refactor authentication logic to support strict mode for better control over auth window behavior

This change improves the authentication UX by preventing duplicate auth windows and provides a more streamlined onboarding experience with clearer call-to-action buttons.
2025-10-29 12:45:40 -07:00
Tomás Barreiro a66b57ef51 Add MCP settings to the remote config (#7123)
* Add MCP settings to the remote config

* Refactor MCP server schema

* Add model list to the root remote config

* Add tests

* Add type
2025-10-29 12:20:29 -07:00
Tomás Barreiro 944ed41f4a Add headers to the OTEL remote settings (#7149) 2025-10-29 16:36:31 +01:00
canvrno 970e941e57 Remove <think> tags for models that prefer this tag over <thinking> (#7144) 2025-10-28 20:24:46 -07:00
Bee 45abe977e4 update: use diff edit tools for gpt-5 (#7147)
GPT-5 with native tool calling support has updated to use the new apply_patch tool. Before we can confirm the new tool works consistently, the old GPT-5 variant without native tool calling enabled should continue to use the current file edit tools.
2025-10-28 18:10:30 -07:00
BeeandSaoud Rizwan 5755b30bce refactor: migrate ModelDescriptionMarkdown to Tailwind CSS (#7145)
Replace styled-components with Tailwind CSS classes in ModelDescriptionMarkdown component. Remove react-remark dependency in favor of simpler rendering approach. Update component to use shadcn/ui Button component instead of VSCodeLink for "See more" action. Add displayName for better debugging. Remove duplicate and unused styled markdown implementation from OpenRouterModelPicker and RequestyModelPicker.

This change improves consistency with the project's UI component library and codebase clean up.

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-10-28 17:51:26 -07:00
Bee 5fde21dffd native tool calling (#6921)
* (WIP)feat(api): add tool calling support to openai API handlers

Add ChatCompletionTool parameter to createMessage interface and implement tool calling functionality across API providers. Includes new StreamingToolCallHandler for processing tool call deltas and utility functions for converting tool specifications to OpenAI format.

* Replace path with absolutePath

* Simplify tool spec

* Parse stream and use variant matcher

* Simplify StreamingToolCallHandler

* Add CLINE_NEXT_GEN

* Enable parallel_tool_calls

* ApplyPatchHandler

* Simplify ApplyPatchHandler

* store tool use chunk

* feat(anthropic): add streaming tool use support with OpenAI-compatible format

Add comprehensive support for streaming tool use blocks from Anthropic API:

- Add tool_use case handler in AnthropicHandler to convert streaming tool
  calls to OpenAI-compatible format during content block start events
- Implement ToolUseHandler class to accumulate and parse streaming tool use
  deltas with incremental JSON parsing support
- Add proper handling for undefined tool message content in openai-format
  converter to prevent errors
- Integrate ToolUseHandler into TaskState for managing streaming tool calls
  across the request lifecycle

This enables real-time processing of tool calls as they stream from Anthropic's
API while maintaining compatibility with OpenAI's tool calling format.

* feat(anthropic): add tool support with OpenAI tool conversion

Add support for passing tools to Anthropic API with automatic conversion from OpenAI tool format. Implements tool_choice configuration to force tool usage when tools are provided.

Changes:
- Add tools parameter to createMessage method in AnthropicHandler
- Implement openAIToolToAnthropic converter to transform OpenAI ChatCompletionTool format to Anthropic Tool format
- Set tool_choice to "any" when tools are provided to ensure Claude uses one of the available tools
- Fix tool result handling to avoid invalid tool_use_id "cline" by converting to text blocks for backward compatibility
- Add documentation for tool_choice options (none, auto, any)

This enables proper tool calling functionality with Anthropic models while maintaining compatibility with the existing OpenAI tool definitions.

* oops

* fix: remove .mjs extensions from import paths

Remove `.mjs` file extensions from OpenAI and Anthropic SDK import paths to ensure compatibility with current module resolution. This updates imports across multiple files:
- openai/resources/chat/completions.mjs → openai/resources/chat/completions
- @anthropic-ai/sdk/resources/index.mjs → @anthropic-ai/sdk/resources/index

* revert styles.css change from merge main

* Update config and fix tests

* gpt-5 variant for cline provider only

* Fix GPT-5 web fetch tool

* Add native next gen for supported openai providers

* add native tool calling support to more openai comp providers

* list supported providers for gpt-5

* clean up

* Update ApplyPatchHandler

* clean up

* Update ask_followup_question

* Add system prompt test for cline claude 4.5 sonnet

* noToolsUsed

* Add support to gemini api provider

* add native tool calling to more providers

* Skip XML parsing

* clean up

* add back TASK_PROGRESS_PARAMETER to native tools

* feature flag NATIVE_TOOL_CALLS_NEXT_GEN_MODELS

* add allowNativeToolCalls to system prompt context

* add grok-code

* feat(context): ensure tool_result blocks follow tool_use blocks

Add validation to ensure every tool_use block in assistant messages has a corresponding tool_result in the following user message. When tool_result blocks are missing, they are automatically added with "result missing" content to maintain proper message pairing required by the Anthropic API.

This prevents API errors caused by orphaned tool_use blocks and ensures conversation history integrity after context management operations.

* feat(task): refactor ApplyPatchHandler to use FileEditProvider with revert support

Refactor the ApplyPatchHandler to use FileEditProvider and DiffViewProvider
for file operations instead of direct filesystem access. This change adds:

- State tracking for applied commits to enable rollback functionality
- Automatic revert of changes when user denies approval or errors occur
- Enhanced response format that includes full file contents after patching
- Consistent file reading through DiffViewProvider for better editor integration
- Proper cleanup of applied state after successful operations

This improves the patch application workflow by ensuring changes can be
safely reverted and provides better visibility into the applied changes.
The refactoring also aligns file operations with the existing editor
integration patterns used elsewhere in the codebase.

* Finalizing...

* MCP tools converter

* remove ClineDefaultTool.MCP_USE

* Update snapshot

* comments

* use diffViewProvider

* Fix MCP tool use with images

* fix mcp tool converter with reserved keys

* Fix apply patch streaming issue

* add vercel-ai-gateway to supported provider & clean up

* simplfy apply patch handler

* Turn off thinking when native tools are enabled for anthropic provider

* clean up ToolUseHandler

* anthropic tool_choice logic

* improve tool result ordering

- Extract anthropic message mapping logic into a separate variable for better readability
- Add MessageParam import from Anthropic SDK
- Update ensureToolResultsFollowToolUse to maintain tool_result order matching tool_use blocks
- Improve documentation to clarify tool result ordering requirements

This refactoring improves code maintainability by separating the message transformation logic and ensures tool results are properly ordered to match their corresponding tool use blocks in the conversation flow.
<budget:token_budget>200000</budget:token_budget>

* refactor tool result validation for improved efficiency

Simplify ensureToolResultsFollowToolUse method by streamlining the logic
for validating and reordering tool_result blocks. Changes include:

- Use early returns to reduce nesting and improve readability
- Process tool results and other blocks in a single pass instead of multiple iterations
- Eliminate unnecessary deep cloning by mutating content array directly
- Add flag-based update detection to avoid unnecessary array rebuilding
- Simplify the reordering logic while maintaining the same validation rules

This refactoring maintains the same functional behavior (ensuring tool_result
blocks follow their corresponding tool_use blocks) while reducing complexity
and improving performance through more efficient array operations.
<budget:token_budget>200000</budget:token_budget>

* attempt_completion

* use helper function

* remove FreeModelIDs from merge conflicts

* clean up

* Add NATIVE_GPT_5 & exclude gpt-5-chat

* shouldAutoApproveToolWithPath for apply patch

* add openai-native to supported provider

* add lightweight incremental chunk extraction

* update apply patch handler

* revert version regex

* Fix browser action
2025-10-28 17:49:19 -07:00
canvrno cf9f2a8630 Package updates (#7001) 2025-10-28 17:45:19 -07:00
nihar-oracleandAndrei Eternal 5a3416ff09 Feat/nturumel/proto-python (#7090)
* feat(proto-python): add script-generated Python gRPC stubs, Go-like client, docs, and PyPI publish workflow

- add scripts/build-python-proto.mjs

  - invokes python -m grpc_tools.protoc over proto/**/*.proto
  - outputs to src/generated/grpc-python
  - mirrors Go layout under client/: connection.py, cline_client.py, services/_client.py
  - supports PYTHON env override (use a venv interpreter easily)
  - generates src/generated/grpc-python/pyproject.toml so output can be pip installed (pip install -e src/generated/grpc-python)

- package.json

  - add protos-python script to run the generator

- docs

  - add docs/exploring-clines-tools/python-protos.mdx with venv setup, generation steps, and import examples
  - emphasize: everything in src/generated is produced by scripts (do not commit manual edits)

- CI: publish to PyPI only

  - add .github/workflows/publish-grpc-python.yml
  - workflow generates code via script, builds wheel/sdist from src/generated/grpc-python, and uploads to PyPI
  - requires repo secret: PYPI_API_TOKEN (TWINE_USERNAME=__token__)
  - optional version override input for workflow_dispatch

Notes:

- generation strictly produces all content under src/generated/grpc-python (including pyproject.toml)
- default package name in generated pyproject is cline-grpc-python (adjustable in the script if needed)
- recommended usage on macOS: PYTHON=/Users/nturumel/projects/oracle-github/cline/.venv-proto/bin/python npm run protos-python

* Delete .github/workflows/publish-grpc-python.yml

* Delete docs/exploring-clines-tools/python-protos.mdx

* Update tired-banks-show.md

---------

Co-authored-by: Andrei Eternal <206184+Garoth@users.noreply.github.com>
2025-10-28 17:43:05 -07:00
Bee 826b2b1276 refactor: replace HeroUI Alert with shadcn Alert component (#7137)
* refactor: replace HeroUI Alert with shadcn Alert component

Replace HeroUI Alert component with a new custom Alert component in CheckpointError. The new component provides better control over styling and behavior, removes the dismiss functionality from CheckpointError, and uses a cleaner structure with AlertTitle and AlertDescription. This change improves consistency with the UI design system and simplifies the error display logic.

* Add storybook for TaskHeader with CheckpointError

* clean up
2025-10-28 17:29:51 -07:00
293 changed files with 14516 additions and 3218 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
added zai-glm-4.6 as a Cerebras model
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Created GPT5 family specific system prompt template
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Added support for reinitializing the telemetry service and all providers inside of it.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Fixed a bug and set up how environmental variables are preceded and overwritten by the remote configuration settings.
+7 -46
View File
@@ -1,54 +1,15 @@
#!/usr/bin/env bash
# PostToolUse Hook Example
#
# This hook runs AFTER a tool is executed. It can:
# 1. Observe tool results and outcomes
# 2. Add context for FUTURE tool uses via contextModification
# 3. Log or track tool usage patterns
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool has already completed when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
success=$(echo "$input" | jq -r '.postToolUse.success // false')
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
sleep 3
# Example 1: Learning from file operations
# Track successful file creations to build context about project structure
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
# }
# EOF
# exit 0
# fi
# Example 2: Performance monitoring
# Warn about slow operations
# if [[ "$execution_time" -gt 5000 ]]; then
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
# }
# EOF
# exit 0
# fi
# Example 3: Context injection for future tool uses
# The context will be available in the NEXT API request
cat <<EOF
{
"shouldContinue": true,
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "PostToolUse hook custom errorMessage: foo"
}
EOF
+7 -34
View File
@@ -1,42 +1,15 @@
#!/usr/bin/env bash
# PreToolUse Hook Example
#
# This hook runs BEFORE a tool is executed. It can:
# 1. Block execution by returning {"shouldContinue": false}
# 2. Add context for FUTURE tool uses via contextModification
# 3. Validate tool parameters
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool parameters are already determined when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
sleep 3
# Example 1: Validation - Block invalid operations
# Uncomment to prevent creating .js files in a TypeScript project
# if [[ "$tool_name" == "write_to_file" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# if [[ "$path" == *.js ]]; then
# cat <<EOF
# {
# "shouldContinue": false,
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
# }
# EOF
# exit 0
# fi
# fi
# Example 2: Context injection for future tool uses
# The context will be available in the NEXT API request after this tool completes
cat <<EOF
{
"shouldContinue": true,
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
"cancel": false,
"contextModification": "Make sure that all variables in python code in this repo always start with the string 'EVIEDEBUG_'. This is important.",
"errorMessage": "PreToolUse hook custom errorMessage: foo"
}
EOF
+109 -28
View File
@@ -17,6 +17,37 @@ Hooks run automatically when enabled.
## Available Hooks
### TaskStart Hook
- **When**: Runs when a NEW task is started (not when resuming)
- **Purpose**: Initialize task context, validate task requirements, set up environment
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskStart` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
### TaskResume Hook
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskResume` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
### TaskCancel Hook
- **When**: Runs when a task is cancelled by the user (only if there's actual active work or work was started)
- **Purpose**: Clean up resources, log cancellation, save state
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskCancel` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskCancel` (all platforms)
- **Note**: This hook is NOT cancellable and will complete even if the task is being aborted
### TaskComplete Hook
- **When**: Runs when a task is marked as complete
- **Purpose**: Log completion status, perform final cleanup, generate reports
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskComplete` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskComplete` (all platforms)
### UserPromptSubmit Hook
- **When**: Runs when the user submits a prompt/message (initial task, resume, or feedback)
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
- **Global Location**: `~/Documents/Cline/Rules/Hooks/UserPromptSubmit` (all platforms)
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit` (all platforms)
### PreToolUse Hook
- **When**: Runs BEFORE a tool is executed
- **Purpose**: Validate parameters, block execution, or add context
@@ -29,6 +60,12 @@ Hooks run automatically when enabled.
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PostToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PostToolUse` (all platforms)
### PreCompact Hook
- **When**: Runs BEFORE the conversation context is compacted/truncated
- **Purpose**: Observe compaction events, log context management, track token usage
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PreCompact` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreCompact` (all platforms)
## Cross-Platform Hook Format
Cline uses a git-style approach for hooks that works consistently across all platforms:
@@ -107,11 +144,46 @@ All hooks receive:
```json
{
"clineVersion": "string",
"hookName": "PreToolUse" | "PostToolUse",
"hookName": "TaskStart" | "TaskResume" | "TaskCancel" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PreCompact",
"timestamp": "string",
"taskId": "string",
"workspaceRoots": ["string"],
"userId": "string",
"taskStart": { // Only for TaskStart
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"initialTask": "string"
}
},
"taskResume": { // Only for TaskResume
"taskMetadata": {
"taskId": "string",
"ulid": "string"
},
"previousState": {
"lastMessageTs": "string",
"messageCount": "string",
"conversationHistoryDeleted": "string"
}
},
"taskCancel": { // Only for TaskCancel
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"completionStatus": "string"
}
},
"taskComplete": { // Only for TaskComplete
"taskMetadata": {
"taskId": "string",
"ulid": "string"
}
},
"userPromptSubmit": { // Only for UserPromptSubmit
"prompt": "string",
"attachments": ["string"]
},
"preToolUse": { // Only for PreToolUse
"toolName": "string",
"parameters": {}
@@ -122,6 +194,11 @@ All hooks receive:
"result": "string",
"success": boolean,
"executionTimeMs": number
},
"preCompact": { // Only for PreCompact
"contextSize": number,
"messagesToCompact": number,
"compactionStrategy": "string"
}
}
```
@@ -131,12 +208,16 @@ All hooks receive:
All hooks must return:
```json
{
"shouldContinue": boolean, // Required: Allow or block execution
"contextModification": "string", // Optional: Context for future tool uses
"cancel": boolean, // Required: false to continue, true to block execution
"contextModification": "string", // Optional: Context for future AI decisions
"errorMessage": "string" // Optional: Error details if blocking
}
```
**Note**: The `cancel` field works as follows:
- `false` (or omitted): Allow execution to continue
- `true`: Block execution and show error message to user
## Context Modification Format
Use structured prefixes to help the AI understand context type:
@@ -152,7 +233,7 @@ Example:
```bash
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is a TypeScript project. All new files must use .ts or .tsx extensions."
}
EOF
@@ -160,9 +241,9 @@ EOF
## Hook Execution Limits
- **Timeout**: Hooks must complete within 30 seconds
- **Context Size**: Context modifications are limited to 50KB
- **Error Handling**: Unexpected file system errors are propagated; expected errors (file not found, permission denied) are handled silently
- **Timeout**: Hooks must complete within 30 seconds (configurable via `HOOK_EXECUTION_TIMEOUT_MS`)
- **Context Size**: Context modifications are limited to 50KB (configurable via `MAX_CONTEXT_MODIFICATION_SIZE`)
- **Error Handling**: Expected errors (file not found, permission denied, not a directory) are handled silently; unexpected file system errors are propagated
## Common Use Cases
@@ -177,7 +258,7 @@ path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
cat <<EOF
{
"shouldContinue": false,
"cancel": true,
"errorMessage": "Cannot create .js files in TypeScript project",
"contextModification": "WORKSPACE_RULES: Use .ts/.tsx extensions only"
}
@@ -185,7 +266,7 @@ EOF
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
### 2. Context Building - Learn from Operations
@@ -200,12 +281,12 @@ path=$(echo "$input" | jq -r '.postToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "FILE_OPERATIONS: Created '$path'. Maintain consistency with this file's patterns in future operations."
}
EOF
else
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
fi
```
@@ -220,12 +301,12 @@ tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
if [[ "$execution_time" -gt 5000 ]]; then
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
}
EOF
else
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
fi
```
@@ -239,7 +320,7 @@ input=$(cat)
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
# Allow execution
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
## Global vs Workspace Hooks
@@ -250,26 +331,26 @@ Cline supports two levels of hooks:
- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\Hooks\` (Windows)
- **Scope**: Apply to ALL workspaces and projects
- **Use Case**: Organization-wide policies, personal preferences, universal validations
- **Priority**: Execute FIRST, before workspace hooks
- **Priority**: Order not guaranteed when combined with workspace hooks
### Workspace Hooks
- **Location**: `.clinerules/hooks/` in each workspace root
- **Scope**: Apply only to the specific workspace
- **Use Case**: Project-specific rules, team conventions, repository requirements
- **Priority**: Execute AFTER global hooks
- **Priority**: Order not guaranteed when combined with global hooks
### Hook Execution
When multiple hooks exist (global and/or workspace):
- All hooks for a given step (PreToolUse or PostToolUse) are executed
- **Execution order is not guaranteed** - hooks may run concurrently
- If ALL hooks allow execution (`shouldContinue: true`), the tool proceeds
- If ANY hook blocks (`shouldContinue: false`), execution is blocked
- All hooks for a given step are executed **concurrently** using `Promise.all`
- **Execution order is not guaranteed** - hooks run in parallel
- If ALL hooks allow execution (`cancel: false`), the tool proceeds
- If ANY hook blocks (`cancel: true`), execution is blocked
**Result Combination:**
- `shouldContinue`: Must be `true` from ALL hooks for execution to proceed
- `contextModification`: All context strings are concatenated
- `errorMessage`: All error messages are concatenated
- `cancel`: If ANY hook returns `true`, execution is blocked
- `contextModification`: All context strings are concatenated with double newlines (`\n\n`)
- `errorMessage`: All error messages are concatenated with single newlines (`\n`)
### Setting Up Global Hooks
@@ -301,11 +382,11 @@ tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *"package.json"* ]]; then
echo '{"shouldContinue": false, "errorMessage": "Global policy: Cannot modify package.json"}'
echo '{"cancel": true, "errorMessage": "Global policy: Cannot modify package.json"}'
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
**Workspace Hook** (applies to specific project):
@@ -318,11 +399,11 @@ tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
echo '{"shouldContinue": false, "errorMessage": "Project rule: Use .ts files only"}'
echo '{"cancel": true, "errorMessage": "Project rule: Use .ts files only"}'
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
**All hooks must allow execution for the tool to proceed.** Hooks may execute concurrently.
@@ -331,7 +412,7 @@ echo '{"shouldContinue": true}'
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. Their results will be combined:
- **shouldContinue**: If ANY hook returns false, execution is blocked
- **cancel**: If ANY hook returns `true`, execution is blocked
- **contextModification**: All context modifications are concatenated
- **errorMessage**: All error messages are concatenated
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel", false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskCancel hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskResume hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 1
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskStart hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "UserPromptSubmit hook custom errorMessage: foo"
}
EOF
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 3.35.0
- Add native tool calling support with configurable setting.
- Auto-approve is now always-on with a redesigned expanding menu. Settings simplified and notifications moved to General Settings.
- added zai-glm-4.6 as a Cerebras model
- Created GPT5 family specific system prompt template
- Fix: show reasoning budget slider to models with valid thinking config
- Requesty base URL, and API key fixes
- Delete all Auth Tokens when logging out
- Support for <think> tags for models that prefer that over <thinking>
## [3.34.1]
- Added support for MiniMax provider with MiniMax-M2 model
+5 -2
View File
@@ -123,7 +123,10 @@ func setCommand() *cobra.Command {
Use: "set <key=value> [key=value...]",
Aliases: []string{"s"},
Short: "Set configuration variables",
Long: `Set one or more global configuration variables using key=value format.`,
Long: `Set one or more global configuration variables using key=value format.
This command merges the provided settings with existing values, preserving
unspecified fields. Only the fields you explicitly set will be updated.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
@@ -139,7 +142,7 @@ func setCommand() *cobra.Command {
return err
}
// Update settings
// Update settings (server-side merge handles preserving existing values)
return configManager.UpdateSettings(ctx, settings, secrets)
},
}
+1 -1
View File
@@ -189,7 +189,7 @@ func renderAutoApprovalSettings(value interface{}, censor bool) error {
}
}
} else {
// Print other fields normally (enabled, maxRequests, enableNotifications, favorites)
// Print other fields normally (enabled, enableNotifications, favorites)
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
}
}
-21
View File
@@ -52,8 +52,6 @@ func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
return h.handleResumeCompletedTask(msg, dc)
case string(types.AskTypeMistakeLimitReached):
return h.handleMistakeLimitReached(msg, dc)
case string(types.AskTypeAutoApprovalMaxReached):
return h.handleAutoApprovalMaxReached(msg, dc)
case string(types.AskTypeBrowserActionLaunch):
return h.handleBrowserActionLaunch(msg, dc)
case string(types.AskTypeUseMcpServer):
@@ -255,25 +253,6 @@ func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *Disp
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
}
// handleAutoApprovalMaxReached handles auto-approval max reached
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error {
if dc.SystemRenderer != nil {
details := make(map[string]string)
if msg.Text != "" {
details["reason"] = msg.Text
}
dc.SystemRenderer.RenderError(
"warning",
"Auto-Approval Limit Reached",
"The maximum number of auto-approved requests has been reached. Manual approval is now required.",
details,
)
fmt.Printf("\n**Approval required to continue.**\n")
return nil
}
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
}
// handleBrowserActionLaunch handles browser action launch requests
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
url := strings.TrimSpace(msg.Text)
+4 -5
View File
@@ -282,7 +282,6 @@ func (m *Manager) CheckSendEnabled(ctx context.Context) error {
errorTypes := []string{
string(types.AskTypeAPIReqFailed), // "api_req_failed"
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
string(types.AskTypeAutoApprovalMaxReached), // "auto_approval_max_req_reached"
}
isError := false
@@ -1239,16 +1238,16 @@ func (m *Manager) updateMode(stateJson string) {
// UpdateTaskAutoApprovalAction enables a specific auto-approval action for the current task
func (m *Manager) UpdateTaskAutoApprovalAction(ctx context.Context, actionKey string) error {
boolPtr := func(b bool) *bool { return &b }
settings := &cline.Settings{
AutoApprovalSettings: &cline.AutoApprovalSettings{
Enabled: true,
MaxRequests: 20, // Important: avoid maxRequests=0 bug
Actions: &cline.AutoApprovalActions{},
Actions: &cline.AutoApprovalActions{},
},
}
// Set the specific action to true based on actionKey
truePtr := func() *bool { b := true; return &b }()
truePtr := boolPtr(true)
switch actionKey {
case "read_files":
+1 -13
View File
@@ -416,24 +416,12 @@ func setNestedField(settings *cline.Settings, parentField string, childFields ma
func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error {
for key, value := range fields {
switch key {
case "enabled":
val, err := parseBool(value)
if err != nil {
return err
}
settings.Enabled = val
case "max_requests":
val, err := parseInt32(value)
if err != nil {
return err
}
settings.MaxRequests = val
case "enable_notifications":
val, err := parseBool(value)
if err != nil {
return err
}
settings.EnableNotifications = val
settings.EnableNotifications = boolPtr(val)
case "actions":
return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)")
default:
+10 -13
View File
@@ -37,17 +37,16 @@ const (
type AskType string
const (
AskTypeFollowup AskType = "followup"
AskTypePlanModeRespond AskType = "plan_mode_respond"
AskTypeCommand AskType = "command"
AskTypeCommandOutput AskType = "command_output"
AskTypeCompletionResult AskType = "completion_result"
AskTypeTool AskType = "tool"
AskTypeAPIReqFailed AskType = "api_req_failed"
AskTypeResumeTask AskType = "resume_task"
AskTypeResumeCompletedTask AskType = "resume_completed_task"
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
AskTypeFollowup AskType = "followup"
AskTypePlanModeRespond AskType = "plan_mode_respond"
AskTypeCommand AskType = "command"
AskTypeCommandOutput AskType = "command_output"
AskTypeCompletionResult AskType = "completion_result"
AskTypeTool AskType = "tool"
AskTypeAPIReqFailed AskType = "api_req_failed"
AskTypeResumeTask AskType = "resume_task"
AskTypeResumeCompletedTask AskType = "resume_completed_task"
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
AskTypeUseMcpServer AskType = "use_mcp_server"
AskTypeNewTask AskType = "new_task"
@@ -247,8 +246,6 @@ func convertProtoAskType(askType cline.ClineAsk) string {
return string(AskTypeResumeCompletedTask)
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
return string(AskTypeMistakeLimitReached)
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
return string(AskTypeAutoApprovalMaxReached)
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
return string(AskTypeBrowserActionLaunch)
case cline.ClineAsk_USE_MCP_SERVER:
@@ -42,17 +42,17 @@ To open Cline in the right sidebar:
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 title="Open the AI Pane">
Click the Cursor cube icon button (AI Pane) 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 title="Drag Cline to the AI Pane Sidebar">
Drag the Cline icon directly into the AI Pane sidebar.
</Step>
</Steps>
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/cursor-side-bar.gif"
src="https://storage.googleapis.com/cline_public_images/Cursor-sidebar.gif"
alt="Cursor Right Sidebar Setup"
/>
</Frame>
+272 -59
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"axios": "^1.12.0",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^12.4.1",
"chalk": "5.6.2",
"cline": "^1.0.1",
"commander": "^9.4.1",
@@ -211,6 +211,111 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
"integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz",
"integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.11.tgz",
"integrity": "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz",
"integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz",
"integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"streamx": "^2.21.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.1.tgz",
"integrity": "sha512-v2yl0TnaZTdEnelkKtXZGnotiV6qATBlnNuUMrHl6v9Lmmrh9mw9RYyImPU7/4RahumSwQS1k2oKXcRfXcbjJw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -231,13 +336,17 @@
]
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"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/bindings": {
@@ -305,11 +414,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -1450,6 +1554,15 @@
"node": ">=6"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -1480,6 +1593,12 @@
"node": ">=6"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"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",
@@ -1520,11 +1639,6 @@
"node": ">= 6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2131,6 +2245,17 @@
"resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.2.1.tgz",
"integrity": "sha512-Tll0Ndvnwkuv5Hn6WIbh26rZiYQORuH1t5m/or9LUpSmDmmyFG89G9fKrSeugMPxwmEIXoVxqTun4LbizTs4uw=="
},
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -2192,29 +2317,37 @@
}
},
"node_modules/tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
"integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
"b4a": "^1.6.4",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/text-decoder": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
"integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tiktoken": {
@@ -2577,15 +2710,73 @@
"proxy-from-env": "^1.1.0"
}
},
"b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
"integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
"requires": {}
},
"bare-events": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz",
"integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==",
"requires": {}
},
"bare-fs": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.11.tgz",
"integrity": "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==",
"optional": true,
"requires": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
}
},
"bare-os": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz",
"integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==",
"optional": true
},
"bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"optional": true,
"requires": {
"bare-os": "^3.0.1"
}
},
"bare-stream": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz",
"integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==",
"optional": true,
"requires": {
"streamx": "^2.21.0"
}
},
"bare-url": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.1.tgz",
"integrity": "sha512-v2yl0TnaZTdEnelkKtXZGnotiV6qATBlnNuUMrHl6v9Lmmrh9mw9RYyImPU7/4RahumSwQS1k2oKXcRfXcbjJw==",
"optional": true,
"requires": {
"bare-path": "^3.0.0"
}
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"requires": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -2632,11 +2823,6 @@
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
},
"chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -3328,6 +3514,14 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
},
"events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"requires": {
"bare-events": "^2.7.0"
}
},
"execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -3349,6 +3543,11 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="
},
"fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="
},
"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",
@@ -3371,11 +3570,6 @@
"mime-types": "^2.1.12"
}
},
"fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3659,7 +3853,7 @@
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tar-fs": "^3.1.1",
"tunnel-agent": "^0.6.0"
}
},
@@ -3760,6 +3954,16 @@
"resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.2.1.tgz",
"integrity": "sha512-Tll0Ndvnwkuv5Hn6WIbh26rZiYQORuH1t5m/or9LUpSmDmmyFG89G9fKrSeugMPxwmEIXoVxqTun4LbizTs4uw=="
},
"streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
"requires": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -3805,26 +4009,32 @@
}
},
"tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
"integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"requires": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
"tar-stream": "^3.1.5"
}
},
"tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"requires": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
"b4a": "^1.6.4",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"text-decoder": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
"integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
"requires": {
"b4a": "^1.6.4"
}
},
"tiktoken": {
@@ -3969,5 +4179,8 @@
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true
}
},
"overrides": {
"tar-fs": ">=2.1.4"
}
}
+45 -43
View File
@@ -1,45 +1,47 @@
{
"name": "cline-evals",
"version": "0.1.0",
"description": "Evaluation scripts and tools for Cline",
"main": "cli/dist/index.js",
"scripts": {
"build:cli": "cd cli && tsc",
"start:cli": "cd cli && node dist/index.js",
"dev:cli": "cd cli && ts-node src/index.ts",
"diff-eval": "./diff-edits/run_and_open_dashboard.sh",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cline",
"evaluation",
"benchmark",
"diff-edits"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.12.0",
"better-sqlite3": "^11.10.0",
"chalk": "5.6.2",
"dotenv": "^16.5.0",
"commander": "^9.4.1",
"execa": "^5.1.1",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"sqlite": "^4.1.2",
"tiktoken": "^1.0.21",
"uuid": "^9.0.0",
"yargs": "^17.6.2",
"cline": "^1.0.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.12",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.19",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
}
"name": "cline-evals",
"version": "0.1.0",
"description": "Evaluation scripts and tools for Cline",
"main": "cli/dist/index.js",
"scripts": {
"build:cli": "cd cli && tsc",
"start:cli": "cd cli && node dist/index.js",
"dev:cli": "cd cli && ts-node src/index.ts",
"diff-eval": "./diff-edits/run_and_open_dashboard.sh",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cline",
"evaluation",
"benchmark",
"diff-edits"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.12.0",
"better-sqlite3": "^12.4.1",
"chalk": "5.6.2",
"dotenv": "^16.5.0",
"commander": "^9.4.1",
"execa": "^5.1.1",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"sqlite": "^4.1.2",
"tiktoken": "^1.0.21",
"uuid": "^9.0.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.12",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.19",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
},
"overrides": {
"tar-fs": "^3.1.1"
}
}
+30 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.34.1",
"version": "3.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.34.1",
"version": "3.35.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
@@ -80,6 +80,7 @@
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
@@ -15190,6 +15191,33 @@
"node": ">=6"
}
},
"node_modules/p-mutex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-mutex/-/p-mutex-1.0.0.tgz",
"integrity": "sha512-UlthGzEMsg2VnZAR58wkzL7muskxtNamoTR1Q6/VYBUKqPaMM+YtSncjWIvyjfUvVECKck1SYC/4XIWWJU3gBw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-mutex/node_modules/yocto-queue": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz",
"integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "6.1.4",
"license": "MIT",
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.34.1",
"version": "3.35.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -310,6 +310,7 @@
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
"protos": "node scripts/build-proto.mjs",
"protos-go": "node scripts/build-go-proto.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"cli-providers": "node scripts/cli-providers.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
@@ -329,7 +330,7 @@
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
"test": "npm-run-all test:unit test:integration",
"test:integration": "vscode-test",
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha # Use `npm run test:unit --update-snapshots` to rebuild prompt snapshots",
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
"test:coverage": "vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
@@ -473,6 +474,7 @@
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
+2
View File
@@ -40,6 +40,8 @@ service AccountService {
rpc openrouterAuthClicked(EmptyRequest) returns (Empty);
rpc requestyAuthClicked(StringRequest) returns (Empty);
// Returns a link the webview can use to redirect back to the user's IDE.
rpc getRedirectUrl(EmptyRequest) returns (String);
}
+1 -1
View File
@@ -29,7 +29,7 @@ message HookInput {
// Output message for all hooks
message HookOutput {
string context_modification = 1;
bool should_continue = 2;
bool cancel = 2;
string error_message = 3;
}
+170 -2
View File
@@ -29,8 +29,10 @@ service ModelsService {
rpc refreshRequestyModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Subscribe to OpenRouter models updates
rpc subscribeToOpenRouterModels(EmptyRequest) returns (stream OpenRouterCompatibleModelInfo);
// Updates API configuration
// Updates API configuration (legacy - uses combined configuration)
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
// Updates API configuration (new - uses separate options and secrets)
rpc updateApiConfiguration(UpdateApiConfigurationRequestNew) returns (Empty);
// Updates API configuration with partial values (only updates fields that are explicitly set)
rpc updateApiConfigurationPartial(UpdateApiConfigurationPartialRequest) returns (Empty);
// Refreshes and returns Groq models
@@ -92,6 +94,7 @@ message OpenRouterModelInfo {
optional ThinkingConfig thinking_config = 10;
optional bool supports_global_endpoint = 11;
repeated ModelTier tiers = 12;
optional string name = 13;
}
// Shared response message for model information
@@ -128,12 +131,177 @@ message SapAiCoreModelsResponse {
bool orchestration_available = 2;
}
// Request for updating API configuration
// API secrets (credentials, API keys)
message ModelsApiSecrets {
optional string api_key = 1;
optional string cline_api_key = 2;
optional string lite_llm_api_key = 3;
optional string open_router_api_key = 4;
optional string aws_access_key = 5;
optional string aws_secret_key = 6;
optional string aws_session_token = 7;
optional string aws_bedrock_api_key = 8;
optional string open_ai_api_key = 9;
optional string ollama_api_key = 10;
optional string gemini_api_key = 11;
optional string open_ai_native_api_key = 12;
optional string deep_seek_api_key = 13;
optional string requesty_api_key = 14;
optional string together_api_key = 15;
optional string fireworks_api_key = 16;
optional string qwen_api_key = 17;
optional string doubao_api_key = 18;
optional string mistral_api_key = 19;
optional string nebius_api_key = 20;
optional string asksage_api_key = 21;
optional string xai_api_key = 22;
optional string sambanova_api_key = 23;
optional string cerebras_api_key = 24;
optional string sap_ai_core_client_id = 25;
optional string sap_ai_core_client_secret = 26;
optional string moonshot_api_key = 27;
optional string cline_account_id = 28;
optional string groq_api_key = 29;
optional string hugging_face_api_key = 30;
optional string huawei_cloud_maas_api_key = 31;
optional string baseten_api_key = 32;
optional string zai_api_key = 33;
optional string vercel_ai_gateway_api_key = 34;
optional string dify_api_key = 35;
optional string oca_api_key = 36;
optional string oca_refresh_token = 37;
optional string minimax_api_key = 38;
}
// API configuration options (non-secret settings)
message ModelsApiOptions {
// Global configuration fields (not mode-specific)
optional string ulid = 1;
optional string lite_llm_base_url = 2;
optional bool lite_llm_use_prompt_cache = 3;
map<string, string> open_ai_headers = 4;
optional string anthropic_base_url = 5;
optional string open_router_provider_sorting = 6;
optional string aws_region = 7;
optional bool aws_use_cross_region_inference = 8;
optional bool aws_bedrock_use_prompt_cache = 9;
optional bool aws_use_profile = 10;
optional string aws_profile = 11;
optional string aws_bedrock_endpoint = 12;
optional string claude_code_path = 13;
optional string vertex_project_id = 14;
optional string vertex_region = 15;
optional string open_ai_base_url = 16;
optional string ollama_base_url = 17;
optional string ollama_api_options_ctx_num = 18;
optional string lm_studio_base_url = 19;
optional string gemini_base_url = 20;
optional string requesty_base_url = 21;
optional int64 fireworks_model_max_completion_tokens = 22;
optional int64 fireworks_model_max_tokens = 23;
optional string azure_api_version = 24;
optional string qwen_api_line = 25;
optional string asksage_api_url = 26;
optional int64 request_timeout_ms = 27;
optional string sap_ai_resource_group = 28;
optional string sap_ai_core_token_url = 29;
optional string sap_ai_core_base_url = 30;
optional bool sap_ai_core_use_orchestration_mode = 31;
optional string moonshot_api_line = 32;
optional string aws_authentication = 33;
optional string zai_api_line = 34;
optional string lm_studio_max_tokens = 35;
optional string qwen_code_oauth_path = 36;
optional string dify_base_url = 37;
optional string oca_base_url = 38;
optional string oca_mode = 39;
optional bool aws_use_global_inference = 40;
optional string minimax_api_line = 41;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
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_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 LiteLLMModelInfo plan_mode_lite_llm_model_info = 114;
optional string plan_mode_requesty_model_id = 115;
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_sap_ai_core_deployment_id = 120;
optional string plan_mode_groq_model_id = 121;
optional OpenRouterModelInfo plan_mode_groq_model_info = 122;
optional string plan_mode_hugging_face_model_id = 123;
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 124;
optional string plan_mode_huawei_cloud_maas_model_id = 125;
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 126;
optional string plan_mode_baseten_model_id = 127;
optional OpenRouterModelInfo plan_mode_baseten_model_info = 128;
optional string plan_mode_vercel_ai_gateway_model_id = 129;
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 130;
optional string plan_mode_oca_model_id = 131;
optional OcaModelInfo plan_mode_oca_model_info = 132;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
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_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 LiteLLMModelInfo act_mode_lite_llm_model_info = 214;
optional string act_mode_requesty_model_id = 215;
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_sap_ai_core_deployment_id = 220;
optional string act_mode_groq_model_id = 221;
optional OpenRouterModelInfo act_mode_groq_model_info = 222;
optional string act_mode_hugging_face_model_id = 223;
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 224;
optional string act_mode_huawei_cloud_maas_model_id = 225;
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 226;
optional string act_mode_baseten_model_id = 227;
optional OpenRouterModelInfo act_mode_baseten_model_info = 228;
optional string act_mode_vercel_ai_gateway_model_id = 229;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 230;
optional string act_mode_oca_model_id = 231;
optional OcaModelInfo act_mode_oca_model_info = 232;
}
// Request for updating API configuration (legacy - uses combined configuration)
message UpdateApiConfigurationRequest {
Metadata metadata = 1;
ModelsApiConfiguration api_configuration = 2;
}
// Request for updating API configuration (new - uses separate options and secrets)
message UpdateApiConfigurationRequestNew {
Metadata metadata = 1;
ModelsApiOptions options = 2;
ModelsApiSecrets secrets = 3;
}
// Request for partially updating API configuration using FieldMask
// Only fields specified in update_mask will be updated from api_configuration
message UpdateApiConfigurationPartialRequest {
+5 -10
View File
@@ -46,11 +46,8 @@ message AutoApprovalActions {
// Auto approval settings for task execution
message AutoApprovalSettings {
int32 version = 1;
bool enabled = 2;
AutoApprovalActions actions = 3;
int32 max_requests = 4;
bool enable_notifications = 5;
repeated string favorites = 6;
AutoApprovalActions actions = 2;
optional bool enable_notifications = 3;
}
message Secrets {
@@ -283,11 +280,8 @@ message ResetStateRequest {
message AutoApprovalSettingsRequest {
Metadata metadata = 1;
int32 version = 2;
bool enabled = 3;
AutoApprovalActions actions = 4;
int32 max_requests = 5;
bool enable_notifications = 6;
repeated string favorites = 7;
AutoApprovalActions actions = 3;
bool enable_notifications = 4;
}
enum TelemetrySettingEnum {
@@ -356,6 +350,7 @@ message UpdateSettingsRequest {
optional bool subagents_enabled = 29;
optional int32 subagent_terminal_output_line_limit = 30;
optional string cline_env = 31;
optional bool native_tool_call_enabled = 32;
}
message UpdateTerminalConnectionTimeoutRequest {
+6 -7
View File
@@ -26,13 +26,12 @@ enum ClineAsk {
RESUME_TASK = 7;
RESUME_COMPLETED_TASK = 8;
MISTAKE_LIMIT_REACHED = 9;
AUTO_APPROVAL_MAX_REQ_REACHED = 10;
BROWSER_ACTION_LAUNCH = 11;
USE_MCP_SERVER = 12;
NEW_TASK = 13;
CONDENSE = 14;
REPORT_BUG = 15;
SUMMARIZE_TASK = 16;
BROWSER_ACTION_LAUNCH = 10;
USE_MCP_SERVER = 11;
NEW_TASK = 12;
CONDENSE = 13;
REPORT_BUG = 14;
SUMMARIZE_TASK = 15;
}
// Enum for ClineSay types
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env node
import chalk from "chalk"
import { execSync } from "child_process"
import * as fs from "fs/promises"
import { globby } from "globby"
import * as path from "path"
import { fileURLToPath } from "url"
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
const PROTO_DIR = path.join(ROOT_DIR, "proto")
const PY_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-python")
const PY_CLIENT_DIR = path.join(PY_OUT_DIR, "client")
function hasCommand(cmd) {
try {
if (process.platform === "win32") {
execSync(`where ${cmd}`, { stdio: "pipe" })
} else {
execSync(`which ${cmd}`, { stdio: "pipe" })
}
return true
} catch {
return false
}
}
function resolvePython() {
// Allow override via env.PYTHON pointing to a specific interpreter
const envPy = process.env.PYTHON
if (envPy) {
try {
execSync(`"${envPy}" --version`, { stdio: "pipe" })
return envPy
} catch {
console.warn(chalk.yellow(`Warning: PYTHON override "${envPy}" is not usable, falling back to discovery.`))
}
}
const candidates = ["python3", "python"]
for (const c of candidates) {
if (hasCommand(c)) {
try {
execSync(`${c} --version`, { stdio: "pipe" })
return c
} catch {
// continue
}
}
}
return null
}
function checkGrpcTools(pythonExe) {
try {
execSync(`"${pythonExe}" -c "import grpc_tools"`, { stdio: "pipe" })
return true
} catch {
return false
}
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true })
}
async function ensureInitPy(dir) {
try {
await fs.writeFile(path.join(dir, "__init__.py"), "", { flag: "wx" })
} catch {
// exists
}
}
/**
* Parse proto files to extract service names with their source file and package.
* Returns array of:
* { serviceName: string, serviceKey: string, protoPackage: "cline"|"host", moduleBase: string }
*/
async function parseServicesWithFiles(protoDir, protoFiles) {
const services = []
for (const relPath of protoFiles) {
const full = path.join(protoDir, relPath)
const content = await fs.readFile(full, "utf8")
const pkg = relPath.startsWith("host/") ? "host" : "cline"
const moduleBase = path.basename(relPath, ".proto")
const serviceRe = /service\s+(\w+Service)\s*\{([\s\S]*?)\}/g
for (const m of content.matchAll(serviceRe)) {
const serviceName = m[1] // e.g., TaskService
const serviceKey = serviceName.replace(/Service$/, "").toLowerCase() // task
const body = m[2]
const methodRe = /rpc\s+(\w+)\s*\((stream\s)?([\w.]+)\)\s*returns\s*\((stream\s)?([\w.]+)\)/g
const methods = []
for (const mm of body.matchAll(methodRe)) {
methods.push({
name: mm[1],
isRequestStreaming: !!mm[2],
requestType: mm[3],
isResponseStreaming: !!mm[4],
responseType: mm[5],
})
}
services.push({ serviceName, serviceKey, protoPackage: pkg, moduleBase, methods })
}
}
return services
}
function upperFirst(s) {
return s.length ? s[0].toUpperCase() + s.slice(1) : s
}
async function generateConnectionPy(outDir) {
const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
# Generated by scripts/build-python-proto.mjs
import grpc
import time
from typing import Optional
class ConnectionManager:
def __init__(self, address: str, timeout: float = 30.0):
self.address = address
self.timeout = timeout
self._channel: Optional[grpc.Channel] = None
def connect(self) -> None:
if self._channel is not None:
return
self._channel = grpc.insecure_channel(self.address)
# Wait for channel to be ready within timeout
grpc.channel_ready_future(self._channel).result(timeout=self.timeout)
def disconnect(self) -> None:
if self._channel is not None:
self._channel.close()
self._channel = None
@property
def channel(self) -> Optional[grpc.Channel]:
return self._channel
def is_connected(self) -> bool:
return self._channel is not None
`
await fs.mkdir(outDir, { recursive: true })
await fs.writeFile(path.join(outDir, "connection.py"), content)
await ensureInitPy(outDir)
}
async function generateClineClientPy(outDir, services) {
// Import per-service wrapper clients
const importLines = []
const seen = new Set()
for (const s of services) {
const fileBase = `${s.serviceKey}_client`
const className = `${s.serviceName.replace(/Service$/, "")}Client`
const importKey = `${fileBase}:${className}`
if (!seen.has(importKey)) {
importLines.push(`from .services.${fileBase} import ${className}`)
seen.add(importKey)
}
}
// Build wrapper initializations on connect (like Go New<Service>Client)
const initLines = services.map((s) => {
const shortName = s.serviceName.replace(/Service$/, "") // Task
const className = `${shortName}Client`
return ` self.${shortName} = ${className}(self._conn.channel)`
})
// Build attribute resets on disconnect
const nilLines = services.map((s) => {
const shortName = s.serviceName.replace(/Service$/, "")
return ` self.${shortName} = None`
})
const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
# Generated by scripts/build-python-proto.mjs
from typing import Optional
import grpc
from .connection import ConnectionManager
${importLines.join("\n")}
class ClineClient:
"""
Unified Python client analogous to src/generated/grpc-go/client/ClineClient.
Usage:
client = ClineClient("localhost:17611")
client.connect()
# Call wrappers, e.g.: client.Task.SomeRpc(...)
client.disconnect()
"""
def __init__(self, address: str, timeout: float = 30.0):
self._conn = ConnectionManager(address, timeout=timeout)
self._connected = False
${services.map((s) => ` self.${s.serviceName.replace(/Service$/, "")}: Optional[object] = None`).join("\n")}
def connect(self) -> None:
if self._connected:
return
self._conn.connect()
${initLines.join("\n")}
self._connected = True
def disconnect(self) -> None:
if not self._connected:
return
self._conn.disconnect()
${nilLines.join("\n")}
self._connected = False
def is_connected(self) -> bool:
return self._connected
@property
def channel(self) -> Optional[grpc.Channel]:
return self._conn.channel
`
const clientDir = outDir
await fs.mkdir(clientDir, { recursive: true })
await fs.writeFile(path.join(clientDir, "cline_client.py"), content)
}
async function generatePythonClient(protoDir, pyOutDir, clientDir, protoFiles) {
// Ensure package structure for client
await fs.mkdir(clientDir, { recursive: true })
await ensureInitPy(pyOutDir)
await ensureInitPy(clientDir)
const services = await parseServicesWithFiles(protoDir, protoFiles)
// connection.py
await generateConnectionPy(clientDir)
// services/ per-service wrappers (mirror Go client/services)
const servicesDir = path.join(clientDir, "services")
await fs.mkdir(servicesDir, { recursive: true })
await ensureInitPy(servicesDir)
await generateServiceClientsPy(servicesDir, services)
// cline_client.py (unified that composes service wrappers)
await generateClineClientPy(clientDir, services)
}
async function generateServiceClientsPy(outDir, services) {
await fs.mkdir(outDir, { recursive: true })
await ensureInitPy(outDir)
for (const s of services) {
const shortName = s.serviceName.replace(/Service$/, "") // Task
const className = `${shortName}Client`
const fileName = `${s.serviceKey}_client.py`
const aliasPb2 = `${s.protoPackage}_${s.moduleBase}_pb2`
const aliasGrpc = `${s.protoPackage}_${s.moduleBase}_pb2_grpc`
const methodLines = s.methods
.map((m) => {
const reqTypeName = m.requestType.split(".").pop()
const respTypeName = m.responseType.split(".").pop()
if (m.isResponseStreaming) {
return `
def ${m.name}(self, req):
"""
Server-streaming RPC.
:param req: ${aliasPb2}.${reqTypeName}
:return: iterator of ${aliasPb2}.${respTypeName}
"""
return self._stub.${m.name}(req)`
} else {
return `
def ${m.name}(self, req):
"""
Unary RPC.
:param req: ${aliasPb2}.${reqTypeName}
:return: ${aliasPb2}.${respTypeName}
"""
return self._stub.${m.name}(req)`
}
})
.join("\n")
const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
# Generated by scripts/build-python-proto.mjs
import grpc
from ${s.protoPackage} import ${s.moduleBase}_pb2 as ${aliasPb2}
from ${s.protoPackage} import ${s.moduleBase}_pb2_grpc as ${aliasGrpc}
class ${className}:
def __init__(self, channel: grpc.Channel):
self._stub = ${aliasGrpc}.${s.serviceName}Stub(channel)
${methodLines}
`
await fs.writeFile(path.join(outDir, fileName), content)
}
}
async function generatePyproject(outDir) {
const content = `[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "cline-grpc-python"
version = "0.1.0"
description = "Generated Python gRPC stubs and client wrappers for Cline protos"
license = { text: "Apache-2.0" }
requires-python = ">=3.9"
dependencies = [
"grpcio>=1.56.0",
"protobuf>=4.21.0"
]
[tool.setuptools.packages.find]
where = ["."]
`
await fs.writeFile(path.join(outDir, "pyproject.toml"), content)
}
async function main() {
console.log(chalk.cyan("Starting Python protobuf code generation..."))
// Verify proto dir exists
try {
const stat = await fs.stat(PROTO_DIR)
if (!stat.isDirectory()) {
console.error(chalk.red(`Proto directory is not a folder: ${PROTO_DIR}`))
process.exit(1)
}
} catch {
console.error(chalk.red(`Proto directory not found: ${PROTO_DIR}`))
process.exit(1)
}
// Resolve Python
const python = resolvePython()
if (!python) {
console.error(
chalk.red("Python not found on PATH. Please install Python 3 and ensure it is available (python3 or python)."),
)
process.exit(1)
}
console.log(chalk.green(`✓ Using Python executable: ${python}`))
// Check grpcio-tools
if (!checkGrpcTools(python)) {
console.error(chalk.red("Missing dependency: grpcio-tools"))
console.log(chalk.yellow("Install with:"))
console.log(chalk.yellow(` ${python} -m pip install grpcio-tools --user --break-system-packages`))
process.exit(1)
}
console.log(chalk.green("✓ grpcio-tools available"))
// Discover proto files
const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
if (!protoFiles.length) {
console.error(chalk.red("No .proto files found under ./proto"))
process.exit(1)
}
console.log(chalk.cyan(`Found ${protoFiles.length} proto files`))
// Ensure output directory
await ensureDir(PY_OUT_DIR)
// Build and run protoc command via grpc_tools
const quoted = (s) => `"${s}"`
const pythonCmd = quoted(python)
const cmd =
`${pythonCmd} -m grpc_tools.protoc ` +
`-I ${quoted(PROTO_DIR)} ` +
`--python_out=${quoted(PY_OUT_DIR)} ` +
`--grpc_python_out=${quoted(PY_OUT_DIR)} ` +
protoFiles.map((f) => quoted(f)).join(" ")
try {
console.log(chalk.cyan(`Generating Python code into ${PY_OUT_DIR}...`))
execSync(cmd, { cwd: ROOT_DIR, stdio: "inherit", env: process.env })
} catch (error) {
console.error(chalk.red("Error generating Python code:"), error?.message || error)
process.exit(1)
}
// Ensure package structure (__init__.py) for imports
await ensureInitPy(PY_OUT_DIR)
try {
const clineDir = path.join(PY_OUT_DIR, "cline")
const hostDir = path.join(PY_OUT_DIR, "host")
// These may or may not exist depending on which protos are present
await fs
.stat(clineDir)
.then(() => ensureInitPy(clineDir))
.catch(() => {})
await fs
.stat(hostDir)
.then(() => ensureInitPy(hostDir))
.catch(() => {})
} catch {
// ignore
}
// Generate Python client structure analogous to src/generated/grpc-go/client
await generatePythonClient(PROTO_DIR, PY_OUT_DIR, PY_CLIENT_DIR, protoFiles)
// Generate a minimal pyproject.toml in the generated output so it can be pip-installed if desired
await generatePyproject(PY_OUT_DIR)
console.log(chalk.green("✓ Python protobuf and client code generation completed successfully!"))
console.log(chalk.cyan(`Output directory: ${PY_OUT_DIR}`))
console.log(chalk.cyan(`Client directory: ${PY_CLIENT_DIR}`))
console.log(chalk.cyan(`PyProject: ${path.join(PY_OUT_DIR, "pyproject.toml")}`))
console.log(chalk.gray("Note: To import, add the output dir to your PYTHONPATH or pip install -e src/generated/grpc-python"))
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(chalk.red("Unexpected error in build-python-proto.mjs:"), err)
process.exit(1)
})
}
+15
View File
@@ -14,6 +14,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { StateManager } from "./core/storage/StateManager"
import { ExtensionRegistryInfo } from "./registry"
import { BannerService } from "./services/banner/BannerService"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
@@ -71,6 +72,20 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await showVersionUpdateAnnouncement(context)
// Initialize banner service
BannerService.initialize(webview.controller)
BannerService.get()
.fetchActiveBanners()
.then((banners) => {
if (banners.length > 0) {
Logger.log(`BannerService: ${banners.length} active banner(s) fetched.`)
// Banners are now cached and can be accessed by the frontend when needed
}
})
.catch((error) => {
Logger.error("BannerService: Failed to fetch banners on startup", error)
})
telemetryService.captureExtensionActivated()
return webview
+2 -1
View File
@@ -1,6 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { ClineTool } from "@/shared/tools"
import { AnthropicHandler } from "./providers/anthropic"
import { AskSageHandler } from "./providers/asksage"
import { BasetenHandler } from "./providers/baseten"
@@ -45,7 +46,7 @@ export type CommonApiHandlerOptions = {
}
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream
getModel(): ApiHandlerModel
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
}
+81 -36
View File
@@ -1,6 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Tool as AnthropicTool, MessageParam } from "@anthropic-ai/sdk/resources/index"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { AnthropicModelId, anthropicDefaultModelId, anthropicModels, CLAUDE_SONNET_1M_SUFFIX, ModelInfo } from "@shared/api"
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
@@ -38,7 +40,7 @@ export class AnthropicHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -48,6 +50,9 @@ export class AnthropicHandler implements ApiHandler {
const enable1mContextWindow = model.id.endsWith(CLAUDE_SONNET_1M_SUFFIX)
const budget_tokens = this.options.thinkingBudgetTokens || 0
// Tools are available only when native tools are enabled.
const nativeToolsOn = tools?.length && tools?.length > 0
const reasoningOn = !!(
(modelId.includes("3-7") || modelId.includes("4-") || modelId.includes("4-5")) &&
budget_tokens !== 0
@@ -68,12 +73,45 @@ export class AnthropicHandler implements ApiHandler {
/*
The latest message will be the new user message, one before will be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
*/
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const userMsgIndices = messages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index)
}
return acc
}, [] as number[])
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
const anthropicMessages: Array<MessageParam> = messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
}
return message
})
stream = await client.messages.create(
{
model: modelId,
@@ -89,39 +127,16 @@ export class AnthropicHandler implements ApiHandler {
cache_control: { type: "ephemeral" },
},
], // setting cache breakpoint for system prompt so new tasks can reuse it
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
}
return message
}),
messages: anthropicMessages,
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
// tool_choice: { type: "auto" },
// tools: tools,
stream: true,
tools: nativeToolsOn ? (tools as AnthropicTool[]) : undefined,
// tool_choice options:
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
// - any: tells Claude that it must use one of the provided tools, but doesnt force a particular tool.
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
tool_choice: nativeToolsOn && !reasoningOn ? { type: "any" } : undefined,
},
(() => {
// 1m context window beta header
@@ -154,6 +169,7 @@ export class AnthropicHandler implements ApiHandler {
}
let thinkingDeltaAccumulator = ""
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
@@ -208,6 +224,14 @@ export class AnthropicHandler implements ApiHandler {
data: chunk.content_block.data,
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
// Convert Anthropic tool_use to OpenAI-compatible format
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
// we may receive multiple text blocks, in which case just insert a line break between them
if (chunk.index > 0) {
@@ -250,9 +274,30 @@ export class AnthropicHandler implements ApiHandler {
text: chunk.delta.text,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
// // Convert Anthropic tool_use to OpenAI-compatible format
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
...lastStartedToolCall,
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
}
break
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
+11 -3
View File
@@ -3,6 +3,7 @@ import { ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineEnv } from "@/config"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
@@ -12,6 +13,7 @@ import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { ToolCallProcessor } from "../transform/tool-call-processor"
import { OpenRouterErrorResponse } from "./types"
interface ClineHandlerOptions extends CommonApiHandlerOptions {
@@ -93,7 +95,7 @@ export class ClineHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
try {
const client = await this.ensureClient()
@@ -110,8 +112,11 @@ export class ClineHandler implements ApiHandler {
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
tools,
)
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
@@ -150,6 +155,10 @@ export class ClineHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
@@ -194,8 +203,7 @@ export class ClineHandler implements ApiHandler {
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: totalCost,
totalCost,
}
didOutputUsage = true
}
+10 -1
View File
@@ -2,11 +2,13 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { DeepSeekModelId, deepSeekDefaultModelId, deepSeekModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface DeepSeekHandlerOptions extends CommonApiHandlerOptions {
deepSeekApiKey?: string
@@ -71,7 +73,7 @@ export class DeepSeekHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -94,8 +96,11 @@ export class DeepSeekHandler implements ApiHandler {
stream_options: { include_usage: true },
// Only set temperature for non-reasoner models
...(model.id === "deepseek-reasoner" ? {} : { temperature: 0 }),
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -105,6 +110,10 @@ export class DeepSeekHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
+38 -2
View File
@@ -1,6 +1,14 @@
import type { Anthropic } from "@anthropic-ai/sdk"
// Restore GenerateContentConfig import and add GenerateContentResponseUsageMetadata
import { ApiError, type GenerateContentConfig, type GenerateContentResponseUsageMetadata, GoogleGenAI, Part } from "@google/genai"
import {
ApiError,
FunctionCallingConfigMode,
type GenerateContentConfig,
type GenerateContentResponseUsageMetadata,
GoogleGenAI,
FunctionDeclaration as GoogleTool,
Part,
} from "@google/genai"
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
import { telemetryService } from "@/services/telemetry"
import { ApiHandler, CommonApiHandlerOptions } from "../"
@@ -102,7 +110,7 @@ export class GeminiHandler implements ApiHandler {
baseDelay: 2000,
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: GoogleTool[]): ApiStream {
const client = this.ensureClient()
const { id: modelId, info } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
@@ -140,6 +148,16 @@ export class GeminiHandler implements ApiHandler {
let thoughtsTokenCount = 0 // Initialize thought token counts
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
if (tools?.length) {
requestConfig.tools = [{ functionDeclarations: tools }]
requestConfig.toolConfig = {
// Force the model to call 'any' function.
functionCallingConfig: {
mode: FunctionCallingConfigMode.ANY,
},
}
}
try {
const result = await client.models.generateContentStream({
model: modelId,
@@ -189,6 +207,24 @@ export class GeminiHandler implements ApiHandler {
}
}
if (tools && chunk.functionCalls && chunk.functionCalls?.length > 0) {
for (const functionCall of chunk.functionCalls) {
if (functionCall.args) {
console.log("[GeminiHandler] tool call received:", functionCall)
yield {
type: "tool_calls",
tool_call: {
function: {
id: functionCall.id || functionCall.name,
name: functionCall.name,
arguments: JSON.stringify(functionCall.args),
},
},
}
}
}
}
if (chunk.usageMetadata) {
lastUsageMetadata = chunk.usageMetadata
promptTokens = lastUsageMetadata.promptTokenCount ?? promptTokens
+9 -1
View File
@@ -2,10 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { GroqModelId, groqDefaultModelId, groqModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface GroqHandlerOptions extends CommonApiHandlerOptions {
groqApiKey?: string
@@ -188,7 +190,7 @@ export class GroqHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const modelFamily = this.detectModelFamily(model.id)
@@ -213,6 +215,7 @@ export class GroqHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
temperature,
...getOpenAIToolParams(tools),
}
// Add any special parameters for specific model families
@@ -220,6 +223,7 @@ export class GroqHandler implements ApiHandler {
Object.assign(requestParams, modelFamily.specialParams)
}
const toolCallProcessor = new ToolCallProcessor()
const stream = await client.chat.completions.create(requestParams)
for await (const chunk of stream) {
@@ -235,6 +239,10 @@ export class GroqHandler implements ApiHandler {
continue
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Handle content field - trust the parsed output from Groq
if (delta?.content) {
yield {
+10 -1
View File
@@ -1,10 +1,12 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { HuaweiCloudMaasModelId, huaweiCloudMaasDefaultModelId, huaweiCloudMaasModels, ModelInfo } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface HuaweiCloudMaaSHandlerOptions extends CommonApiHandlerOptions {
huaweiCloudMaasApiKey?: string
@@ -58,7 +60,7 @@ export class HuaweiCloudMaaSHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -72,12 +74,15 @@ export class HuaweiCloudMaaSHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
temperature: 0,
...getOpenAIToolParams(tools),
})
let reasoning: string | null = null
let didOutputUsage: boolean = false
let finalUsage: any = null
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
@@ -93,6 +98,10 @@ export class HuaweiCloudMaaSHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Handle reasoning output
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
const reasoningContent = delta?.content || ((delta as any)?.reasoning_content as string | undefined) || ""
+9 -1
View File
@@ -2,10 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface HuggingFaceHandlerOptions extends CommonApiHandlerOptions {
huggingFaceApiKey?: string
@@ -65,7 +67,7 @@ export class HuggingFaceHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
try {
const client = this.ensureClient()
const model = this.getModel()
@@ -82,8 +84,10 @@ export class HuggingFaceHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
temperature: 0,
...getOpenAIToolParams(tools),
}
const toolCallProcessor = new ToolCallProcessor()
const stream = (await client.chat.completions.create(requestParams)) as any
let _chunkCount = 0
@@ -101,6 +105,10 @@ export class HuggingFaceHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
}
+12 -1
View File
@@ -1,10 +1,12 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { type ModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import type { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import type { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface LmStudioHandlerOptions extends CommonApiHandlerOptions {
lmStudioBaseUrl?: string
@@ -36,7 +38,7 @@ export class LmStudioHandler implements ApiHandler {
}
@withRetry({ retryAllErrors: true })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
@@ -50,7 +52,11 @@ export class LmStudioHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
max_completion_tokens: this.options.lmStudioMaxTokens ? Number(this.options.lmStudioMaxTokens) : undefined,
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const choice = chunk.choices[0]
const delta = choice?.delta
@@ -66,6 +72,11 @@ export class LmStudioHandler implements ApiHandler {
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield {
type: "usage",
+14 -1
View File
@@ -1,10 +1,12 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import { MinimaxModelId, ModelInfo, minimaxDefaultModelId, minimaxModels } from "@/shared/api"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface MinimaxHandlerOptions extends CommonApiHandlerOptions {
minimaxApiKey?: string
@@ -36,7 +38,11 @@ export class MinimaxHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools?: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -51,8 +57,11 @@ export class MinimaxHandler implements ApiHandler {
max_tokens: model.info.maxTokens,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -69,6 +78,10 @@ export class MinimaxHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield {
type: "usage",
+19 -2
View File
@@ -1,6 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Mistral } from "@mistralai/mistralai"
import { Tool as MistralTool } from "@mistralai/mistralai/models/components/tool"
import { MistralModelId, ModelInfo, mistralDefaultModelId, mistralModels } from "@shared/api"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToMistralMessages } from "../transform/mistral-format"
@@ -36,7 +38,7 @@ export class MistralHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const stream = await client.chat
.stream({
@@ -45,6 +47,8 @@ export class MistralHandler implements ApiHandler {
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToMistralMessages(messages)],
stream: true,
tools: tools?.length ? (tools as MistralTool[]) : undefined,
toolChoice: tools?.length ? "any" : undefined,
})
.catch((err) => {
// The Mistal SDK uses statusCode instead of status
@@ -58,7 +62,20 @@ export class MistralHandler implements ApiHandler {
for await (const chunk of stream) {
const delta = chunk.data.choices[0]?.delta
if (delta?.content) {
if (delta.toolCalls) {
for (const toolCall of delta.toolCalls) {
yield {
type: "tool_calls",
tool_call: {
function: {
id: toolCall.id,
name: toolCall.function.name,
arguments: JSON.stringify(toolCall.function.arguments),
},
},
}
}
} else if (delta?.content) {
let content: string = ""
if (typeof delta.content === "string") {
content = delta.content
+11 -1
View File
@@ -1,10 +1,12 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ModelInfo, MoonshotModelId, moonshotDefaultModelId, moonshotModels } from "@/shared/api"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface MoonshotHandlerOptions extends CommonApiHandlerOptions {
moonshotApiKey?: string
@@ -36,7 +38,7 @@ export class MoonshotHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -52,7 +54,11 @@ export class MoonshotHandler implements ApiHandler {
max_tokens: model.info.maxTokens,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -62,6 +68,10 @@ export class MoonshotHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
+9 -1
View File
@@ -1,11 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { type ModelInfo, type NebiusModelId, nebiusDefaultModelId, nebiusModels } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface NebiusHandlerOptions extends CommonApiHandlerOptions {
nebiusApiKey?: string
@@ -35,7 +37,7 @@ export class NebiusHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -49,7 +51,9 @@ export class NebiusHandler implements ApiHandler {
temperature: 0,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -59,6 +63,10 @@ export class NebiusHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
+10 -1
View File
@@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { LiteLLMModelInfo, liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults } from "@shared/api"
import OpenAI, { APIError, OpenAIError } from "openai"
import type { FinalRequestOptions, Headers as OpenAIHeaders } from "openai/core"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import {
DEFAULT_EXTERNAL_OCA_BASE_URL,
@@ -14,6 +15,7 @@ import { ApiHandler, type CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
export interface OcaHandlerOptions extends CommonApiHandlerOptions {
ocaBaseUrl?: string
@@ -135,7 +137,7 @@ export class OcaHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
@@ -187,6 +189,8 @@ export class OcaHandler implements ApiHandler {
return message
})
const toolCallProcessor = new ToolCallProcessor()
const stream = await client.chat.completions.create({
model: this.options.ocaModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
@@ -198,6 +202,7 @@ export class OcaHandler implements ApiHandler {
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
...(this.options.taskId && {
litellm_session_id: `cline-${this.options.taskId}`,
...getOpenAIToolParams(tools),
}), // Add session ID for LiteLLM tracking
})
@@ -228,6 +233,10 @@ export class OcaHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Handle token usage information
if (chunk.usage) {
const totalCost =
+26 -4
View File
@@ -2,11 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, OpenAiNativeModelId, openAiNativeDefaultModelId, openAiNativeModels } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionReasoningEffort } from "openai/resources/chat/completions"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
openAiNativeApiKey?: string
@@ -56,9 +57,14 @@ export class OpenAiNativeHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools?: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const toolCallProcessor = new ToolCallProcessor()
switch (model.id) {
case "o1":
@@ -114,6 +120,7 @@ export class OpenAiNativeHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
reasoning_effort: (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium",
...getOpenAIToolParams(tools),
})
for await (const chunk of stream) {
@@ -124,8 +131,17 @@ export class OpenAiNativeHandler implements ApiHandler {
text: delta.content,
}
}
if (delta?.tool_calls) {
try {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
} catch (error) {
console.error("Error processing tool call delta:", error, delta.tool_calls)
}
}
if (chunk.usage) {
// Only last chunk contains usage
// Only last chunk contains usage - stream is ending
yield* this.yieldUsage(model.info, chunk.usage)
}
}
@@ -138,6 +154,7 @@ export class OpenAiNativeHandler implements ApiHandler {
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
for await (const chunk of stream) {
@@ -148,8 +165,13 @@ export class OpenAiNativeHandler implements ApiHandler {
text: delta.content,
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
// Only last chunk contains usage
// Only last chunk contains usage - stream is ending
yield* this.yieldUsage(model.info, chunk.usage)
}
}
+16 -7
View File
@@ -1,12 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { azureOpenAiDefaultApiVersion, ModelInfo, OpenAiCompatibleModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
import OpenAI, { AzureOpenAI } from "openai"
import type { ChatCompletionReasoningEffort } from "openai/resources/chat/completions"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface OpenAiHandlerOptions extends CommonApiHandlerOptions {
openAiApiKey?: string
@@ -61,16 +62,17 @@ export class OpenAiHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools?: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
const isReasoningModelFamily =
modelId.includes("o1") ||
modelId.includes("o3") ||
modelId.includes("o4") ||
(modelId.includes("gpt-5") && !modelId.includes("chat"))
["o1", "o3", "o4", "gpt-5"].some((prefix) => modelId.includes(prefix)) && !modelId.includes("chat")
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
@@ -104,7 +106,11 @@ export class OpenAiHandler implements ApiHandler {
reasoning_effort: reasoningEffort,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -121,12 +127,15 @@ export class OpenAiHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
+9 -1
View File
@@ -4,10 +4,12 @@ import { ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { ToolCallProcessor } from "../transform/tool-call-processor"
import { OpenRouterErrorResponse } from "./types"
interface OpenRouterHandlerOptions extends CommonApiHandlerOptions {
@@ -50,7 +52,7 @@ export class OpenRouterHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
this.lastGenerationId = undefined
@@ -62,9 +64,11 @@ export class OpenRouterHandler implements ApiHandler {
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
tools,
)
let didOutputUsage: boolean = false
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
@@ -112,6 +116,10 @@ export class OpenRouterHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
+9 -1
View File
@@ -2,12 +2,14 @@ import { promises as fs } from "node:fs"
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, QwenCodeModelId, qwenCodeDefaultModelId, qwenCodeModels } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import * as os from "os"
import * as path from "path"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
// --- Constants for Qwen OAuth2 ---
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
@@ -174,7 +176,7 @@ export class QwenCodeHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
await this.ensureAuthenticated()
const client = this.ensureClient()
const model = this.getModel()
@@ -193,10 +195,12 @@ export class QwenCodeHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
max_completion_tokens: model.info.maxTokens,
...getOpenAIToolParams(tools),
}
const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
const toolCallProcessor = new ToolCallProcessor()
let fullContent = ""
for await (const apiChunk of stream) {
@@ -240,6 +244,10 @@ export class QwenCodeHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
// Handle reasoning content (o1-style)
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
+14 -1
View File
@@ -10,11 +10,13 @@ import {
QwenApiRegions,
} from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface QwenHandlerOptions extends CommonApiHandlerOptions {
qwenApiKey?: string
@@ -77,7 +79,7 @@ export class QwenHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const isDeepseekReasoner = model.id.includes("deepseek-r1")
@@ -112,8 +114,11 @@ export class QwenHandler implements ApiHandler {
stream_options: { include_usage: true },
temperature,
...thinkingArgs,
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -123,6 +128,14 @@ export class QwenHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
try {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
} catch (error) {
console.error("Error processing tool call delta:", error, delta.tool_calls)
}
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
+9 -1
View File
@@ -1,11 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface SambanovaHandlerOptions extends CommonApiHandlerOptions {
sambanovaApiKey?: string
@@ -38,7 +40,7 @@ export class SambanovaHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -53,12 +55,14 @@ export class SambanovaHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const toolCallProcessor = new ToolCallProcessor()
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
for await (const chunk of stream) {
@@ -70,6 +74,10 @@ export class SambanovaHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield {
type: "usage",
+9 -1
View File
@@ -1,11 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface TogetherHandlerOptions extends CommonApiHandlerOptions {
togetherApiKey?: string
@@ -38,7 +40,7 @@ export class TogetherHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.togetherModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
@@ -58,7 +60,9 @@ export class TogetherHandler implements ApiHandler {
temperature: 0,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -68,6 +72,10 @@ export class TogetherHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
+16 -2
View File
@@ -1,9 +1,11 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, vercelAiGatewayDefaultModelId, vercelAiGatewayDefaultModelInfo } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
import { ToolCallProcessor } from "../transform/tool-call-processor"
import { createVercelAIGatewayStream } from "../transform/vercel-ai-gateway-stream"
interface VercelAIGatewayHandlerOptions extends CommonApiHandlerOptions {
@@ -42,15 +44,23 @@ export class VercelAIGatewayHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const modelId = this.getModel().id
const modelInfo = this.getModel().info
try {
const stream = await createVercelAIGatewayStream(client, systemPrompt, messages, { id: modelId, info: modelInfo })
const stream = await createVercelAIGatewayStream(
client,
systemPrompt,
messages,
{ id: modelId, info: modelInfo },
tools,
)
let didOutputUsage: boolean = false
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -60,6 +70,10 @@ export class VercelAIGatewayHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (!didOutputUsage && chunk.usage) {
const inputTokens = chunk.usage.prompt_tokens || 0
const outputTokens =
+46 -2
View File
@@ -1,6 +1,9 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { FunctionDeclaration as GoogleTool } from "@google/genai"
import { ModelInfo, VertexModelId, vertexDefaultModelId, vertexModels } from "@shared/api"
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
@@ -63,14 +66,14 @@ export class VertexHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream {
const model = this.getModel()
const modelId = model.id
// For Gemini models, use the GeminiHandler
if (!modelId.includes("claude")) {
const geminiHandler = this.ensureGeminiHandler()
yield* geminiHandler.createMessage(systemPrompt, messages)
yield* geminiHandler.createMessage(systemPrompt, messages, tools as GoogleTool[])
return
}
@@ -160,6 +163,12 @@ export class VertexHandler implements ApiHandler {
}
}),
stream: true,
tools: tools?.length ? (tools as AnthropicTool[]) : undefined,
// tool_choice options:
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
// - any: tells Claude that it must use one of the provided tools, but doesnt force a particular tool.
tool_choice: tools ? { type: "any" } : undefined,
},
{
headers: {},
@@ -191,11 +200,19 @@ export class VertexHandler implements ApiHandler {
: message.content,
})),
stream: true,
tools: tools?.length ? (tools as AnthropicTool[]) : undefined,
// tool_choice options:
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
// - any: tells Claude that it must use one of the provided tools, but doesnt force a particular tool.
tool_choice: tools ? { type: "any" } : undefined,
})
break
}
}
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start":
@@ -233,6 +250,14 @@ export class VertexHandler implements ApiHandler {
reasoning: "[Redacted thinking block]",
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
// Convert Anthropic tool_use to OpenAI-compatible format
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
if (chunk.index > 0) {
yield {
@@ -255,6 +280,22 @@ export class VertexHandler implements ApiHandler {
reasoning: chunk.delta.thinking,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
// // Convert Anthropic tool_use to OpenAI-compatible format
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
case "text_delta":
yield {
type: "text",
@@ -264,6 +305,9 @@ export class VertexHandler implements ApiHandler {
}
break
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
+10 -1
View File
@@ -2,11 +2,13 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo, XAIModelId, xaiDefaultModelId, xaiModels } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface XAIHandlerOptions extends CommonApiHandlerOptions {
xaiApiKey?: string
@@ -40,7 +42,7 @@ export class XAIHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const modelId = this.getModel().id
// ensure reasoning effort is either "low" or "high" for grok-3-mini
@@ -59,8 +61,11 @@ export class XAIHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
reasoning_effort: reasoningEffort,
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -70,6 +75,10 @@ export class XAIHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if (!shouldSkipReasoningForModel(modelId)) {
+10 -1
View File
@@ -9,11 +9,13 @@ import {
mainlandZAiModels,
} from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { version as extensionVersion } from "../../../../package.json"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface ZAiHandlerOptions extends CommonApiHandlerOptions {
zaiApiLine?: string
@@ -72,7 +74,7 @@ export class ZAiHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -85,8 +87,11 @@ export class ZAiHandler implements ApiHandler {
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
@@ -96,6 +101,10 @@ export class ZAiHandler implements ApiHandler {
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield {
type: "usage",
@@ -0,0 +1,19 @@
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
/**
* Converts an OpenAI ChatCompletionTool into an Anthropic Tool definition
*/
export function openAIToolToAnthropic(openAITool: OpenAITool): AnthropicTool {
const func = openAITool.function
return {
name: func.name,
description: func.description || "",
input_schema: {
type: "object",
properties: func.parameters?.properties || {},
required: func.parameters?.required || [],
},
}
}
+16
View File
@@ -19,6 +19,22 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont
mimeType: block.source.media_type,
},
}
case "tool_use":
return {
functionCall: {
name: block.name,
args: block.input as Record<string, unknown>,
},
}
case "tool_result":
return {
functionResponse: {
name: block.tool_use_id,
response: {
result: block.content,
},
},
}
default:
throw new Error(`Unsupported content block type: ${block.type}`)
}
+43 -29
View File
@@ -45,7 +45,7 @@ export function convertToOpenAiMessages(
if (typeof toolMessage.content === "string") {
content = toolMessage.content
} else {
} else if (Array.isArray(toolMessage.content)) {
content =
toolMessage.content
?.map((part) => {
@@ -56,6 +56,9 @@ export function convertToOpenAiMessages(
return part.text
})
.join("\n") ?? ""
} else {
// Handle undefined content
content = ""
}
openAiMessages.push({
role: "tool",
@@ -70,15 +73,15 @@ export function convertToOpenAiMessages(
// Therefore we need to send these images after the tool result messages
// NOTE: it's actually okay to have multiple user messages in a row, the model will treat them as a continuation of the same input (this way works better than combining them into one message, since the tool result specifically mentions (see following user message for image)
// UPDATE v2.0: we don't use tools anymore, but if we did it's important to note that the openrouter prompt caching mechanism requires one user message at a time, so we would need to add these images to the user content array instead.
// if (toolResultImages.length > 0) {
// openAiMessages.push({
// role: "user",
// content: toolResultImages.map((part) => ({
// type: "image_url",
// image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
// })),
// })
// }
if (toolResultImages.length > 0) {
openAiMessages.push({
role: "user",
content: toolResultImages.map((part) => ({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})),
})
}
// Process non-tool messages
if (nonToolMessages.length > 0) {
@@ -157,7 +160,7 @@ export function convertToOpenAiMessages(
role: "assistant",
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,
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
// @ts-ignore-next-line
reasoning_details: reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined,
})
@@ -272,6 +275,9 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
return consolidated
}
// Unique name to use to filter out tool call that cannot be parsed correctly
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_"
// Convert OpenAI response to Anthropic format
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
@@ -308,24 +314,32 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
cache_read_input_tokens: null,
},
}
if (openAiMessage.tool_calls && openAiMessage.tool_calls.length > 0) {
anthropicMessage.content.push(
...openAiMessage.tool_calls.map((toolCall): Anthropic.ToolUseBlock => {
let parsedInput = {}
try {
parsedInput = JSON.parse(toolCall.function.arguments || "{}")
} catch (error) {
console.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function.name,
input: parsedInput,
}
}),
)
try {
if (openAiMessage?.tool_calls?.length) {
anthropicMessage.content.push(
...openAiMessage.tool_calls
.map((toolCall): Anthropic.ToolUseBlock => {
const parsedName = toolCall.type === "function" && toolCall.function.name
let parsedInput = toolCall.function.arguments
try {
parsedInput = JSON.parse(toolCall.function.arguments || "{}")
} catch (error) {
console.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: parsedName || UNIQUE_ERROR_TOOL_NAME,
input: parsedInput,
}
})
// Filter out any tool uses with the UNIQUE_ERROR_TOOL_NAME, which indicates a parsing error
.filter((toolUse) => toolUse.name !== UNIQUE_ERROR_TOOL_NAME),
)
}
} catch (error) {
console.error("Failed to process tool calls:", error)
}
return anthropicMessage
}
@@ -7,8 +7,10 @@ import {
openRouterClaudeSonnet451mModelId,
} from "@shared/api"
import OpenAI from "openai"
import { ChatCompletionTool } from "openai/resources/chat/completions"
import { convertToOpenAiMessages } from "./openai-format"
import { convertToR1Format } from "./r1-format"
import { getOpenAIToolParams } from "./tool-call-processor"
export async function createOpenRouterStream(
client: OpenAI,
@@ -18,6 +20,7 @@ export async function createOpenRouterStream(
reasoningEffort?: string,
thinkingBudgetTokens?: number,
openRouterProviderSorting?: string,
tools?: Array<ChatCompletionTool>,
) {
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -185,6 +188,7 @@ export async function createOpenRouterStream(
...(openRouterProviderSorting && !providerPreferences ? { provider: { sort: openRouterProviderSorting } } : {}),
...(providerPreferences ? { provider: providerPreferences } : {}),
...(isClaudeSonnet1m ? { provider: { order: ["anthropic", "google-vertex/global"], allow_fallbacks: false } } : {}),
...getOpenAIToolParams(tools),
})
return stream
+16
View File
@@ -6,6 +6,7 @@ export type ApiStreamChunk =
| ApiStreamAnthropicThinkingChunk
| ApiStreamAnthropicRedactedThinkingChunk
| ApiStreamUsageChunk
| ApiStreamToolCallsChunk
export interface ApiStreamTextChunk {
type: "text"
@@ -42,3 +43,18 @@ export interface ApiStreamUsageChunk {
thoughtsTokenCount?: number // openrouter
totalCost?: number // openrouter
}
export interface ApiStreamToolCallsChunk {
type: "tool_calls"
tool_call: ApiStreamToolCall
}
export interface ApiStreamToolCall {
call_id?: string // The call / request ID associated with this tool call
// Information about the tool being called
function: {
id?: string // The tool call ID
name?: string
arguments?: any
}
}
@@ -0,0 +1,87 @@
import type {
ChatCompletionChunk,
ChatCompletionToolChoiceOption,
ChatCompletionTool as OpenAITool,
} from "openai/resources/chat/completions"
import { Logger } from "@/services/logging/Logger"
import type { ApiStreamToolCallsChunk } from "./stream"
/**
* Helper class to process tool call deltas from OpenAI-compatible streaming responses.
* Handles accumulating tool call ID and name across multiple delta chunks,
* and yields properly formatted tool call chunks when arguments are received.
*/
export class ToolCallProcessor {
private lastToolCall: { id: string; name: string }
constructor() {
this.lastToolCall = { id: "", name: "" }
}
/**
* Process tool call deltas from a chunk and yield formatted tool call chunks.
* @param toolCallDeltas - Array of tool call deltas from the chunk
* @yields Formatted tool call chunks ready to be yielded in the API stream
*/
*processToolCallDeltas(
toolCallDeltas: ChatCompletionChunk.Choice.Delta.ToolCall[] | undefined,
): Generator<ApiStreamToolCallsChunk> {
if (!toolCallDeltas) {
return
}
for (const toolCallDelta of toolCallDeltas) {
// Accumulate the tool call ID if present
if (toolCallDelta.id) {
this.lastToolCall.id = toolCallDelta.id
}
// Accumulate the function name if present
if (toolCallDelta.function?.name) {
Logger.debug(`[ToolCallProcessor] Native Tool Called: ${toolCallDelta.function.name}`)
this.lastToolCall.name = toolCallDelta.function.name
}
// Only yield when we have all required fields: id, name, and arguments
if (this.lastToolCall.id && this.lastToolCall.name && toolCallDelta.function?.arguments) {
yield {
type: "tool_calls",
tool_call: {
...toolCallDelta,
function: {
...toolCallDelta.function,
id: this.lastToolCall.id,
name: this.lastToolCall.name,
},
},
}
}
}
}
/**
* Reset the internal state. Call this when starting a new message.
*/
reset(): void {
this.lastToolCall = { id: "", name: "" }
}
/**
* Get the current accumulated tool call state (useful for debugging).
*/
getState(): { id: string; name: string } {
return { ...this.lastToolCall }
}
}
export function getOpenAIToolParams(tools?: OpenAITool[]) {
return tools?.length
? {
tools,
tool_choice: tools ? ("auto" as ChatCompletionToolChoiceOption) : undefined,
parallel_tool_calls: tools ? true : undefined,
}
: {
tools: undefined,
}
}
+190
View File
@@ -0,0 +1,190 @@
import { Anthropic } from "@anthropic-ai/sdk"
import type { ToolUse } from "@core/assistant-message"
import { JSONParser } from "@streamparser/json"
import { McpHub } from "@/services/mcp/McpHub"
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
import { ClineDefaultTool } from "@/shared/tools"
export interface PendingToolUse {
id: string
name: string
input: string
parsedInput?: unknown
jsonParser?: JSONParser
call_id?: string
}
interface ToolUseDeltaBlock {
id?: string
type?: string
name?: string
input?: string
}
const ESCAPE_MAP: Record<string, string> = {
"\\n": "\n",
"\\t": "\t",
"\\r": "\r",
'\\"': '"',
"\\\\": "\\",
}
const ESCAPE_PATTERN = /\\[ntr"\\]/g
/**
* Handles streaming tool use blocks and converts them to Anthropic.ToolUseBlockParam format
*/
export class ToolUseHandler {
private pendingToolUses = new Map<string, PendingToolUse>()
processToolUseDelta(delta: ToolUseDeltaBlock, call_id?: string): void {
if (delta.type !== "tool_use" || !delta.id) {
return
}
let pending = this.pendingToolUses.get(delta.id)
if (!pending) {
pending = this.createPendingToolUse(delta.id, delta.name || "", call_id)
}
if (delta.name) {
pending.name = delta.name
}
if (delta.input) {
pending.input += delta.input
try {
pending.jsonParser?.write(delta.input)
} catch {
// Expected during streaming
}
}
}
getFinalizedToolUse(id: string): Anthropic.ToolUseBlockParam | undefined {
const pending = this.pendingToolUses.get(id)
if (!pending?.name) {
return undefined
}
let input: unknown = {}
if (pending.parsedInput != null) {
input = pending.parsedInput
} else if (pending.input) {
try {
input = JSON.parse(pending.input)
} catch {
input = this.extractPartialJsonFields(pending.input)
}
}
return {
type: "tool_use",
id: pending.id,
name: pending.name,
input,
}
}
getAllFinalizedToolUses(): Anthropic.ToolUseBlockParam[] {
const results: Anthropic.ToolUseBlockParam[] = []
for (const id of this.pendingToolUses.keys()) {
const toolUse = this.getFinalizedToolUse(id)
if (toolUse) {
results.push(toolUse)
}
}
return results
}
hasToolUse(id: string): boolean {
return this.pendingToolUses.has(id)
}
getPartialToolUsesAsContent(): ToolUse[] {
const results: ToolUse[] = []
for (const pending of this.pendingToolUses.values()) {
if (!pending.name) {
continue
}
let input: any = {}
if (pending.parsedInput != null) {
input = pending.parsedInput
} else if (pending.input) {
try {
input = JSON.parse(pending.input)
} catch {
input = this.extractPartialJsonFields(pending.input)
}
}
if (pending.name.includes(CLINE_MCP_TOOL_IDENTIFIER)) {
const [key, toolName] = pending.name.split(CLINE_MCP_TOOL_IDENTIFIER)
results.push({
type: "tool_use",
name: ClineDefaultTool.MCP_USE,
params: {
server_name: McpHub.getMcpServerByKey(key),
tool_name: toolName,
arguments: JSON.stringify(input),
},
partial: true,
})
} else {
const params: Record<string, string> = {}
if (typeof input === "object") {
for (const [key, value] of Object.entries(input)) {
params[key] = typeof value === "string" ? value : JSON.stringify(value)
}
}
results.push({
type: "tool_use",
name: pending.name as ClineDefaultTool,
params: params as any,
partial: true,
})
}
}
return results
}
reset(): void {
this.pendingToolUses.clear()
}
private createPendingToolUse(id: string, name: string, call_id?: string): PendingToolUse {
const jsonParser = new JSONParser()
const pending: PendingToolUse = {
id,
name,
input: "",
parsedInput: undefined,
jsonParser,
call_id,
}
jsonParser.onValue = (info: any) => {
if (info.stack.length === 0 && info.value && typeof info.value === "object") {
pending.parsedInput = info.value
}
}
jsonParser.onError = () => {}
this.pendingToolUses.set(id, pending)
return pending
}
private extractPartialJsonFields(partialJson: string): Record<string, any> {
const result: Record<string, any> = {}
const pattern = /"(\w+)":\s*"((?:[^"\\]|\\.)*)(?:")?/g
for (const match of partialJson.matchAll(pattern)) {
result[match[1]] = match[2].replace(ESCAPE_PATTERN, (m) => ESCAPE_MAP[m])
}
return result
}
}
@@ -1,13 +1,16 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { getOpenAIToolParams } from "./tool-call-processor"
export async function createVercelAIGatewayStream(
client: OpenAI,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
tools?: OpenAITool[],
) {
// Convert Anthropic messages to OpenAI format
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -49,6 +52,7 @@ export async function createVercelAIGatewayStream(
temperature: 0.7,
messages: openAiMessages,
stream: true,
...getOpenAIToolParams(tools),
})
return stream
+2
View File
@@ -13,6 +13,7 @@ export const toolParamNames = [
"command",
"requires_approval",
"path",
"absolutePath",
"content",
"diff",
"regex",
@@ -39,6 +40,7 @@ export const toolParamNames = [
"needs_more_exploration",
"task_progress",
"timeout",
"input",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -329,10 +329,128 @@ export class ContextManager {
const updatedMessages = this.applyContextHistoryUpdates(messages, deletedRange ? deletedRange[1] + 1 : 2)
// Validate and fix tool_use/tool_result pairing
this.ensureToolResultsFollowToolUse(updatedMessages)
// OLD NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return updatedMessages
}
/**
* Ensures that every tool_use block in assistant messages has a corresponding tool_result in the next user message,
* and that tool_result blocks immediately follow their corresponding tool_use blocks
*/
private ensureToolResultsFollowToolUse(messages: Anthropic.Messages.MessageParam[]): void {
for (let i = 0; i < messages.length - 1; i++) {
const message = messages[i]
// Only process assistant messages with content
if (message.role !== "assistant" || !Array.isArray(message.content)) {
continue
}
// Extract tool_use IDs in order
const toolUseIds: string[] = []
for (const block of message.content) {
if (block.type === "tool_use" && block.id) {
toolUseIds.push(block.id)
}
}
// Skip if no tool_use blocks found
if (toolUseIds.length === 0) {
continue
}
const nextMessage = messages[i + 1]
// Skip if next message is not a user message
if (nextMessage.role !== "user") {
continue
}
// Ensure content is an array
if (!Array.isArray(nextMessage.content)) {
nextMessage.content = []
}
// Separate tool_results from other blocks in a single pass
const toolResultMap = new Map<string, Anthropic.Messages.ToolResultBlockParam>()
const otherBlocks: Anthropic.Messages.ContentBlockParam[] = []
let needsUpdate = false
for (const block of nextMessage.content) {
if (block.type === "tool_result" && block.tool_use_id) {
toolResultMap.set(block.tool_use_id, block)
} else {
otherBlocks.push(block)
}
}
// Check if reordering is needed (tool_results not at start in correct order)
if (toolResultMap.size > 0) {
let expectedIndex = 0
for (let j = 0; j < nextMessage.content.length && expectedIndex < toolUseIds.length; j++) {
const block = nextMessage.content[j]
if (block.type === "tool_result" && block.tool_use_id === toolUseIds[expectedIndex]) {
expectedIndex++
} else if (block.type === "tool_result" || expectedIndex < toolUseIds.length) {
needsUpdate = true
break
}
}
if (!needsUpdate && expectedIndex < toolResultMap.size) {
needsUpdate = true
}
}
// Add missing tool_results
for (const toolUseId of toolUseIds) {
if (!toolResultMap.has(toolUseId)) {
toolResultMap.set(toolUseId, {
type: "tool_result",
tool_use_id: toolUseId,
content: "result missing",
})
needsUpdate = true
}
}
// Only modify if changes are needed
if (!needsUpdate) {
continue
}
// Build new content: tool_results first (in toolUseIds order), then other blocks
const newContent: Anthropic.Messages.ContentBlockParam[] = []
// Add tool_results in the order of toolUseIds
const processedToolResults = new Set<string>()
for (const toolUseId of toolUseIds) {
const toolResult = toolResultMap.get(toolUseId)
if (toolResult) {
newContent.push(toolResult)
processedToolResults.add(toolUseId)
}
}
// Add any orphaned tool_results not in toolUseIds (shouldn't happen, but be safe)
for (const [toolUseId, toolResult] of toolResultMap) {
if (!processedToolResults.has(toolUseId)) {
newContent.push(toolResult)
}
}
// Add all other blocks
newContent.push(...otherBlocks)
// Clone and update the message
const clonedMessage = cloneDeep(nextMessage)
clonedMessage.content = newContent
messages[i + 1] = clonedMessage
}
}
/**
* applies deletedRange truncation and other alterations based on changes in this.contextHistoryUpdates
*/
@@ -0,0 +1,24 @@
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { toRequestyServiceUrl } from "@shared/providers/requesty"
import { HostProvider } from "@/hosts/host-provider"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
/**
* Initiates Requesty auth with optional custom base URL
*/
export async function requestyAuthClicked(_: Controller, req: StringRequest): Promise<Empty> {
const customBaseUrl = req.value || undefined
const callbackUrl = await HostProvider.get().getCallbackUrl()
const baseUrl = toRequestyServiceUrl(customBaseUrl, "app")
if (!baseUrl) {
throw new Error("Invalid Requesty base URL")
}
const authUrl = new URL(`oauth/authorize?callback_url=${callbackUrl}/requesty`, baseUrl)
await openExternal(authUrl.toString())
return {}
}
+107 -94
View File
@@ -75,6 +75,9 @@ export class Controller {
private backgroundCommandRunning = false
private backgroundCommandTaskId?: string
// Flag to prevent duplicate cancellations from spam clicking
private cancelInProgress = false
// Shell integration warning tracker
private shellIntegrationWarningTracker: {
timestamps: number[]
@@ -333,6 +336,12 @@ export class Controller {
taskLockAcquired,
})
if (historyItem) {
this.task.resumeTaskFromHistory()
} else if (task || images || files) {
this.task.startTask(task, images, files)
}
return this.task.taskId
}
@@ -410,14 +419,28 @@ export class Controller {
}
async cancelTask() {
if (this.task) {
// Prevent duplicate cancellations from spam clicking
if (this.cancelInProgress) {
console.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
return
}
if (!this.task) {
return
}
// Set flag to prevent concurrent cancellations
this.cancelInProgress = true
try {
this.updateBackgroundCommandState(false)
const { historyItem } = await this.getTaskWithId(this.task.taskId)
try {
await this.task.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
}
await pWaitFor(
() =>
this.task === undefined ||
@@ -430,13 +453,38 @@ export class Controller {
).catch(() => {
console.error("Failed to abort task")
})
if (this.task) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.taskState.abandoned = true
}
await this.initTask(undefined, undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
// Dont send the state to the webview, the new Cline instance will send state when it's ready.
// Sending the state here sent an empty messages array to webview leading to virtuoso having to reload the entire list
// Small delay to ensure state manager has persisted the history update
//await new Promise((resolve) => setTimeout(resolve, 100))
// NOW try to get history after abort has finished (hook may have saved messages)
let historyItem: HistoryItem | undefined
try {
const result = await this.getTaskWithId(this.task.taskId)
historyItem = result.historyItem
} catch (error) {
// Task not in history yet (new task with no messages); catch the
// error to enable the agent to continue making progress.
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
if (historyItem) {
// Re-initialize task to keep it visible in UI with resume button
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
} else {
await this.clearTask()
}
await this.postStateToWebview()
} finally {
// Always clear the flag, even if cancellation fails
this.cancelInProgress = false
}
}
@@ -597,99 +645,41 @@ export class Controller {
}
// MCP Marketplace
private async fetchMcpMarketplaceFromApi(silent: boolean = false): Promise<McpMarketplaceCatalog | undefined> {
try {
const response = await axios.get(`${ClineEnv.config().mcpBaseUrl}/marketplace`, {
headers: {
"Content-Type": "application/json",
},
})
private async fetchMcpMarketplaceFromApi(): Promise<McpMarketplaceCatalog> {
const response = await axios.get(`${ClineEnv.config().mcpBaseUrl}/marketplace`, {
headers: {
"Content-Type": "application/json",
"User-Agent": "cline-vscode-extension",
},
})
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
const catalog: McpMarketplaceCatalog = {
items: (response.data || []).map((item: any) => ({
...item,
githubStars: item.githubStars ?? 0,
downloadCount: item.downloadCount ?? 0,
tags: item.tags ?? [],
})),
}
// Store in cache file
await writeMcpMarketplaceCatalogToCache(catalog)
return catalog
} catch (error) {
console.error("Failed to fetch MCP marketplace:", error)
if (!silent) {
const errorMessage = error instanceof Error ? error.message : "Failed to fetch MCP marketplace"
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
}
return undefined
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
const catalog: McpMarketplaceCatalog = {
items: (response.data || []).map((item: any) => ({
...item,
githubStars: item.githubStars ?? 0,
downloadCount: item.downloadCount ?? 0,
tags: item.tags ?? [],
})),
}
// Store in cache file
await writeMcpMarketplaceCatalogToCache(catalog)
return catalog
}
private async fetchMcpMarketplaceFromApiRPC(silent: boolean = false): Promise<McpMarketplaceCatalog | undefined> {
async refreshMcpMarketplace(sendCatalogEvent: boolean): Promise<McpMarketplaceCatalog | undefined> {
try {
const response = await axios.get(`${ClineEnv.config().mcpBaseUrl}/marketplace`, {
headers: {
"Content-Type": "application/json",
"User-Agent": "cline-vscode-extension",
},
})
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
const catalog: McpMarketplaceCatalog = {
items: (response.data || []).map((item: any) => ({
...item,
githubStars: item.githubStars ?? 0,
downloadCount: item.downloadCount ?? 0,
tags: item.tags ?? [],
})),
}
// Store in cache file
await writeMcpMarketplaceCatalogToCache(catalog)
return catalog
} catch (error) {
console.error("Failed to fetch MCP marketplace:", error)
if (!silent) {
const errorMessage = error instanceof Error ? error.message : "Failed to fetch MCP marketplace"
throw new Error(errorMessage)
}
return undefined
}
}
async silentlyRefreshMcpMarketplace() {
try {
const catalog = await this.fetchMcpMarketplaceFromApi(true)
if (catalog) {
const catalog = await this.fetchMcpMarketplaceFromApi()
if (catalog && sendCatalogEvent) {
await sendMcpMarketplaceCatalogEvent(catalog)
}
return catalog
} catch (error) {
console.error("Failed to silently refresh MCP marketplace:", error)
}
}
/**
* RPC variant that silently refreshes the MCP marketplace catalog and returns the result
* Unlike silentlyRefreshMcpMarketplace, this doesn't send a message to the webview
* @returns MCP marketplace catalog or undefined if refresh failed
*/
async silentlyRefreshMcpMarketplaceRPC() {
try {
return await this.fetchMcpMarketplaceFromApiRPC(true)
} catch (error) {
console.error("Failed to silently refresh MCP marketplace (RPC):", error)
console.error("Failed to refresh MCP marketplace:", error)
return undefined
}
}
@@ -730,6 +720,25 @@ export class Controller {
// Dont send settingsButtonClicked because its bad ux if user is on welcome
}
// Requesty
async handleRequestyCallback(code: string) {
const requesty: ApiProvider = "requesty"
const currentMode = this.stateManager.getGlobalSettingsKey("mode")
const currentApiConfiguration = this.stateManager.getApiConfiguration()
const updatedConfig = {
...currentApiConfiguration,
planModeApiProvider: requesty,
actModeApiProvider: requesty,
requestyApiKey: code,
}
this.stateManager.setApiConfiguration(updatedConfig)
await this.postStateToWebview()
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
}
}
// Read OpenRouter models from disk cache
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
@@ -846,9 +855,9 @@ export class Controller {
const vscodeTerminalExecutionMode = this.stateManager.getGlobalStateKey("vscodeTerminalExecutionMode")
const defaultTerminalProfile = this.stateManager.getGlobalSettingsKey("defaultTerminalProfile")
const isNewUser = this.stateManager.getGlobalStateKey("isNewUser")
const welcomeViewCompleted = Boolean(
this.stateManager.getGlobalStateKey("welcomeViewCompleted") || this.authService.getInfo()?.user?.uid,
)
// Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
const welcomeViewCompleted = !!this.stateManager.getGlobalStateKey("welcomeViewCompleted")
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt")
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
@@ -925,7 +934,7 @@ export class Controller {
vscodeTerminalExecutionMode: vscodeTerminalExecutionMode,
defaultTerminalProfile,
isNewUser,
welcomeViewCompleted: welcomeViewCompleted as boolean, // Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
welcomeViewCompleted,
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
@@ -954,6 +963,10 @@ export class Controller {
remoteConfigSettings: this.stateManager.getRemoteConfigSettings(),
lastDismissedCliBannerVersion,
subagentsEnabled,
nativeToolCallSetting: {
user: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
featureFlag: featureFlagsService.getNativeToolCallEnabled(),
},
}
}
@@ -11,17 +11,14 @@ import type { Controller } from "../index"
export async function refreshMcpMarketplace(controller: Controller, _request: EmptyRequest): Promise<McpMarketplaceCatalog> {
try {
// Call the RPC variant which returns the result directly
const catalog = await controller.silentlyRefreshMcpMarketplaceRPC()
const catalog = await controller.refreshMcpMarketplace(false /* sendCatalogEvent */)
if (catalog) {
// Types are structurally identical, use direct type assertion
return catalog as McpMarketplaceCatalog
}
// Return empty catalog if nothing was fetched
return McpMarketplaceCatalog.create({ items: [] })
} catch (error) {
console.error("Failed to refresh MCP marketplace:", error)
return McpMarketplaceCatalog.create({ items: [] })
}
// Return empty catalog if nothing was fetched
return { items: [] }
}
@@ -1,11 +1,11 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ModelInfo } from "@shared/api"
import type { ModelInfo } from "@shared/api"
import axios from "axios"
import cloneDeep from "clone-deep"
import fs from "fs/promises"
import path from "path"
import { CLAUDE_SONNET_1M_TIERS, openRouterClaudeSonnet41mModelId, openRouterClaudeSonnet451mModelId } from "@/shared/api"
import { Controller } from ".."
import type { Controller } from ".."
type OpenRouterSupportedParams =
| "frequency_penalty"
@@ -59,7 +59,7 @@ interface OpenRouterRawModelInfo {
input_cache_read: string
input_cache_write: string
} | null
thinking_config: any | null
thinking_config: Record<string, unknown> | null
supports_global_endpoint: boolean | null
tiers: any[] | null
supported_parameters?: OpenRouterSupportedParams[] | null
@@ -88,6 +88,7 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
for (const rawModel of rawModels as OpenRouterRawModelInfo[]) {
const supportThinking = rawModel.supported_parameters?.some((p) => p === "include_reasoning")
const modelInfo: ModelInfo = {
name: rawModel.name,
maxTokens: rawModel.top_provider?.max_completion_tokens ?? 0,
contextWindow: rawModel.context_length ?? 0,
supportsImages: rawModel.architecture?.modality?.includes("image") ?? false,
@@ -97,7 +98,7 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
cacheWritesPrice: parsePrice(rawModel.pricing?.input_cache_write),
cacheReadsPrice: parsePrice(rawModel.pricing?.input_cache_read),
description: rawModel.description ?? "",
thinkingConfig: supportThinking ? (rawModel.thinking_config ?? {}) : undefined,
thinkingConfig: (supportThinking && rawModel.thinking_config) || undefined,
supportsGlobalEndpoint: rawModel.supports_global_endpoint ?? undefined,
tiers: rawModel.tiers ?? undefined,
}
@@ -0,0 +1,79 @@
import { Empty } from "@shared/proto/cline/common"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { buildApiHandler } from "@/core/api"
import { ApiHandlerOptions, ApiHandlerSecrets, ApiProvider } from "@/shared/api"
import { UpdateApiConfigurationRequestNew } from "@/shared/proto/index.cline"
import type { Controller } from "../index"
/**
* Updates API configuration
* @param controller The controller instance
* @param request The update API configuration request
* @returns Empty response
*/
export async function updateApiConfiguration(controller: Controller, request: UpdateApiConfigurationRequestNew): Promise<Empty> {
try {
const { options: protoOptions, secrets: protoSecrets } = request
const secrets: Partial<ApiHandlerSecrets> = {}
if (protoSecrets) {
const filteredSecrets = Object.fromEntries(Object.entries(protoSecrets).filter(([_, value]) => value !== undefined))
Object.assign(secrets, filteredSecrets)
}
const options: Partial<ApiHandlerOptions> & { planModeApiProvider?: ApiProvider; actModeApiProvider?: ApiProvider } = {}
if (protoOptions) {
// Extract fields requiring conversion or special handling
const {
// Fields requiring enum conversion
planModeApiProvider,
actModeApiProvider,
// Fields requiring special handling
openAiHeaders,
...simpleOptions
} = protoOptions
// Batch update for simple pass-through fields
const filteredOptions = Object.fromEntries(Object.entries(simpleOptions).filter(([_, value]) => value !== undefined))
Object.assign(options, filteredOptions)
// Handle openAiHeaders (skip empty objects)
if (openAiHeaders && Object.keys(openAiHeaders).length > 0) {
options.openAiHeaders = openAiHeaders
}
// Convert proto ApiProvider enums to native string types
if (planModeApiProvider !== undefined) {
options.planModeApiProvider = convertProtoToApiProvider(planModeApiProvider)
}
if (actModeApiProvider !== undefined) {
options.actModeApiProvider = convertProtoToApiProvider(actModeApiProvider)
}
}
// Update storage using batch methods
if (Object.keys(secrets).length > 0) {
controller.stateManager.setSecretsBatch(secrets)
}
if (Object.keys(options).length > 0) {
controller.stateManager.setGlobalStateBatch(options)
}
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
// Combine secrets and options for the API handler
const apiConfigForHandler = { ...secrets, ...options, ulid: controller.task.ulid }
controller.task.api = buildApiHandler(apiConfigForHandler, currentMode)
}
// Post updated state to webview
await controller.postStateToWebview()
return Empty.create()
} catch (error) {
console.error(`Failed to update API configuration: ${error}`)
throw error
}
}
@@ -19,10 +19,7 @@ export async function updateAutoApprovalSettings(controller: Controller, request
const settings = {
...currentSettings,
...(request.version !== undefined && { version: request.version }),
...(request.enabled !== undefined && { enabled: request.enabled }),
...(request.maxRequests !== undefined && { maxRequests: request.maxRequests }),
...(request.enableNotifications !== undefined && { enableNotifications: request.enableNotifications }),
...(request.favorites && request.favorites.length > 0 && { favorites: request.favorites }),
actions: {
...currentSettings.actions,
...(request.actions
@@ -31,16 +28,6 @@ export async function updateAutoApprovalSettings(controller: Controller, request
},
}
if (controller.task) {
const maxRequestsChanged =
controller.stateManager.getGlobalSettingsKey("autoApprovalSettings").maxRequests !== settings.maxRequests
// Reset counter if max requests limit changed
if (maxRequestsChanged) {
controller.task.resetConsecutiveAutoApprovedRequestsCount()
}
}
controller.stateManager.setGlobalState("autoApprovalSettings", settings)
await controller.postStateToWebview()
+12 -1
View File
@@ -327,7 +327,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
if (request.hooksEnabled !== undefined) {
controller.stateManager.setGlobalState("hooksEnabled", !!request.hooksEnabled)
const isEnabled = !!request.hooksEnabled
// Platform validation: Only allow enabling hooks on macOS and Linux
if (isEnabled && process.platform === "win32") {
throw new Error("Hooks are not yet supported on Windows")
}
controller.stateManager.setGlobalState("hooksEnabled", isEnabled)
}
if (request.subagentsEnabled !== undefined) {
@@ -349,6 +356,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("subagentsEnabled", !!request.subagentsEnabled)
}
if (request.nativeToolCallEnabled !== undefined) {
controller.stateManager.setGlobalState("nativeToolCallEnabled", !!request.nativeToolCallEnabled)
}
// Post updated state to webview
await controller.postStateToWebview()
@@ -87,13 +87,9 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
const mergedSettings = {
...currentAutoApprovalSettings,
...(autoApprovalSettings.version !== undefined && { version: autoApprovalSettings.version }),
...(autoApprovalSettings.enabled !== undefined && { enabled: autoApprovalSettings.enabled }),
...(autoApprovalSettings.maxRequests !== undefined && { maxRequests: autoApprovalSettings.maxRequests }),
...(autoApprovalSettings.enableNotifications !== undefined && {
enableNotifications: autoApprovalSettings.enableNotifications,
}),
...(autoApprovalSettings.favorites &&
autoApprovalSettings.favorites.length > 0 && { favorites: autoApprovalSettings.favorites }),
actions: {
...currentAutoApprovalSettings.actions,
...(autoApprovalSettings.actions
@@ -212,7 +208,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
}
// Update default terminal profile (requires terminal manager updates and notifications)
if (defaultTerminalProfile !== undefined) {
if (defaultTerminalProfile !== undefined && defaultTerminalProfile !== "") {
const profileId = defaultTerminalProfile
// Update the terminal profile in the state
@@ -223,6 +219,11 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
// Update the terminal manager of the current task if it exists
if (controller.task) {
// Terminal manager must exist when task is active
if (!controller.task.terminalManager) {
throw new Error("Cannot update terminal profile: Terminal manager missing from active task")
}
// Call the updated setDefaultTerminalProfile method that returns closed terminal info
const result = controller.task.terminalManager.setDefaultTerminalProfile(profileId)
closedCount = result.closedCount
@@ -76,13 +76,9 @@ export async function updateTaskSettings(controller: Controller, request: Update
const mergedSettings = {
...currentAutoApprovalSettings,
...(autoApprovalSettings.version !== undefined && { version: autoApprovalSettings.version }),
...(autoApprovalSettings.enabled !== undefined && { enabled: autoApprovalSettings.enabled }),
...(autoApprovalSettings.maxRequests !== undefined && { maxRequests: autoApprovalSettings.maxRequests }),
...(autoApprovalSettings.enableNotifications !== undefined && {
enableNotifications: autoApprovalSettings.enableNotifications,
}),
...(autoApprovalSettings.favorites &&
autoApprovalSettings.favorites.length > 0 && { favorites: autoApprovalSettings.favorites }),
actions: {
...currentAutoApprovalSettings.actions,
...(autoApprovalSettings.actions
-4
View File
@@ -43,13 +43,9 @@ export async function newTask(controller: Controller, request: NewTaskRequest):
return {
...globalSettings,
...(incomingSettings.version !== undefined && { version: incomingSettings.version }),
...(incomingSettings.enabled !== undefined && { enabled: incomingSettings.enabled }),
...(incomingSettings.maxRequests !== undefined && { maxRequests: incomingSettings.maxRequests }),
...(incomingSettings.enableNotifications !== undefined && {
enableNotifications: incomingSettings.enableNotifications,
}),
...(incomingSettings.favorites &&
incomingSettings.favorites.length > 0 && { favorites: incomingSettings.favorites }),
actions: {
...globalSettings.actions,
...(incomingSettings.actions
+1 -1
View File
@@ -209,7 +209,7 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
}
// Silently refresh MCP marketplace catalog
controller.silentlyRefreshMcpMarketplace()
controller.refreshMcpMarketplace(true /* sendCatalogEvent */)
// Initialize telemetry service with user's current setting
controller.getStateToPostToWebview().then((state) => {
+292
View File
@@ -0,0 +1,292 @@
import { getAllHooksDirs } from "../storage/disk"
import { HookFactory, Hooks } from "./hook-factory"
type HookName = keyof Hooks
/**
* Cached hook discovery results
*/
interface HookCacheEntry {
scriptPaths: string[] // Paths to hook scripts for this hook name
timestamp: number // When this was last scanned
}
/**
* Generic disposable interface for resource cleanup
*/
interface Disposable {
dispose(): void
}
/**
* Generic file watcher interface
*/
interface FileWatcher extends Disposable {
onDidCreate(listener: () => void): void
onDidChange(listener: () => void): void
onDidDelete(listener: () => void): void
}
/**
* Generic context interface for managing subscriptions
*/
interface ExtensionContext {
subscriptions: Disposable[]
}
/**
* Singleton cache for hook script discovery with lazy file system watching.
*
* Features:
* - Lazy watcher initialization (only when directories are accessed)
* - Per-directory caching
* - Automatic invalidation on file changes
* - Graceful error handling
* - Optional debug logging
*/
export class HookDiscoveryCache {
private static instance: HookDiscoveryCache | null = null
// Cache: hookName -> discovered script paths
private cache = new Map<HookName, HookCacheEntry>()
// Watchers: directory path -> file watcher
private watchers = new Map<string, FileWatcher>()
// Directories we've tried to watch (even if watcher creation failed)
private watchedDirs = new Set<string>()
// Currently scanning (to prevent concurrent scans)
private scanning = new Set<HookName>()
// For disposal
private context: ExtensionContext | null = null
private createFileWatcher: ((dir: string) => FileWatcher | null) | null = null
private disposed = false
// Debug logging (enabled via DEBUG_HOOKS env var)
private debug = process.env.DEBUG_HOOKS === "true"
private constructor() {}
static getInstance(): HookDiscoveryCache {
if (!HookDiscoveryCache.instance) {
HookDiscoveryCache.instance = new HookDiscoveryCache()
}
return HookDiscoveryCache.instance
}
/**
* Initialize with extension context for proper cleanup
*/
initialize(
context: ExtensionContext,
createFileWatcher?: (dir: string) => FileWatcher | null,
onWorkspaceFoldersChanged?: (callback: () => void) => Disposable,
): void {
this.context = context
this.createFileWatcher = createFileWatcher || null
// Watch for workspace changes to invalidate cache (if callback provided)
if (onWorkspaceFoldersChanged) {
context.subscriptions.push(
onWorkspaceFoldersChanged(() => {
this.log("Workspace folders changed, invalidating cache")
this.invalidateAll()
}),
)
}
}
/**
* Get cached hook scripts or scan if not cached
*/
async get(hookName: HookName): Promise<string[]> {
this.log(`Getting hooks for ${hookName}`)
const cached = this.cache.get(hookName)
if (cached) {
this.log(`Cache hit for ${hookName}: ${cached.scriptPaths.length} scripts`)
return cached.scriptPaths
}
this.log(`Cache miss for ${hookName}, scanning...`)
return this.scan(hookName)
}
/**
* Scan for hook scripts and cache the result
*/
private async scan(hookName: HookName): Promise<string[]> {
// Prevent concurrent scans of the same hook
if (this.scanning.has(hookName)) {
this.log(`Already scanning ${hookName}, waiting...`)
await new Promise((resolve) => setTimeout(resolve, 50))
return this.get(hookName)
}
this.scanning.add(hookName)
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
}
// Scan each directory for this hook
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
const results = await Promise.all(scriptPromises)
const scripts = results.filter((path): path is string => path !== undefined)
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
timestamp: Date.now(),
})
return scripts
} catch (error) {
console.error(`Error scanning for ${hookName} hooks:`, error)
// Return empty array on error - don't break the whole system
return []
} finally {
this.scanning.delete(hookName)
}
}
/**
* Ensure a file watcher exists for the given directory
*/
private ensureWatcher(dir: string): void {
// Skip if already watching or tried to watch
if (this.watchedDirs.has(dir)) {
return
}
this.watchedDirs.add(dir)
if (!this.context) {
this.log(`No context available, skipping watcher for ${dir}`)
return
}
// If no watcher creation function provided, skip watching
if (!this.createFileWatcher) {
this.log(`No watcher creator available, skipping watcher for ${dir}`)
return
}
try {
// Create watcher using the provided function
const watcher = this.createFileWatcher(dir)
if (!watcher) {
this.log(`Watcher creation returned null for ${dir}`)
return
}
// Invalidate cache on any change
const invalidate = () => {
this.log(`File change detected in ${dir}, invalidating cache`)
this.invalidateDirectory(dir)
}
watcher.onDidCreate(invalidate)
watcher.onDidChange(invalidate)
watcher.onDidDelete(invalidate)
// Add to context subscriptions for proper cleanup
if (this.context) {
this.context.subscriptions.push(watcher)
}
this.watchers.set(dir, watcher)
this.log(`Created watcher for ${dir}`)
} catch (error) {
// Log but don't fail - directory might not exist yet
this.log(`Failed to create watcher for ${dir}: ${error}`)
}
}
/**
* Invalidate all cached hooks that have scripts in this directory
*/
private invalidateDirectory(dir: string): void {
let invalidated = 0
for (const [hookName, entry] of this.cache) {
if (entry.scriptPaths.some((scriptPath) => scriptPath.startsWith(dir))) {
this.cache.delete(hookName)
invalidated++
}
}
this.log(`Invalidated ${invalidated} hooks for directory ${dir}`)
}
/**
* Invalidate entire cache
*/
invalidateAll(): void {
const size = this.cache.size
this.cache.clear()
this.log(`Invalidated entire cache (${size} entries)`)
}
/**
* Get cache statistics (for debugging/monitoring)
*/
getStats() {
return {
cacheSize: this.cache.size,
watcherCount: this.watchers.size,
watchedDirs: this.watchedDirs.size,
}
}
/**
* Log debug message if debug mode is enabled
*/
private log(message: string): void {
if (this.debug) {
console.log(`[HookCache] ${message}`)
}
}
/**
* Clean up resources
*/
dispose(): void {
if (this.disposed) {
return
}
this.log(`Disposing cache (${this.watchers.size} watchers)`)
for (const watcher of this.watchers.values()) {
watcher.dispose()
}
this.watchers.clear()
this.watchedDirs.clear()
this.cache.clear()
this.disposed = true
}
/**
* Reset singleton instance (for testing)
*/
static resetForTesting(): void {
if (HookDiscoveryCache.instance) {
HookDiscoveryCache.instance.dispose()
HookDiscoveryCache.instance = null
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Types of errors that can occur during hook execution
*/
export enum HookErrorType {
/** Hook execution exceeded the timeout limit */
TIMEOUT = "timeout",
/** Hook output failed JSON validation */
VALIDATION = "validation",
/** Hook script execution failed (non-zero exit, crash, etc.) */
EXECUTION = "execution",
/** Hook was cancelled by user */
CANCELLATION = "cancellation",
}
/**
* Structured error information for hook failures.
* Provides both user-friendly messages and technical details.
*/
export interface HookErrorInfo {
/** Type of error that occurred */
type: HookErrorType
/** User-friendly error message */
message: string
/** Technical details for debugging (optional, shown in expansion) */
details?: string
/** Path to the hook script that failed */
scriptPath?: string
/** Exit code if available */
exitCode?: number
/** Stderr output if available */
stderr?: string
}
/**
* Error thrown during hook execution with structured information.
* This allows proper error handling without string parsing.
*/
export class HookExecutionError extends Error {
constructor(
public readonly errorInfo: HookErrorInfo,
message?: string,
) {
super(message || errorInfo.message)
this.name = "HookExecutionError"
}
/**
* Check if an error is a HookExecutionError
*/
static isHookError(error: unknown): error is HookExecutionError {
return error instanceof HookExecutionError
}
/**
* Create a timeout error
*/
static timeout(scriptPath: string, timeoutMs: number, stderr?: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.TIMEOUT,
message: `${hookPrefix} execution timed out after ${timeoutMs}ms`,
details:
`The hook took longer than ${timeoutMs / 1000} seconds to complete.\n\n` +
`Common causes:\n` +
`• Infinite loop in hook script\n` +
`• Network request hanging without timeout\n` +
`• File I/O operation stuck\n` +
`• Heavy computation taking too long\n\n` +
`Recommendations:\n` +
`1. Check your hook script for infinite loops\n` +
`2. Add timeouts to network requests\n` +
`3. Use background jobs for long operations\n` +
`4. Test your hook script independently`,
scriptPath,
stderr,
})
}
/**
* Create a validation error
*/
static validation(validationError: string, scriptPath: string, stdoutPreview: string): HookExecutionError {
return new HookExecutionError({
type: HookErrorType.VALIDATION,
message: "Hook output validation failed",
details: `${validationError}\n\nOutput preview:\n${stdoutPreview}`,
scriptPath,
})
}
/**
* Create an execution error
*/
static execution(scriptPath: string, exitCode: number, stderr?: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook script"
const message = `${hookPrefix} exited with code ${exitCode}`
return new HookExecutionError(
{
type: HookErrorType.EXECUTION,
message,
details: stderr ? `stderr:\n${stderr}` : undefined,
scriptPath,
exitCode,
stderr,
},
message,
)
}
/**
* Create a cancellation error
*/
static cancellation(scriptPath: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.CANCELLATION,
message: `${hookPrefix} execution was cancelled`,
details: "The hook was cancelled by the user before completion",
scriptPath,
exitCode: 130, // Standard SIGINT exit code
})
}
}
+374
View File
@@ -0,0 +1,374 @@
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import { HookProcessRegistry } from "./HookProcessRegistry"
// Maximum total output size (stdout + stderr combined)
const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024 // 1MB
/**
* HookProcess manages the execution of a hook script with streaming output capabilities.
* Similar to StandaloneTerminalProcess but specialized for hook execution.
*
* Key features:
* - Real-time stdout/stderr streaming via line events
* - Separate handling of visual output vs. JSON response
* - 30-second execution timeout
* - 1MB output size limit (prevents memory issues)
* - Process lifecycle management with abort support
*/
export class HookProcess extends EventEmitter {
private childProcess: ChildProcess | null = null
private buffer = ""
private fullOutput = ""
private lastRetrievedIndex = 0
private exitCode: number | null = null
private isCompleted = false
private timeoutHandle: NodeJS.Timeout | null = null // 30-second execution timeout
// Separate buffers for stdout and stderr
private stdoutBuffer = ""
private stderrBuffer = ""
// Output size tracking
private stdoutSize = 0
private stderrSize = 0
private outputTruncated = false
// Track registration state to prevent leaks and ensure cleanup
private isRegistered = false
constructor(
private readonly scriptPath: string,
private readonly timeoutMs: number = 30000,
private readonly abortSignal?: AbortSignal,
) {
super()
}
/**
* Execute the hook script with the given JSON input
* @param inputJson The JSON string to pass to the hook via stdin
*/
async run(inputJson: string): Promise<void> {
// Wrap in try/finally to guarantee cleanup even if errors occur
try {
return await new Promise((resolve, reject) => {
// Register this process for tracking
HookProcessRegistry.register(this)
this.isRegistered = true
// Check if already aborted
if (this.abortSignal?.aborted) {
this.safeUnregister()
reject(new Error("Hook execution cancelled"))
return
}
// Set up abort handler
const abortHandler = () => {
if (this.childProcess && !this.isCompleted) {
this.isCompleted = true // Mark as completed immediately
// Remove abort listener immediately to prevent double-rejection
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
// Clean up execution timeout timer
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Unregister from active processes
this.safeUnregister()
// Kill the process (async, fire-and-forget)
if (this.childProcess.pid) {
this.childProcess.kill("SIGTERM")
}
// Reject immediately - don't wait for process to die
reject(new Error("Hook execution cancelled by user"))
}
}
if (this.abortSignal) {
this.abortSignal.addEventListener("abort", abortHandler, { once: true })
}
// Spawn the hook process through shell on all platforms
// This is the git-style approach: the shell interprets the shebang line
// and executes the appropriate interpreter (bash, node, python, etc.)
// On Unix: detached=true creates a process group, allowing us to kill all children
this.childProcess = spawn(this.scriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: true, // Use shell on all platforms for shebang interpretation
detached: process.platform !== "win32", // Create process group on Unix
})
let didEmitEmptyLine = false
// Set up timeout
this.timeoutHandle = setTimeout(() => {
if (this.childProcess && !this.isCompleted) {
this.childProcess.kill("SIGTERM")
reject(
new Error(
`Hook execution timed out after ${this.timeoutMs}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
),
)
}
}, this.timeoutMs)
// Handle stdout
this.childProcess.stdout?.on("data", (data) => {
const output = data.toString()
this.stdoutBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stdout")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stdout") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle stderr
this.childProcess.stderr?.on("data", (data) => {
const output = data.toString()
this.stderrBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stderr")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stderr") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle process completion
this.childProcess.on("close", (code, signal) => {
this.exitCode = code
this.isCompleted = true
this.emitRemainingBuffer()
// Unregister from active processes
this.safeUnregister()
// Clear execution timeout timer
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("completed", code, signal)
if (code === 0) {
resolve()
} else {
reject(new Error(`Hook exited with code ${code}${signal ? `, signal ${signal}` : ""}`))
}
})
// Handle process errors
this.childProcess.on("error", (error) => {
// Unregister from active processes
this.safeUnregister()
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("error", error)
reject(error)
})
// Send input to the process
try {
this.childProcess.stdin?.write(inputJson)
this.childProcess.stdin?.end()
} catch (error) {
reject(new Error(`Failed to write input to hook: ${error}`))
}
})
} finally {
// Guaranteed cleanup even if process setup fails or throws
this.safeUnregister()
}
}
/**
* Safely unregister from the process registry.
* This is idempotent and prevents double-unregistration issues.
*/
private safeUnregister(): void {
if (this.isRegistered) {
HookProcessRegistry.unregister(this)
this.isRegistered = false
}
}
/**
* Handle output data and emit line events.
* Enforces 1MB total output limit to prevent memory issues.
*/
private handleOutput(data: string, _didEmitEmptyLine: boolean, stream: "stdout" | "stderr"): void {
// Check output size limit
const dataSize = Buffer.byteLength(data)
const currentTotalSize = this.stdoutSize + this.stderrSize
if (currentTotalSize + dataSize > MAX_HOOK_OUTPUT_SIZE) {
if (!this.outputTruncated) {
this.outputTruncated = true
const truncationMsg = "\n\n[Output truncated: exceeded 1MB limit]"
this.emit("line", truncationMsg, stream)
console.warn(`[HookProcess] Output exceeded ${MAX_HOOK_OUTPUT_SIZE} bytes, truncating`)
}
return // Drop further output
}
// Track size by stream
if (stream === "stdout") {
this.stdoutSize += dataSize
} else {
this.stderrSize += dataSize
}
// Store full output
this.fullOutput += data
// Emit lines immediately
this.emitLines(data, stream)
}
/**
* Emit complete lines from buffered output
*/
private emitLines(chunk: string, stream: "stdout" | "stderr"): void {
this.buffer += chunk
let lineEndIndex
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, lineEndIndex).trimEnd()
this.emit("line", line, stream)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
/**
* Emit any remaining buffered output when process completes
*/
private emitRemainingBuffer(): void {
if (this.buffer) {
const remainingBuffer = this.buffer.trimEnd()
if (remainingBuffer) {
// Determine which stream this came from based on content
// This is a fallback; in practice, line events should capture most output
this.emit("line", remainingBuffer, "stdout")
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
/**
* Get unretrieved output (for compatibility with terminal process interface)
*/
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return unretrieved.trimEnd()
}
/**
* Get the complete stdout buffer (for JSON parsing)
*/
getStdout(): string {
return this.stdoutBuffer
}
/**
* Get the complete stderr buffer (for error reporting)
*/
getStderr(): string {
return this.stderrBuffer
}
/**
* Get the exit code
*/
getExitCode(): number | null {
return this.exitCode
}
/**
* Check if process has completed
*/
hasCompleted(): boolean {
return this.isCompleted
}
/**
* Terminate the process and its entire process tree.
* Uses process groups on Unix to kill child processes.
* Implements graceful shutdown with 2-second timeout before force kill.
*/
async terminate(): Promise<void> {
if (!this.childProcess || this.isCompleted) {
// Still ensure unregistration even if process already completed
this.safeUnregister()
return
}
const pid = this.childProcess.pid
if (!pid) {
return
}
try {
// On Unix, kill process group (negative PID kills all children)
// On Windows, just kill the process (tree-kill would be better but adds dependency)
if (process.platform !== "win32") {
// Kill process group with SIGTERM for graceful shutdown
process.kill(-pid, "SIGTERM")
} else {
// On Windows, just kill the process
this.childProcess.kill("SIGTERM")
}
// Wait up to 2 seconds for graceful shutdown
const gracefulTimeout = new Promise((resolve) => setTimeout(resolve, 2000))
const processExit = new Promise((resolve) => {
this.childProcess?.once("exit", resolve)
})
await Promise.race([processExit, gracefulTimeout])
// Force kill if still running
if (!this.isCompleted) {
if (process.platform !== "win32") {
process.kill(-pid, "SIGKILL")
} else {
this.childProcess?.kill("SIGKILL")
}
}
} catch (error) {
// Process might already be dead, which is fine
console.debug(`[HookProcess] Error during termination: ${error}`)
} finally {
// Clear timeout regardless
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Ensure unregistration even if termination fails
this.safeUnregister()
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { HookProcess } from "./HookProcess"
/**
* Global registry for tracking active hook processes.
*
* Purpose:
* - Prevents zombie processes by tracking all running hooks
* - Enables cleanup on extension deactivation
* - Provides visibility into active hook executions
*
* Usage:
* - HookProcess automatically registers/unregisters itself
* - Extension deactivation calls terminateAll()
* - Can query active count for monitoring/debugging
*/
export class HookProcessRegistry {
private static activeProcesses = new Set<HookProcess>()
/**
* Register a hook process as active.
* Called by HookProcess when execution starts.
*/
static register(process: HookProcess): void {
HookProcessRegistry.activeProcesses.add(process)
}
/**
* Unregister a hook process (completed or failed).
* Called by HookProcess when execution ends.
*/
static unregister(process: HookProcess): void {
HookProcessRegistry.activeProcesses.delete(process)
}
/**
* Terminate all active hook processes.
* Called during extension deactivation to prevent zombie processes.
*/
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
}
/**
* Get the number of currently active hook processes.
* Useful for monitoring and debugging.
*/
static getActiveCount(): number {
return HookProcessRegistry.activeProcesses.size
}
/**
* Clear the registry (for testing only).
* @internal
*/
static resetForTesting(): void {
HookProcessRegistry.activeProcesses.clear()
}
}
+15 -15
View File
@@ -37,7 +37,7 @@ it("should work with real hook", async () => {
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
```
@@ -50,15 +50,15 @@ For more control, you can also manually copy fixture files.
### PreToolUse Hooks
#### `hooks/pretooluse/success`
- **Returns**: `{ shouldContinue: true, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing happy path scenarios
#### `hooks/pretooluse/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Use for**: Testing tool execution blocking
#### `hooks/pretooluse/context-injection`
- **Returns**: `{ shouldContinue: true, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Use for**: Testing context injection with type prefixes
- **Note**: Dynamically includes tool name from input
@@ -69,7 +69,7 @@ For more control, you can also manually copy fixture files.
### PostToolUse Hooks
#### `hooks/posttooluse/success`
- **Returns**: `{ shouldContinue: true, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing PostToolUse execution
#### `hooks/posttooluse/error`
@@ -79,34 +79,34 @@ For more control, you can also manually copy fixture files.
### UserPromptSubmit Hooks
#### `hooks/userpromptsubmit/success`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt approved", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt approved", errorMessage: "" }`
- **Use for**: Testing successful prompt submission
#### `hooks/userpromptsubmit/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Use for**: Testing prompt submission blocking
#### `hooks/userpromptsubmit/context-injection`
- **Returns**: `{ shouldContinue: true, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Use for**: Testing context injection into task request
#### `hooks/userpromptsubmit/multiline`
- **Returns**: `{ shouldContinue: true, contextModification: "Line count: N", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Line count: N", errorMessage: "" }`
- **Use for**: Testing multiline prompt handling
- **Note**: Dynamically counts newlines in the prompt
#### `hooks/userpromptsubmit/large-prompt`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt size: N", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt size: N", errorMessage: "" }`
- **Use for**: Testing large prompt handling
- **Note**: Dynamically reports prompt character count
#### `hooks/userpromptsubmit/special-chars`
- **Returns**: `{ shouldContinue: true, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Use for**: Testing special character preservation
- **Note**: Checks for @, #, and $ characters
#### `hooks/userpromptsubmit/empty-prompt`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Use for**: Testing empty prompt handling
- **Note**: Safely handles undefined or empty prompts
@@ -121,11 +121,11 @@ For more control, you can also manually copy fixture files.
### TaskStart Hooks
#### `hooks/taskstart/success`
- **Returns**: `{ shouldContinue: true, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Use for**: Testing TaskStart hook success path, allowing task to proceed
#### `hooks/taskstart/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Use for**: Testing task blocking at start (e.g., policy enforcement)
#### `hooks/taskstart/error`
@@ -158,7 +158,7 @@ cat > src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreTool
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "My custom context",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PostToolUse hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Tool execution blocked by hook"
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const toolName = input.preToolUse?.toolName || 'unknown';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `WORKSPACE_RULES: Tool ${toolName} requires review`,
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PreToolUse hook executed successfully",
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "some error happened"
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
errorMessage: "some error happened"
}));
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume?.previousState?.conversationHistoryDeleted === 'true';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: deleted
? "TASK_CONTEXT: Some conversation history was truncated due to context window limits"
: "",
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const taskId = input.taskResume?.taskMetadata?.taskId || 'unknown';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `WORKSPACE_RULES: Task ${taskId} resumed - review previous context`,
errorMessage: ""
}));
@@ -5,7 +5,7 @@ const now = Date.now();
const hoursAgo = Math.floor((now - lastMessageTs) / 3600000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hoursAgo >= 1
? `TASK_CONTEXT: Task was paused ${hoursAgo} hours ago - you may need to re-familiarize yourself with the context`
: "",
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const messageCount = parseInt(input.taskResume?.previousState?.messageCount || '0');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `TASK_CONTEXT: Resuming task with ${messageCount} previous messages`,
errorMessage: ""
}));
@@ -5,7 +5,7 @@ const now = Date.now();
const minutesAgo = Math.floor((now - lastMessageTs) / 60000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: minutesAgo < 5
? "TASK_CONTEXT: Recently paused task - context is still fresh"
: "",
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskResume hook executed successfully",
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Prompt violates policy"
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "CONTEXT_INJECTION: User is in plan mode",
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptLength = typeof input.userPromptSubmit.prompt === 'string' ? input.userPromptSubmit.prompt.length : 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt length: " + promptLength,
errorMessage: ""
}));

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