Compare commits

..
Author SHA1 Message Date
abeatrix 143e4fd94f add back tasksay 2026-02-13 18:10:47 -08:00
Saoud Rizwan 897e842eb4 fix(task): ignore interleaved reasoning UI after text starts (#9298) 2026-02-13 00:25:08 -08:00
Saoud Rizwan 0389d4de07 Revert "fix(task): prevent duplicate streamed text rows after completion (#9235)" (#9297)
This reverts commit b514f18e4f.
2026-02-13 00:20:33 -08:00
Saoud Rizwan d99eec15d8 fix(minimax): emit single reasoning chunk on thinking start (#9290) 2026-02-12 23:46:53 -08:00
Ara 98ed009e69 fix: add missing name fields to free featured models and improve type safety (#9291)
- Add `name` property to minimax, kat-coder-pro, and trinity-large-preview
  models that were previously missing it
- Move type annotation from `as FeaturedModel[]` casts to the variable
  declaration for proper type checking at assignment time
- Add test to verify all featured models include a display name
2026-02-12 23:18:51 -08:00
Saoud Rizwan 8fb7b94297 Revert "Jose/thinking and flicker fix (#9148)" (#9292)
This reverts commit d8397c71b2.
2026-02-12 22:54:07 -08:00
Jose R. Perezandcline-test d8397c71b2 Jose/thinking and flicker fix (#9148)
* feat: persistant thinking loader at bottom of stream during any cline activity with no visual feedback

* feat: thinking and flicker fix

* refactor: remove multi-layer throttling, use single canonical throttle point

Collapse 4 independent throttle layers (up to ~500ms added latency) into
a single 50ms debounce in subscribeToPartialMessage. Replace index-based
partial message tracking with stable ts-based tracking. Remove webview
queue/timer/flush system in favor of cheap equality dedup.

* fix: Add production-grade improvements to flicker fix

- Fix global mutable state bug in subscribeToPartialMessage.ts
- Add comprehensive test coverage (51 tests passing)
- Rename ThrottledApiHandler → SanitizedApiHandler
- Remove incomplete OpenAI reasoning effort code

* Fix test failures

* PR changes as per Greptile feedback

* Fixes as per feedback during PR review

---------

Co-authored-by: cline-test <132302818+candieduniverse@users.noreply.github.com>
2026-02-12 22:28:46 -08:00
3899469d76 feat(evals): comprehensive LLM evaluation framework with CI (#8909)
* chore(evals): reorganize eval structure with purpose-based naming

- Move evals/diff-edits/ → evals/benchmarks/tool-precision/replace-in-file/
- Move evals/cli/ → evals/legacy/cli/ (preserve for reference)
- Create evals/benchmarks/real-world/ directory
- Create evals/benchmarks/coding-exercises/cases/ directory
- Create evals/analysis/ directory structure

Note: No repositories/exercism/ directory found to move.
Skipping pre-commit hook as this is a reorganization of legacy code.

* chore(evals): remove legacy evaluation code

Remove abandoned evaluation infrastructure:
- evals/benchmarks/tool-precision/ - Dashboard, database, diff implementations
- evals/legacy/cli/ - Old HTTP-based eval harness

This functionality is superseded by the new testing pyramid:
- Tool precision is now covered by contract tests in src/core/
- E2E testing uses the cline-bench framework

* feat(evals): add analysis framework for benchmark results

Add shared infrastructure for analyzing evaluation results:
- TypeScript schemas for Harbor and analysis output formats
- Parsers for Harbor, tool-precision, and exercise results
- Failure classifier with pattern matching (cline-failures.yaml)
- Metrics calculator (pass@k, consistency, latency)
- JSON and Markdown reporters
- CLI with analyze and compare commands
- Unit tests for classifier and metrics

This framework is used by both smoke tests and E2E evaluations
to provide consistent metrics and failure categorization.

* feat(evals): add contract tests for API transforms

Add tests to verify API response transformations preserve data correctly:
- thinking-traces.test.ts: Tests thinking block extraction and formatting
- tool-parsing.test.ts: Tests tool call parsing across providers

These contract tests catch regressions when modifying transform logic,
ensuring API responses are correctly processed regardless of provider.
Run with: npm run test:unit

* feat(evals): add provider smoke tests with pass@k metrics

Add lightweight smoke tests that validate provider integrations work
correctly with real LLM calls:

Scenarios (5 curated tests):
- 01-create-file: Tests write_to_file tool
- 02-edit-file: Tests replace_in_file tool
- 03-read-summarize: Tests read_file tool
- 04-multi-file: Tests multi-file edits
- 05-typescript-function: Tests code generation

Features:
- CLI-based runner using the cline CLI
- Multiple trials per scenario for reliability testing
- pass@k metrics (solution finding) and pass^k (consistency)
- Results storage with logs and latest symlink
- Adaptive metric display based on trial count

Run locally: npm run eval:smoke

* feat(evals): add E2E runner with cline-bench

Add end-to-end testing infrastructure using real-world production bugs:

- cline-bench submodule: 12 curated tasks from actual Cline sessions
  - Complex multi-file refactors
  - Bug fixes requiring deep context understanding
  - Cross-language/framework tasks

- E2E runner (evals/e2e/run-cline-bench.ts):
  - Integrates with Harbor for containerized execution
  - Supports single task or full suite runs
  - Pass/fail metrics with detailed logging

Run: npm run eval:e2e -- --task discord-trivia

Note: E2E tests require Docker and are intended for weekly/release
testing, not per-commit CI (each task takes 20-30 minutes).

* feat(evals): add CI workflow and documentation

CI Workflow (.github/workflows/cline-evals-regression.yml):
- Triggers on push/PR to main (src/core, src/shared, proto, evals paths)
- Builds CLI from source with Go 1.24
- Runs 5 smoke test scenarios in parallel
- Uses Anthropic API with claude-sonnet-4
- Uploads results as artifacts with summary

npm scripts:
- eval:smoke - Run smoke tests locally (builds CLI first)
- eval:smoke:run - Run smoke tests (assumes CLI is built)
- eval:e2e - Run cline-bench E2E tests

Documentation:
- ARCHITECTURE.md: Testing pyramid overview with ASCII diagrams
- EVALS_OVERVIEW.md: High-level introduction for mixed audience
- Updated README.md with current structure and usage

* chore(evals): restore tool-precision as deprecated legacy

Restore the diff edit evaluation framework for @ara's use case.
Marked as DEPRECATED - target removal Q2 2026 when cline-bench
is fully operational for model comparison.

Note: Skipping linter as this is legacy code being preserved as-is.

* feat(evals): add per-scenario model support and apply_patch test

Also honor --model overrides and prune stubs.

* chore(evals): update smoke tests for CLI 2.0

- Remove Go setup from workflow (CLI 2.0 is TypeScript)
- Build CLI via `npm run build` in cli/ directory
- Install CLI via `npm link` to test built code from PR
- Update CLI flags: -y -m model --json (remove -o and -s)
- Provider configured via `cline auth` before tests run

* chore(evals): add auth check and CLI 2.0 flags

- Add configureAuth() that runs cline auth non-interactively
- Require CLINE_API_KEY env var or use existing ~/.cline auth
- Add --config flag to use shared config directory
- Add -t timeout flag to CLI args
- Reduce scenario timeout to 30s for faster iteration
- Remove --json flag (CLI doesn't output errors in json mode)

* feat(evals): add parallel execution and move workspaces to results

- Add --parallel flag to run scenarios concurrently (default limit: 4)
- Move trial workspaces from scenarios/ to results/ directory
- Workspaces now cleaned up with `npm run eval:smoke:clean`
- Keeps scenarios/ clean and version-controllable

* ci: add smoke tests workflow with parallel execution

- Single job runs all 7 scenarios in parallel using test runner's --parallel flag
- Builds CLI in-job (no artifact passing needed)
- Outputs summary.md to GitHub step summary
- Syncs package-lock.json for tiktoken/commander deps

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(evals): increase 01-create-file timeout to 120s

The 30s timeout was too short for reliable execution.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: restore changesets deleted during rebase

These changesets belong to the already-merged CLI fix (#9073)
and should not be deleted by this branch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(evals): remove unused dependencies from package.json

Drop execa, node-fetch, ora, sqlite, uuid, yargs and their types.
These were leftovers from the old CLI-based eval runner. The smoke
tests use Node builtins and the tool-precision benchmark only needs
axios, better-sqlite3, chalk, commander, dotenv, tiktoken.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add TypeScript build info files to .gitignore

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-02-12 22:23:35 -06:00
Robin Newhouse e85332319e feat: add .agents/skills directory support for skill discovery (#9074)
* feat: add .agents/skills directory support for skill discovery

Add compatibility for the standardized .agents/skills directory pattern,
both globally (~/.agents/skills) and locally (.agents/skills in workspace).

* feat: make .agents/skills the default for new skills

New skills are now created in .agents/skills (local) and ~/.agents/skills
(global) by default. These directories also have highest priority in
skill discovery, overriding skills with the same name from other locations.

* docs: update skills documentation for .agents/skills directories

* refactor skills directory helpers
2026-02-12 22:08:30 -06:00
1a29d428ae refactor: BannerService initialization and cache management (#8969)
* increases banner cache duration to 24 hours so we make one api calls per day per user; implements a circuit breaker that stops retrying after 3 consecutive failures

* add new tests

* Clear banner cache when auth status changes

* revert 5898bc6e0e

* Fixing circuit breaker

* fix: reset circuitBreakerOpenedAt on failed half-open recovery

Previously, circuitBreakerOpenedAt was only set when consecutiveFailures
reached exactly MAX_CONSECUTIVE_FAILURES. This meant that after a failed
half-open recovery attempt, the timestamp wasn't updated, causing the
circuit breaker to immediately enter half-open state again on the next call.

Now circuitBreakerOpenedAt is updated on every failure once the circuit
breaker is tripped, ensuring proper timeout between recovery attempts.

* refactor: BannerService initialization and cache management

- Move BannerService initialization from common.ts to AuthService (which is initialized in controller)
- Re-initialize BannerService after auth state updates to ensure user context
- Add HostRegistryInfo to centralize host/platform information collection
- Improve rate limiting with exponential backoff (5min → 15min → 30min)
- Refactor error handling to better distinguish between rate limits and server errors
- Remove temporary disabled banner fetching comments

This change ensures banners are only fetched when user authentication is
available and implements more robust rate limiting to prevent API hammering.
The banner service now properly tracks user context and respects server
rate limits with progressive backoff delays.

* refactor(banner): simplify banner service initialization and usage

- Remove `getBanners()` wrapper method from Controller class
- Call `BannerService.get().getActiveBanners()` directly in Controller
- Change `BannerService.initialize()` to synchronous, returns instance immediately
- Make banner fetching non-blocking by moving to background
- Remove unused `BannerCardData` import from Controller
- Update tests to handle asynchronous background fetching with timeouts
- Clean up AuthService banner service initialization comment

This change simplifies the banner service API by removing unnecessary abstraction layers and making initialization non-blocking. The service now fetches banners in the background rather than blocking on initialization, improving application startup performance.

* clean up

* apply feedback

* un-skip unit test

* mock

* mock env

* clean up and add debounce fetch

* log fetch time

* revert

* feature flag: remote-banners

* fix loop in authService on auth update

Co-authored-by: Tomás Barreiro <BarreiroT@users.noreply.github.com>

* Fix tests

* small fixes

* use .? for banner

* moves initializeDistinctId to StateManager

* initializeDistinctId

* use v2 endpoint

---------

Co-authored-by: Zhongying Qiao <cryptoque@users.noreply.github.com>
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
Co-authored-by: Tomás Barreiro <BarreiroT@users.noreply.github.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-02-12 18:28:12 -08:00
Saoud Rizwan 92cf03e42c feat(subagents): simplify research output guidance and command workflow (#9284) 2026-02-12 16:00:02 -08:00
Beeandgreptile-apps[bot] 3ea393a5e9 fix: openai native provider token usage mapping (#9272)
* fix: openai native provider token usage mapping

- Add `store` parameter support to OpenAI native provider options to allow persisting completions.
- Fix incorrect mapping of `cached_tokens` and `reasoning_tokens` in usage statistics.
- Include `thoughtsTokenCount` in the final usage report to track reasoning model performance and costs.

* Update src/core/api/providers/openai-native.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-12 14:26:35 -08:00
9829e7d49e restore yolo mode to what it was before cline cli started (#9205)
Apply suggestions from code review

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-12 11:10:24 -08:00
MaxandMax Paulus 🥪 56de96e5ff fix oca auth (#9145)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-12 10:20:28 -08:00
github-actions[bot] ecde79cf08 v3.59.0 Release Notes (#9263)
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-12 10:20:19 -08:00
Bee ff36cbcb87 feat: implement response chaining for Responses API (#9270)
* feat: implement response chaining for Responses API

Implement response chaining by tracking and passing previous_response_id
to continue conversations from the last assistant message. This enables
the Responses API to maintain context across multiple turns.

Key changes:
- Search backwards through messages to find last assistant message with ID
- Only send new messages after the chained response
- Track function call metadata (call_id, name, id) across chunks
- Include call_id in tool_call events for proper correlation
- Clean up debug logging and remove commented code
- Remove redundant "Ran out of tokens" log message

This improves conversation continuity and ensures function calls are
properly tracked with their associated IDs throughout the streaming
response lifecycle.

* clean up

* update oca

* codex
2026-02-12 10:01:34 -08:00
shey-cline 5a75f08118 Prevent Parent Container Scrolling In Dropdowns (#9146)
* init

* missed some dropdowns & make scroll behavior work for scrolling nested elements

* add changeset

* add combobox roles
2026-02-12 10:00:40 -08:00
shey-cline 120754c2fe Allow Custom AWS Regions in Bedrock (Extension) (#9104)
* init

* changeset

* addressed comments – add onBlur, aria attributes and redundant useMemo
2026-02-12 09:57:04 -08:00
Saoud Rizwan 5d048d09f8 fix(subagents): retry initial stream bootstrap failures (#9264)
* fix(subagents): retry initial stream bootstrap failures

* fix(subagents): align initial retry classification with main loop

* fix(subagents): compact context on window limit during startup

* fix(subagents): proactively compact context at token thresholds

* feat(subagents): optimize file reads before context truncation
2026-02-12 06:34:43 -08:00
Saoud Rizwan 36580ce086 chore(codex): update environment to use launch script and simplify reinstall
Point the VS Code action at the new run-extension-host.sh script and
drop the git checkout of lock files from the reinstall action.
2026-02-12 05:53:25 -08:00
Saoud Rizwan c584bf4185 feat(dev): add tmux-based extension host launch script
Replaces the inline VS Code launch command with a proper dev script that:
- Builds protos and webview upfront
- Runs esbuild, tsc, and webview watchers in parallel tmux panes
- Waits for dist/extension.js before launching the extension host
- Cleans up all processes and closes the dev window on Ctrl+C
2026-02-12 05:53:18 -08:00
Saoud Rizwan 8133babf41 fix(chat): keep focus chain placeholder visible to prevent layout jump (#9266)
* fix(webview): stabilize focus chain header space and placeholder

* fix(chat): add follow-up bottom scroll to avoid short scroll

* style(chat): refine markdown spacing and tool group summary tone

* fix(chat): retry auto-scroll at 40ms and 70ms

* fix(chat): keep focus chain placeholder visible until checklist exists
2026-02-12 03:50:01 -08:00
Saoud Rizwan 741f524da7 chore(deps): upgrade openai sdk to 6.21.0 for xhigh reasoning (#9267) 2026-02-12 03:48:13 -08:00
Robin Newhouse d3918dd7df fix(task): canonicalize attempt_completion result parameter (#9262) 2026-02-12 00:37:27 -06:00
alex-lum 024bb65443 Add organization attributes to telemetry metrics (#9242) 2026-02-11 16:51:24 -08:00
58ebbdbf80 Changeset version bump (#9252)
* changeset version bump

* Updating CHANGELOG.md format

* changeset version bump

* Updating CHANGELOG.md format

* Eve manually updating the banner and the release version

* Manually update the changelog

* Fix GLM 5 model ID in banner

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: cline-test <132302818+candieduniverse@users.noreply.github.com>
2026-02-11 16:30:14 -08:00
Juan Pablo Flores cee74d2f3a docs: add subagents feature documentation (#9258)
* docs: add subagents feature documentation

Add new documentation page covering the Subagents feature, including
how it works, enabling/configuring, auto-approve behavior, available
tools, and usage guidance. Register the page in docs.json sidebar nav.

* docs: remove hardcoded subagent limit from subagents page

Remove references to 'up to five' subagents, as the limit is no longer
fixed. Updates both the intro paragraph and the How It Works section.
2026-02-11 15:54:57 -08:00
Ara a6f3b9f856 Revert "fix MCP OAuth: add missing scope parameter (#9117)" (#9256)
This reverts commit 401358374f.
2026-02-11 15:07:34 -08:00
Ara 0e524ffc3a feat(zai): add glm-5 pricing and make it default (#9254)
* feat(zai): add glm-5 pricing and make it default

* fix(zai,qwen): fallback model id when apiModelId is invalid
2026-02-11 14:05:50 -08:00
Ara 95ca14fa2a Fixing changeset files (#9251) 2026-02-11 12:38:31 -08:00
MaxandMax Paulus 🥪 a6c57a4ce5 print task id in headless modes (#9229)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-11 12:21:02 -08:00
Tomás Barreiro 9470cf19cd Add headers to the Remotely Configured MCP server schema (#9238) 2026-02-11 18:53:44 +01:00
Saoud Rizwan acf34860bb chore: fix codex desktop app configuration 2026-02-11 02:34:33 -08:00
Saoud Rizwan 49cad88aca chore: fix codex desktop app configuration 2026-02-11 02:25:59 -08:00
Saoud Rizwan 3c65cbc7f2 chore: add codex desktop app configuration 2026-02-11 02:22:29 -08:00
Saoud Rizwan 12603d4be1 feat: replace legacy CLI subagents with native use_subagents tool (#9208)
* feat: checkpoint subagent tool workflow and approval UX

* feat: support subagent tool execution without native tool calls

* fix: expose use_subagents when native tool calling is disabled

* fix: stabilize subagent command UX and suppress nested command rows

* chore: tune subagent prompt guidance for context-heavy exploration

* fix: align subagent row spacing with chat row conventions

* fix: keep cancelled subagent state during immediate resume

* feat: implement subagent message rendering for approval prompts and progress updates

* feat: enhance SubagentRunner with tool use ID resolution and fallback handling

* fix: stabilize subagent cline requests with ulid and initial workspace metadata

* refactor: unify subagent chat row rendering

* feat: surface subagent costs in task metrics and status rows

* fix: refine cli subagent tree alignment and wrapping

* fix: refine subagent streaming rows in cli and webview

* fix: ensure unique act mode hint keys in CLI chat

* feat: add subagents settings toggle wiring across webview and cli

* fix(webview): stream subagent stats per prompt while constructing prompts

* fix: remove duplicate subagentsEnabled declaration after rebase

* chore: restore package lockfiles to main

* fix: harden task history usage parsing and clean prompt separators

* chore: refine subagent response formatting guidance

* feat: collapse subagent prompts with show more

* feat: show latest subagent tool call in status rows

* fix: fall back to non-native mode for subagents when native tools are unavailable

* fix: retry empty subagent responses before failing

* fix(subagents): require attempt_completion and dedupe tool result formatting

* feat(subagents): polish prompt guidance and webview status row
2026-02-11 02:17:45 -08:00
Robin NewhouseandCursor b514f18e4f fix(task): prevent duplicate streamed text rows after completion (#9235)
* fix(task): prevent duplicate partial text rows after completion

Avoid adding a new partial text message when the latest text row is already completed with the same content. This stops a presenter race from rendering duplicate streamed text lines for MiniMax-style timing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(task): cover duplicate partial text dedupe behavior

Add a Task.say unit test that reproduces the duplicate-partial-after-complete scenario and verifies we skip creating a second text row with identical content.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 23:44:01 -08:00
Saoud Rizwan ce85d7d414 fix(cli): preserve OAuth callback paths in auth redirects (#9237) 2026-02-10 19:28:28 -08:00
Saoud Rizwan 739d75afe3 fix(claude-code): add opus 4.6 1m model option (#9231)
* fix(claude-code): add opus 4.6 1m model option

* fix(claude-code): support opus[1m] alias and align opus alias

* fix(claude-code): add sonnet[1m] model support
2026-02-11 04:19:44 +01:00
fc1be2baac add more shortcuts to help output (#9204)
* add more shortcuts to help output

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-10 16:14:11 -08:00
Saoud Rizwan 5dcaa8c8cc fix(vertex): add opus 4.6 1m model support on Vertex (#9230)
* fix(vertex): add opus 4.6 1m and global endpoint support

* fix(vertex): enable thinking for opus 4.6 1m in webview
2026-02-10 15:30:12 -08:00
CandiedUniverse 79cf77db0a Finish adding Amazon Bedrock to isNexGenModelProvider() list [CLINE-1291] (#9216)
* Add Bedrock to the list in isNextGenModelProvider()

* feat(bedrock): Remove testing script used to develop isNextGenModelProvider() change against

* refactor: extract shared isParallelToolCallingEnabled into model-utils

Consolidate duplicated parallel tool calling logic from ToolExecutor.ts
and task/index.ts into a single exported function in model-utils.ts.

Both callers now delegate to the shared function, eliminating the need
to maintain identical checks in two places.
2026-02-10 14:39:48 -08:00
Robin Newhouse 4b61799df5 docs: improve PR creation skill to use --body-file flag (#8789)
Replaces inline --body with --body-file approach in the PR creation skill documentation. This avoids shell escaping issues, newline problems, and command-line flakiness when creating PRs with complex markdown content.

Related to #8785
2026-02-10 15:52:05 -06:00
cryptoque 806708e802 feat: enable sync-ed deletion for remote mcp servers from remote config to extension (#9210)
* feat: enable sync-ed deletion for remote mcp servers from remote config to extension

* chore: add tests for syncing remote mcp server adding and removal

* address comments
2026-02-10 10:24:19 -08:00
MaxandMax Paulus 🥪 642ea849e3 fix publish-cli-trusted workflow (#9220)
- parent workflow needs to request permissions for children workflows

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-09 21:53:40 -08:00
MaxandMax Paulus 🥪 dff8193de5 make trusted npm publish workflow (#9219)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-09 21:40:04 -08:00
MaxandMax Paulus 🥪 a05c1c5e53 store input text on remount (#9124)
- my input was getting cleared when i resized the screen. this fixes
that

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-09 21:06:59 -08:00
MaxandMax Paulus 🥪 9a11976d27 improve cline config command (#9212)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-09 18:15:05 -08:00
Tomás Barreiro b54647ab17 [PF-389] Render remote config options and add test buttons (#9051)
* WIP - Render a Remote Config secttion and add an option to refresh

* Add the different remote config sections and test them

* fixes

* refactor

* Add proper wrapping

* Stack more values

* Properly report errors when prompt uploading fails

* Add a better error message for the otel test button

* clean

* Fix option rendering

* Render less options if they aren't configured
2026-02-10 02:46:40 +01:00
ClineXDiego 6e253dfa9a Fix/vscode web oauth callback (#9173)
* fix: use vscode.env.asExternalUri for web OAuth callbacks

In VS Code Web (Codespaces, code serve-web), OAuth callbacks using
http://127.0.0.1:PORT break because the extension host runs remotely.

Changes:
- getCallbackUrl now accepts a path parameter
- Desktop: uses vscode://extension-id/path directly
- Web (UIKind.Web): uses vscode.env.asExternalUri() for web-reachable URL
- Updated all callers (/auth, /openrouter, /hicap, /requesty, MCP) to
  pass path and use URL+searchParams for proper encoding
- Added regression test asserting web callback URL is not 127.0.0.1
- AuthHandler (localhost HTTP) now only used by CLI/standalone mode

* fix: use URL.searchParams for proper callback URL encoding

Callers were using template literal interpolation to embed callback URLs
into query strings, which breaks when the URL contains special characters
(e.g. from asExternalUri with query params). Use URL+searchParams.set()
which automatically encodes values.

* chore: revert unrelated whitespace change in account.proto

* revert: remove non-essential URL encoding changes in auth callers

Keep only the core fix (getCallbackUrl path parameter + asExternalUri for web).
Revert the URL+searchParams encoding improvement to minimize diff.

* fix: URL-encode callback_url in auth callers, add encoding test

In VS Code Web, callback URLs from asExternalUri can contain their own
query params (?tkn=...&extra=...). String-interpolating them into
callback_url= causes everything after the first & to be parsed as
top-level params, truncating the callback URL.

Use URL + searchParams.set() in openrouter, hicap, and requesty callers.
Replace tautology test with deterministic round-trip encoding assertions.
2026-02-09 16:43:24 -08:00
Ara 7236d02ebb feat(tools): add auto-approval support for attempt_completion commands (#8926)
* feat(tools): add auto-approval support for attempt_completion commands

- Add auto-approval logic for bash commands in AttemptCompletionHandler
- Show commands as 'say' instead of 'ask' when auto-approved
- Display notification prompting user approval when manual approval needed
- Add 30-second timeout notification for long-running auto-approved commands
- Fix Logger import path from @/shared to @shared

* Send to cline provider
2026-02-09 15:57:29 -08:00
Saoud Rizwan 84fef6fe1f chore(ci): remove ai review workflows and publish caching (#9211) 2026-02-09 15:42:44 -08:00
CandiedUniverse 7bdbf0a9a7 feat(bedrock): Support parallel tool calling in Amazon Bedrock [CLINE-1291] (#9150)
* feat(bedrock): Create agent implementation plan for supporting parallel tool calling.

* Add Bedrock tool calling support

* Improve Bedrock tool calling test guidance

* Add Bedrock CLI parallel tool calling test script

* fix: add ALLOW_AWS_DEFAULT_CHAIN support to live integration test script

* chore: add changeset for Bedrock parallel tool calling

* feat(bedrock): enable native parallel tool calling for Bedrock provider

- Add 'bedrock' to isNextGenModelProvider() so native tool calling is enabled
- Add 'bedrock' to getNativeConverter() to use Anthropic-format tool specs (input_schema)
- Fix empty tool description validation error in mapClineToolsToBedrockToolConfig
  (Bedrock requires description length >= 1)
- Update CLI test to use Sonnet 4.5 (Haiku too small for native tool calling)
- Add <invoke> XML detection to CLI test to catch XML fallback

Verified: conversation history shows 3 native tool_use blocks in a single
assistant response with 3 matching tool_result blocks — true parallel
tool calling via Bedrock Converse API.

* docs: mark all phases complete in bedrock parallel tool calling implementation plan

* chore: switch test scripts default model to Haiku 4.5 (cheaper for testing)

* feat: enhance CLI verification suite with 3 test cases (single, parallel, round-trip)

* Remove bedrock parallel tool calling implementation plan doc.

* refactor: simplify to single CLI verification script for bedrock parallel tool calling

Remove the handler-level test script (test-bedrock-tool-calling.ts) and consolidate
into a single focused CLI test that proves parallel tool calling works end-to-end:
- Spawns Cline CLI with Bedrock config
- Asks it to read 3 files
- Verifies ≥2 parallel native tool calls (not XML fallback)
- Task completion proves tool result round-trip works

* refactor(bedrock): improve type safety and code quality for parallel tool calling

- Add typed interfaces (ToolUseStart, ToolUseDelta) for Bedrock stream
  events instead of relying on `as any` casts
- Extend ContentBlockStart and ContentBlockDelta interfaces with toolUse
  fields so stream parsing uses typed property access
- Remove dead `inputBuffer` field from activeToolCalls Map (was tracked
  but never read — tool input deltas are yielded immediately)
- Add JSDoc to mapClineToolsToBedrockToolConfig explaining its purpose
  and return semantics
- Document why createDeepseekMessage intentionally ignores the tools
  parameter (DeepSeek R1 uses InvokeModel, not Converse API)

* refactor(scripts): improve test script readability and resource cleanup

- Add try/finally with cleanupDirs() to remove temp workspace and config
  dirs after each run (previously accumulated in $TMPDIR)
- Extract named constants for CLI_TIMEOUT_SECONDS and HEARTBEAT_INTERVAL_MS
- Add CliResult interface for the runCli return type
- Rename cryptic variables: hb → heartbeatInterval, c → chunk, p/d → filePath/data
- Add JSDoc to parseReadFilePaths and hasXmlFallback
- Add explanatory comments to empty catch blocks
- Log stderr on non-zero exit code for easier debugging
- Extract createTestWorkspace() to separate workspace setup from main flow
- Add section separator comments for visual structure

* test(bedrock): add missing edge-case tests and remove dead describe block

- Add tests for mapClineToolsToBedrockToolConfig edge cases:
  undefined/empty input returns undefined, tools without input_schema
  are silently dropped
- Add test for formatMessagesForConverseAPI with array tool_result
  content (multi-block text responses)
- Add test for tool_result is_error → status:'error' mapping
- Remove empty 'reasoning content handling (deprecated)' describe block

35 tests passing (was 31).

* test(bedrock): add integration-level tests covering E2E script gaps

Add 'native tool calling integration' test suite that validates the
concerns previously only covered by the live E2E CLI script:

- Bedrock + Claude 4 is recognized as native tool calling eligible
  (catches silent regression if Bedrock is removed from
  isNextGenModelProvider or Claude 4 from isNextGenModelFamily)
- Bedrock + Claude 3.x correctly does NOT qualify (pre-4.0 guard)
- Native tool calling disabled when user setting is off
- createAnthropicMessage passes toolConfig to ConverseStreamCommand
  (catches the tool spec not reaching the API)
- Full multi-turn tool call round-trip formatting (tool_use in
  assistant → tool_result in user → reformatted for next API call)

40 tests passing (was 35).

* Remove functional verification script before code review
2026-02-09 15:23:56 -08:00
MaxandMax Paulus 🥪 bab336f172 use cline provider for cline pr review workflow. use npx instead of npm install (#9202)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-09 10:21:32 -08:00
MaxandMax Paulus 🥪 967342999f if yolo mode is on, don't ask permission to use mcp tools (#9100)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-09 10:12:30 -08:00
Jose R. Perez d19a8779e7 feat: consolidate ViewHeader and styling (#8989)
* feat: consolidate ViewHeader and styling

* feat: changeset

* fix: added back environment variables for color differentiation

* fix: co-pilot fixes

* feat: github copilot fix
2026-02-09 10:02:16 -08:00
Saoud Rizwan 5c04fa3aa3 fix(cli): flush telemetry on shutdown and include activation metadata (#9195) 2026-02-08 22:00:45 -08:00
Saoud Rizwan 7c31c1d02a fix: restore reasoning behavior parity after #9168 (#9188)
* fix: restore reasoning parity after #9168

* fix: restore webview reasoning support compatibility checks

fix: simplify reasoning support model matching
2026-02-08 19:43:23 -08:00
Saoud Rizwan 740f99400b feat(cli): add max-consecutive-mistakes task flag (#9194) 2026-02-08 18:52:45 -08:00
Igor Tceglevskii 195294f389 feat: bundled endpoints.json (#9113) 2026-02-08 18:31:01 -08:00
Saoud Rizwan 2cc070eee2 fix(api): preserve vercel model id when metadata is missing (#9192) 2026-02-08 18:00:13 -08:00
Saoud Rizwan 627838243e fix(e2e): increase test timeout for Windows CI runners (#9185)
The diff editor e2e test flakes consistently on Windows CI because the
40s test timeout is too tight. The test does signin, message send,
history verification, then a second message send before the diff
assertion -- on slow Windows runners this setup alone can eat most of
the budget. Bumping to 60s gives enough headroom.
2026-02-08 11:27:48 -08:00
Saoud Rizwan f8a1f75664 feat: add output precision and threshold rules to double-check prompt (#9184)
Add Terminal-Bench-proven rules as items 5 and 6 in the double-check
re-verification checklist, so they're enforced at completion
verification time rather than in the system prompt.
2026-02-08 11:09:27 -08:00
Saoud Rizwan 54aeba1fee feat: add double-check completion experimental feature (#9180)
* feat: add double-check completion experimental feature

When enabled, the first attempt_completion call in a task is rejected
with a tool error that instructs the model to re-verify its work
against the original task requirements. The rejection includes the
initial task text for context. The second call proceeds normally.

This is opt-in (default off) and available via:
- Settings > Features > Experimental > Double-Check Completion
- CLI flag: --double-check-completion
- CLI TUI settings panel toggle

Adds completionAttemptCount to TaskState, plumbs the setting through
TaskConfig/ToolExecutor following existing patterns, and includes
9 unit tests.

* chore: add cli:run script for quick CLI testing

* fix: increase task preview to 8000 chars, revert unintended regex change

* fix: preserve existing proto field numbers

The auto-generator renumbered open_ai_headers (175->177) and
openai_codex_oauth_credentials (46->48), and dropped the reserved 146
comment. Restore original field numbers to avoid breaking wire-format
compatibility.

* fix: remove partial completion_result message on double-check rejection

During streaming, handlePartialBlock shows the completion_result in
the chat view. When we reject the first attempt, we need to clean up
that partial message so the user doesn't see a stale completion that
was actually rejected.

* refactor: switch from counter to boolean toggle for double-check

Use a boolean pending flag instead of a counter so that every
attempt_completion gets double-checked, not just the first one in
a task. The flag toggles: reject (set pending), accept (clear pending),
so if the model does more work and tries to complete again later, it
gets double-checked again.
2026-02-08 10:56:10 -08:00
385 changed files with 17104 additions and 8523 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Replace the LiteLLM model list with a selector
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Add GitHub Actions workflow to build CLI from any commit for testing
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
fix(cli): prevent hang when spawned without TTY
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Add Claude Opus 4.6 model support
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add .agents/skills as default skill directory (global and local)
+10
View File
@@ -0,0 +1,10 @@
---
"claude-dev": patch
---
Add comprehensive LLM evaluation framework with CI integration
- Smoke tests: 7 curated scenarios testing tools across providers (Claude, GPT-5, Gemini)
- Analysis framework: pass@k metrics for measuring reliability
- CI workflow: Parallel smoke tests on PRs with ~3min execution time
- E2E runner: cline-bench integration for real-world task evaluation (local only for now)
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Supports rendering markdown table in chat view.
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Fix CLI crashing in CI environments and with stdin redirection (e.g., `cline "prompt" < /dev/null`). Now checks both stdin and stdout TTY status before using Ink, and only errors on empty stdin when no prompt is provided.
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix JetBrains sign-in regression by adding fallback for openExternal RPC
-7
View File
@@ -1,7 +0,0 @@
---
"cline": patch
---
fix: use vscode.env.openExternal for auth in remote environments
Fixes OAuth authentication in VS Code Server and remote environments by routing browser URL opening through VS Code's native openExternal API instead of the npm 'open' package.
@@ -1,7 +0,0 @@
---
"cline": patch
---
fix: use vscode.env.asExternalUri for auth callback URLs only in VS Code Web
Fixes OAuth callback redirect in VS Code Web (`code serve-web`) environments by using `vscode.env.asExternalUri()` to resolve the callback URI. This is gated behind a `vscode.env.uiKind === UIKind.Web` check so regular desktop VS Code continues to use the `vscode://` URI directly, avoiding unintended transformations from `asExternalUri`.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Updating script documentation and removing unnecessary continue on error
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Fix Bedrock model id
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Allows users to enter custom aws region when selecting bedrock as a provider
-5
View File
@@ -1,5 +0,0 @@
---
"cline": minor
---
Add Generate API Key on Hicap Provider selection
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Prevent Parent Container Scrolling In Dropdowns
+20 -5
View File
@@ -147,14 +147,29 @@ When filling out the template:
### Create PR with gh CLI
**Use a temporary file for the PR body** to avoid shell escaping issues, newline problems, and other command-line flakiness:
1. Write the PR body to a temporary file:
```
/tmp/pr-body.md
```
2. Create the PR using the file:
```bash
gh pr create --title "PR_TITLE" --body-file /tmp/pr-body.md --base main
```
3. Clean up the temporary file:
```bash
rm /tmp/pr-body.md
```
For draft PRs:
```bash
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main
gh pr create --title "PR_TITLE" --body-file /tmp/pr-body.md --base main --draft
```
Alternatively, create as draft if the user wants review before marking ready:
```bash
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main --draft
```
**Why use a file?** Passing complex markdown with newlines, special characters, and checkboxes directly via `--body` is error-prone. The `--body-file` flag handles all content reliably.
## Post-Creation
+11
View File
@@ -147,6 +147,17 @@ Required steps:
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
- `src/core/controller/state/updateSettingsCli.ts` for CLI/ACP settings updates
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
+34
View File
@@ -0,0 +1,34 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "cline"
[setup]
script = '''
if [ ! -d "node_modules" ]; then
MAIN_WORKTREE="$(git worktree list | head -n1 | awk '{print $1}')"
ln -s "$MAIN_WORKTREE/node_modules" node_modules
ln -s "$MAIN_WORKTREE/webview-ui/node_modules" webview-ui/node_modules
fi
'''
[[actions]]
name = "VS Code"
icon = "run"
command = "chmod +x ./scripts/run-extension-host.sh && ./scripts/run-extension-host.sh production"
[[actions]]
name = "CLI"
icon = "run"
command = '''
npm run cli:build
npm run cli:run
'''
[[actions]]
name = "npm install"
icon = "tool"
command = '''
rm node_modules
rm webview-ui/node_modules
npm run install:all
'''
-173
View File
@@ -1,173 +0,0 @@
name: Claude Issue Triage
on:
issues:
types: [opened]
# Manual trigger for backfilling existing issues. Run from terminal:
# gh workflow run claude-issue-triage.yml -f issue_number=1234
# Or batch process:
# gh issue list --state open --limit 10 --json number --jq '.[].number' | while read num; do
# gh workflow run claude-issue-triage.yml -f issue_number=$num
# sleep 60
# done
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number to triage'
required: true
type: string
jobs:
claude-issue-triage:
runs-on: ubuntu-latest
timeout-minutes: 120
# SECURITY: These permissions are intentionally restrictive.
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
# - issues: write -> Claude can comment and add labels (the only write access needed)
# - pull-requests: read -> Claude can view PR context but CANNOT create PRs
# This ensures that even if a malicious user attempts prompt injection via issue content,
# Claude cannot modify repository code, create branches, or open PRs.
permissions:
contents: read
issues: write
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Issue Response & Triage
id: triage
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
# Allow all tools - security is enforced by GitHub permissions above (contents: read, issues: write)
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
prompt: |
You're a GitHub issue first responder for the open source Cline repository.
**Issue:** #${{ github.event.issue.number || inputs.issue_number }}
**Title:** ${{ github.event.issue.title || 'See issue details below' }}
**Author:** @${{ github.event.issue.user.login || 'See issue details below' }}
## Your job
Investigate this issue thoroughly, then post a single helpful comment that helps the user and gives maintainers the context they need.
## Investigation
Start by reading the full issue:
gh issue view ${{ github.event.issue.number || inputs.issue_number }}
### Search for duplicates and related issues
Search thoroughly for existing issues that match this one:
gh issue list --search "<keywords from the issue>" --state all --limit 30
gh issue list --search "<error messages>" --state all --limit 20
gh issue list --search "<affected feature/component>" --state all --limit 20
For each relevant issue you find, read it including its comments:
gh issue view <number> --comments
You're looking for:
- **Duplicates**: Issues describing the same problem. Link to them and explain why you think they're duplicates. If closed, check how they were resolved - the solution might apply here.
- **Related issues**: Similar problems or context that could help. Pull useful information from their comments (workarounds others found, debugging steps that helped, maintainer explanations). Link to them and explain the connection.
If there are closed issues with solutions, surface those solutions prominently - this might immediately solve the user's problem.
### Analyze recent changes (ALWAYS DO THIS)
Many issues are regressions from recent releases. **Always** check what changed recently:
gh release list --limit 10
gh pr list --state merged --limit 50 --json number,title,mergedAt,author,body
Look for PRs merged in the last few weeks that might correlate with the issue. If you find a likely connection:
gh pr view <number>
gh pr diff <number>
git log --since="1 month ago" --oneline -- <relevant paths>
git show <commit>
**Always include your findings in your comment:**
- If you find a regression, call it out explicitly: which PR/commit likely caused it, who authored it, what changed, and suggest a fix direction if you can see one.
- If you don't find anything related, still mention it: "I analyzed recent PRs and releases but didn't find any changes that seem related to this issue."
### Search the codebase
Find the relevant code:
- Use grep/find to locate code related to the issue
- Key areas: `src/api/` (providers/models), `src/core/prompts/` (tools/prompts), platform-specific code for VS Code vs JetBrains
### Find documentation
Cline docs are at **https://docs.cline.bot/** and built with Mintlify from the `docs/` directory.
The URL structure maps directly to the file structure:
- `docs/getting-started/selecting-your-model.mdx` → https://docs.cline.bot/getting-started/selecting-your-model
- `docs/troubleshooting.mdx` → https://docs.cline.bot/troubleshooting
- Headings become anchors: `## Which Model` → `#which-model`
Search the `docs/` directory to find relevant documentation, then construct URLs to link users to:
```bash
ls docs/
grep -r "keyword" docs/ --include="*.mdx" -l
```
### Identify subject matter experts
For issues that clearly need engineering attention:
git log --since="6 months ago" --format="%an" -- <relevant paths> | sort | uniq -c | sort -rn | head -5
Cross-reference with GitHub usernames. Include in your response (@mention, do NOT assign):
| SME | Reason |
|-----|--------|
| @username1 | Authored PR #X which modified this area |
| @username2 | Primary contributor to affected file |
## Weak model detection
Many issues are caused by users running small or non-frontier models that don't tool-call reliably. Signs include:
- Model failing to use tools correctly
- Nonsensical or malformed responses
- User is running a small/local model or older model version
If this looks like a weak model issue, kindly suggest they try reproducing with Claude Sonnet and report back if it persists. Link to https://docs.cline.bot/getting-started/selecting-your-model if helpful. Still label and triage normally.
## Your comment
Write a single comment as a helpful community member. Be conversational, not robotic. Include what's relevant:
- **Helpful response** - Answer their question, suggest a fix, provide a workaround. If you found solutions in related closed issues, surface those prominently.
- **Duplicates and related issues** - Link to any you found and explain why they're duplicates/related. Summarize useful context from their comments.
- **Regression analysis** - If this looks like a regression, explain what change likely caused it, link to the PR/commit, and tag the author.
- **Clarifying questions** - If you need more info, ask specific questions. Don't ask for things already provided.
- **SME table** - Include the table above if this needs engineering attention. Don't tag people for questions with obvious answers or weak-model issues.
- **Context for maintainers** - Relevant code paths, what you found. Keep it concise.
- **Docs links** - If there's relevant documentation, link to it naturally in your response as a recommendation (e.g., "For more details, check out [the Ollama setup guide](url)"). Do NOT add a "Sources" section at the end - integrate doc links into your response where they're helpful.
- **Possible Duplicates section** - ALWAYS include a "Possible Duplicates" section at the end of your comment listing issues that might be duplicates so maintainers can quickly close if appropriate. If none found, say "No obvious duplicates found."
## Labels
First, retrieve all available labels and read their descriptions to understand what each is for:
gh label list --json name,description --limit 100
Then apply the appropriate labels based on your analysis. Only use labels from the list above—do not create new labels.
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "label1,label2"
If your regression analysis found a likely culprit (a recent PR/commit that probably caused this issue), add the "Regression" label:
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Regression"
IMPORTANT: After posting your comment, add the "Bot Responded" label to indicate this issue has received an automated response:
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Bot Responded"
## Remember
- **This is a one-time automated response** - you will NOT see their reply or respond again. Never say things like "I can help you", "let me know", "once I have that info", or "I can give you more targeted help" - you won't be there to follow up. If you ask clarifying questions, frame them for the maintainers who will follow up, e.g., "If you can share X, that would help the maintainers diagnose this."
- Don't be formulaic. Respond to what the issue actually needs.
- Surface solutions from past issues - often the fastest path to helping.
- Connecting regressions to specific changes is extremely valuable.
- Link issues with #number so they're clickable.
-290
View File
@@ -1,290 +0,0 @@
name: Claude PR Review
on:
pull_request:
types: [opened, ready_for_review]
# Manual trigger for backfilling existing PRs. Run from terminal:
# gh workflow run claude-pr-review.yml -f pr_number=1234
# Or batch process open PRs:
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
# gh workflow run claude-pr-review.yml -f pr_number=$num
# sleep 60
# done
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: string
jobs:
claude-pr-review:
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
if: |
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 120
# SECURITY: These permissions are intentionally restrictive.
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
# - pull-requests: write -> Claude can post reviews and inline suggestions
# - issues: read -> Claude can search for related issues
# NOTE: Even with pull-requests: write, Claude CANNOT merge PRs because branch protection
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
permissions:
contents: read
pull-requests: write
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Print HEAD commit
run: |
echo "HEAD is at: $(git rev-parse HEAD)"
echo "Short: $(git rev-parse --short HEAD)"
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
- name: Get PR number
id: pr
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
fi
- name: Run PR Review
id: review
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
prompt: |
You're a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
PR: #${{ steps.pr.outputs.number }}
## Gather context
```bash
# Get full PR details
gh pr view ${{ steps.pr.outputs.number }} --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
# Get the diff
gh pr diff ${{ steps.pr.outputs.number }}
# Check CI status
gh pr checks ${{ steps.pr.outputs.number }}
# Get existing review comments (to understand context and your previous feedback)
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments --jq '.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'
# Get conversation comments
gh pr view ${{ steps.pr.outputs.number }} --comments
```
If this is a re-review (workflow_dispatch event):
Read your previous comments carefully. Understand what you asked for before.
Check if new commits or comments address your previous feedback.
## Check contributing guidelines
Flag (but don't block) if:
- Missing changeset - For user-facing changes, check if there's a `.changeset/` file:
```bash
gh pr diff ${{ steps.pr.outputs.number }} --name-only | grep '.changeset/' || echo "No changeset found"
```
If missing, ask them to run `npm run changeset`
- Missing tests - New features should have tests
## Find related issues and PRs
Search thoroughly for context that might help with the review:
```bash
# Find related issues for context
gh issue list --search "<keywords from the PR>" --state all --limit 30
gh issue list --search "<error messages or feature names>" --state all --limit 20
# Find similar PRs for reference
gh pr list --search "<keywords>" --state all --limit 30
```
For each relevant issue or PR you find, read it including comments:
```bash
gh issue view <number> --comments
gh pr view <number> --comments
```
Look for:
- Open issues this PR might fix that weren't linked in the description
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
- Context from maintainer discussions that could inform your review
## Find subject matter experts
For files changed in this PR, find who knows the code best:
```bash
# Get files changed
gh pr diff ${{ steps.pr.outputs.number }} --name-only
# For each relevant path, find contributors
git log --since="6 months ago" --format="%an" -- <path> | sort | uniq -c | sort -rn | head -5
```
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
| SME | Reason |
|-----|--------|
| @username1 | Authored PR #X which modified this area |
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
| @username3 | Reviewed similar PR #Y with extensive feedback |
## Deep code review
This is the most important part. Don't just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
Step 1: Understand the intent
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
Step 2: Form your own opinion first
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
Step 3: Compare approaches
Now look at their implementation. How does it compare to what you would have done?
- Is their approach better in some ways? Note what they did well.
- Is their approach missing something? Be specific about what and why.
- Are there edge cases they haven't considered?
- Does it follow the patterns established in similar parts of the codebase?
Step 4: Look at the bigger picture
- What other files or systems does this change interact with?
- Could this break anything else?
- Is there additional work needed beyond this PR to complete the feature?
- Does this fit well with the overall architecture?
Step 5: Find reference implementations
Look for similar changes in the codebase:
```bash
git log --oneline --all --grep="<relevant keywords>" | head -20
git log --oneline -- <similar files> | head -20
```
If this is adding a new API provider, look at how other providers are implemented.
If this is adding a new feature, look at how similar features were added.
Note where their implementation aligns with or diverges from established patterns.
Step 6: Standard code review checks
- DRY: Is there duplicated code that could be extracted?
- Error handling: Are errors handled appropriately?
- Security: Any injection risks, credential exposure, unsafe dependencies?
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
- Types: Is TypeScript used correctly? Any unsafe type assertions?
- Naming: Are variables and functions named clearly?
- Comments: Is complex logic explained? Are there outdated comments?
## Inline code suggestions
For specific code improvements, use GitHub's suggestion syntax via `gh api`.
This creates suggestions the author can commit with one click.
Single-line suggestion:
```bash
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
-X POST \
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
-f event="COMMENT" \
-f body="" \
-F comments='[
{
"path": "src/example.ts",
"line": 42,
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
}
]'
```
Multi-line suggestion (replacing lines 40-45):
```bash
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
-X POST \
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
-f event="COMMENT" \
-f body="" \
-F comments='[
{
"path": "src/example.ts",
"start_line": 40,
"line": 45,
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
}
]'
```
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
## Post your review
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
Start by noting the commit hash you reviewed:
```bash
git rev-parse --short HEAD
```
Include this at the top of your comment: "Reviewed at commit: <short hash>"
Then thank them for their contribution. Be conversational, not robotic.
Include what's relevant:
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author's intent, why they made the changes, how they implemented it, and what files/systems are affected. Don't just summarize - explain.
- Related issues/PRs you found that provide useful context (link to them)
- Your review findings (issues to address, suggestions, etc.)
- Clear next steps for the author
At the very bottom of your comment, append this exact footer:
```text
---
Generated by Claude PR Review
```
Include a "For Maintainers" section with:
- Anything else useful to help the maintainer resolve this PR
- Related issues/PRs with context on why they're relevant
- Open issues this PR might fix that weren't linked in the description
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
- SME table - who should review this and why
For the SME table:
| SME | Reason |
|-----|--------|
| @username | Primary contributor to affected files |
## Update labels
Add appropriate labels based on your analysis:
```bash
gh label list --json name,description --limit 100
gh pr edit ${{ steps.pr.outputs.number }} --add-label "label1,label2"
```
When done, add the reviewed label:
```bash
gh pr edit ${{ steps.pr.outputs.number }} --add-label "Bot Reviewed"
```
## Remember
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like "let me know if you have questions", "I can help you with", or "feel free to ask" - you won't be there to follow up. Frame any questions for the maintainers who will follow up.
- Be helpful and welcoming - Many contributors are new to the project
- Be specific - Point to exact lines and suggest fixes, don't give vague feedback
- Think deeply - Don't just surface-level review, understand the intent and evaluate the approach
- Use inline suggestions - Make it easy for authors to accept changes
- You're a first-pass reviewer - A human maintainer will do final approval
@@ -0,0 +1,70 @@
name: Smoke Tests
on:
push:
branches: [main]
paths:
- 'src/core/**'
- 'src/shared/**'
- 'proto/**'
- 'evals/**'
- '.github/workflows/cline-evals-regression.yml'
pull_request:
paths:
- 'src/core/**'
- 'src/shared/**'
- 'proto/**'
- 'evals/**'
- '.github/workflows/cline-evals-regression.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: smoke-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke-tests:
name: Smoke Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build and install CLI
run: |
npm run protos
cd cli && npm install && npm run build && npm link
echo "$(npm config get prefix)/bin" >> $GITHUB_PATH
- name: Verify CLI
run: cline --version
- name: Run smoke tests
env:
CLINE_API_KEY: ${{ secrets.CLINE_API_KEY }}
run: |
cline auth -p cline -k "$CLINE_API_KEY" -m "anthropic/claude-sonnet-4.5"
npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1 --parallel
- name: Generate summary
if: always()
run: cat evals/smoke-tests/results/latest/summary.md >> $GITHUB_STEP_SUMMARY
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: smoke-test-results-${{ github.run_id }}
path: evals/smoke-tests/results/latest/
retention-days: 30
-331
View File
@@ -1,331 +0,0 @@
name: Cline PR Code Review
on:
pull_request:
types:
[opened, ready_for_review]
# Manual trigger for backfilling existing PRs. Run from terminal:
# gh workflow run cline-pr-review.yml -f pr_number=1234
# Or batch process open PRs:
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
# gh workflow run cline-pr-review.yml -f pr_number=$num
# sleep 60
# done
workflow_dispatch:
inputs:
pr_number:
description: "PR number to review"
required: true
type: string
concurrency:
group: pr-review-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: true
jobs:
cline-pr-review:
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
if: |
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
# SECURITY: These permissions are intentionally restrictive.
# - contents: read -> cline can read the codebase but CANNOT write/push any code
# - pull-requests: write -> cline can post reviews and inline suggestions
# - issues: read -> cline can search for related issues
# NOTE: Even with pull-requests: write, cline CANNOT merge PRs because branch protection
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
permissions:
contents: read
pull-requests: write
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Print HEAD commit
run: |
echo "HEAD is at: $(git rev-parse HEAD)"
echo "Short: $(git rev-parse --short HEAD)"
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- name: Install and Verify Cline CLI
run: |
npm install -g cline
cline version # verify installation
- name: Configure Cline with Anthropic
run: |
npx cline auth --provider anthropic \
--apikey "${{ secrets.ANTHROPIC_API_KEY }}" \
--modelid claude-opus-4-5-20251101
- name: Get PR number
id: pr
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
fi
- name: Review PR with Cline
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
GITHUB_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
CLINE_COMMAND_PERMISSIONS: |
{
"allow": [
"gh pr diff *",
"gh pr view *",
"gh pr checks *",
"gh pr list *",
"gh label list *",
"gh issue list *",
"gh issue view *",
"git log *",
"gh pr comment ${{ steps.pr.outputs.number }} *",
"gh pr edit ${{ steps.pr.outputs.number }} *",
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments *",
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews *"
]
}
run: |
npx cline --yolo 'You'\''re a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
PR: #'"${PR_NUMBER}"'
## Gather context
```bash
# Get full PR details
gh pr view '"${PR_NUMBER}"' --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
# Get the diff
gh pr diff '"${PR_NUMBER}"'
# Check CI status
gh pr checks '"${PR_NUMBER}"'
# Get existing review comments (to understand context and your previous feedback)
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/comments --jq '\''.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'\''
# Get conversation comments
gh pr view '"${PR_NUMBER}"' --comments
```
If this is a re-review (workflow_dispatch event):
Read your previous comments carefully. Understand what you asked for before.
Check if new commits or comments address your previous feedback.
## Check contributing guidelines
Flag (but don'\''t block) if:
- Missing changeset - For user-facing changes, check if there'\''s a `.changeset/` file:
```bash
gh pr diff '"${PR_NUMBER}"' --name-only | grep '\''.changeset/'\'' || echo '\''No changeset found'\''
```
If missing, ask them to run `npm run changeset`
- Missing tests - New features should have tests
## Find related issues and PRs
Search thoroughly for context that might help with the review:
```bash
# Find related issues for context
gh issue list --search '\''<keywords from the PR>'\'' --state all --limit 30
gh issue list --search '\''<error messages or feature names>'\'' --state all --limit 20
# Find similar PRs for reference
gh pr list --search '\''<keywords>'\'' --state all --limit 30
```
For each relevant issue or PR you find, read it including comments:
```bash
gh issue view <number> --comments
gh pr view <number> --comments
```
Look for:
- Open issues this PR might fix that weren'\''t linked in the description
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
- Context from maintainer discussions that could inform your review
## Find subject matter experts
For files changed in this PR, find who knows the code best:
```bash
# Get files changed
gh pr diff '"${PR_NUMBER}"' --name-only
# For each relevant path, find contributors
git log --since='\''6 months ago'\'' --format='\''%an'\'' -- <path> | sort | uniq -c | sort -rn | head -5
```
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
| SME | Reason |
|-----|--------|
| @username1 | Authored PR #X which modified this area |
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
| @username3 | Reviewed similar PR #Y with extensive feedback |
## Bash command usage
Don'\''t use operators like `|`, `&&`, or `;` - run each command separately and analyze the output.
When referencing command outputs, quote them properly to avoid formatting issues.
## Deep code review
This is the most important part. Don'\''t just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
Step 1: Understand the intent
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
Step 2: Form your own opinion first
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
Step 3: Compare approaches
Now look at their implementation. How does it compare to what you would have done?
- Is their approach better in some ways? Note what they did well.
- Is their approach missing something? Be specific about what and why.
- Are there edge cases they haven'\''t considered?
- Does it follow the patterns established in similar parts of the codebase?
Step 4: Look at the bigger picture
- What other files or systems does this change interact with?
- Could this break anything else?
- Is there additional work needed beyond this PR to complete the feature?
- Does this fit well with the overall architecture?
Step 5: Find reference implementations
Look for similar changes in the codebase:
```bash
git log --oneline --all --grep='\''<relevant keywords>'\'' | head -20
git log --oneline -- <similar files> | head -20
```
If this is adding a new API provider, look at how other providers are implemented.
If this is adding a new feature, look at how similar features were added.
Note where their implementation aligns with or diverges from established patterns.
Step 6: Standard code review checks
- DRY: Is there duplicated code that could be extracted?
- Error handling: Are errors handled appropriately?
- Security: Any injection risks, credential exposure, unsafe dependencies?
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
- Types: Is TypeScript used correctly? Any unsafe type assertions?
- Naming: Are variables and functions named clearly?
- Comments: Is complex logic explained? Are there outdated comments?
## Inline code suggestions
For specific code improvements, use GitHub'\''s suggestion syntax via `gh api`.
This creates suggestions the author can commit with one click.
Single-line suggestion:
```bash
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
-X POST \
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
-f event='\''COMMENT'\'' \
-f body='\'''\'' \
-F comments='\''[
{
"path": "src/example.ts",
"line": 42,
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
}
]'\''
```
Multi-line suggestion (replacing lines 40-45):
```bash
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
-X POST \
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
-f event='\''COMMENT'\'' \
-f body='\'''\'' \
-F comments='\''[
{
"path": "src/example.ts",
"start_line": 40,
"line": 45,
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
}
]'\''
```
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
## Post your review
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
Start by noting the commit hash you reviewed:
```bash
git rev-parse --short HEAD
```
Include this at the top of your comment: "Reviewed at commit: <short hash>"
Then thank them for their contribution. Be conversational, not robotic.
Include what'\''s relevant:
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author'\''s intent, why they made the changes, how they implemented it, and what files/systems are affected. Don'\''t just summarize - explain.
- Related issues/PRs you found that provide useful context (link to them)
- Your review findings (issues to address, suggestions, etc.)
- Clear next steps for the author
At the very bottom of your comment, append this exact footer:
```text
---
Generated by Cline PR Code Review
```
Include a '\''For Maintainers'\'' section with:
- Anything else useful to help the maintainer resolve this PR
- Related issues/PRs with context on why they'\''re relevant
- Open issues this PR might fix that weren'\''t linked in the description
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
- SME table - who should review this and why
For the SME table:
| SME | Reason |
|-----|--------|
| @username | Primary contributor to affected files |
## Update labels
Add appropriate labels based on your analysis:
```bash
gh label list --json name,description --limit 100
gh pr edit '"${PR_NUMBER}"' --add-label '\''label1,label2'\''
```
When done, add the reviewed label:
```bash
gh pr edit '"${PR_NUMBER}"' --add-label '\''Bot Reviewed'\''
```
## Remember
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like '\''let me know if you have questions'\'', '\''I can help you with'\'', or '\''feel free to ask'\'' - you won'\''t be there to follow up. Frame any questions for the maintainers who will follow up.
- Be helpful and welcoming - Many contributors are new to the project
- Be specific - Point to exact lines and suggest fixes, don'\''t give vague feedback
- Think deeply - Don'\''t just surface-level review, understand the intent and evaluate the approach
- Use inline suggestions - Make it easy for authors to accept changes
- You'\''re a first-pass reviewer - A human maintainer will do final approval'
+4 -14
View File
@@ -1,7 +1,7 @@
name: Publish NPM Release
on:
workflow_dispatch:
workflow_call:
inputs:
confirm_publish:
description: 'Type "publish" to confirm you want to publish to NPM'
@@ -10,6 +10,7 @@ on:
permissions:
contents: write # Required for pushing tags
id-token: write # Required for npm trusted publishing (OIDC)
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
@@ -20,7 +21,7 @@ jobs:
publish-npm-release:
needs: test
name: Publish Cline CLI to NPM
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && github.event.inputs.confirm_publish == 'publish'
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && inputs.confirm_publish == 'publish'
runs-on: ubuntu-latest
steps:
@@ -30,19 +31,10 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Install root dependencies and CLI dependencies
if: steps.check_commits.outputs.skip != 'true'
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
- name: Generate Protos
@@ -81,8 +73,6 @@ jobs:
cat dist-standalone/package.json | grep version
- name: Publish to NPM with latest tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
run: |
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'latest'..."
cd dist-standalone
+15 -15
View File
@@ -1,12 +1,17 @@
name: Publish NPM Nightly
on:
schedule:
- cron: "0 12 * * *" # 4 AM PST (UTC-8) = 12 UTC
workflow_dispatch:
workflow_call:
inputs:
force_publish:
description: "Force publish even if there are no commits in the last 24 hours"
required: false
type: boolean
default: false
permissions:
contents: read
id-token: write # Required for npm trusted publishing (OIDC)
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
@@ -27,6 +32,12 @@ jobs:
- name: Check for recent commits
id: check_commits
run: |
if [ "${{ inputs.force_publish }}" = "true" ]; then
echo "force_publish enabled, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
echo "No commits in last 24 hours, skipping publish"
echo "skip=true" >> $GITHUB_OUTPUT
@@ -39,18 +50,9 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
if: steps.check_commits.outputs.skip != 'true'
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Install root dependencies and CLI dependencies
if: steps.check_commits.outputs.skip != 'true'
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
@@ -118,8 +120,6 @@ jobs:
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
run: |
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'nightly'..."
cd dist-standalone
@@ -0,0 +1,53 @@
name: Publish CLI (Trusted)
on:
schedule:
- cron: "0 12 * * *" # 4 AM PST (UTC-8) = 12 UTC
workflow_dispatch:
inputs:
publish_target:
description: "Which publish flow to run"
required: true
default: "main"
type: choice
options:
- main
- nightly
confirm_publish:
description: 'Required when publish_target=main. Type "publish" to confirm release publish.'
required: false
type: string
force_nightly_publish:
description: "Force nightly publish even with no commits in last 24h"
required: false
type: boolean
default: false
permissions:
id-token: write # Required for npm trusted publishing (OIDC)
contents: write # Required because npm-main creates/pushes git tags
checks: write # Required by nested reusable test workflow
pull-requests: write # Required by nested reusable test workflow
jobs:
publish-main:
if: |
github.repository == 'cline/cline' && (
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
)
uses: ./.github/workflows/npm-main.yaml
with:
confirm_publish: ${{ github.event.inputs.confirm_publish }}
publish-nightly:
if: |
github.repository == 'cline/cline' && (
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_target == 'nightly')
)
uses: ./.github/workflows/npm-nightly.yaml
with:
force_publish: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
-16
View File
@@ -38,22 +38,6 @@ jobs:
with:
node-version: "lts/*"
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
run: npm ci --include=optional
-18
View File
@@ -44,28 +44,10 @@ jobs:
with:
node-version: "lts/*"
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm install --include=optional
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm install --include=optional
- name: Install Publishing Tools
+3
View File
@@ -48,3 +48,6 @@ test-results
.secrets
*.tsbuildinfo
# Smoke test results (generated)
evals/smoke-tests/results/
+3
View File
@@ -0,0 +1,3 @@
[submodule "evals/cline-bench"]
path = evals/cline-bench
url = https://github.com/cline/cline-bench.git
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## [3.59.0]
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API
## [3.58.0]
### Added
- Subagent: replace legacy subagents with the native `use_subagents` tool
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
- Amazon Bedrock: support parallel tool calling
- New "double-check completion" experimental feature to verify work before marking tasks complete
- CLI: new task controls/flags including custom `--thinking` token budget and `--max-consecutive-mistakes` for yolo runs
- Remote config: new UI/options (including connection/test buttons) and support for syncing deletion of remotely configured MCP servers
- Vertex / Claude Code: add 1M context model options for Claude Opus 4.6
- ZAI/GLM: add GLM-5
### Fixed
- CLI: handle stdin redirection correctly in CI/headless environments
- CLI: preserve OAuth callback paths during auth redirects
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
- Terminal: surface command exit codes in results and improve long-running `execute_command` timeout behavior
- UI: add loading indicator and fix `api_req_started` rendering
- Task streaming: prevent duplicate streamed text rows after completion
- API: preserve selected Vercel model when model metadata is missing
- Telemetry: route PostHog networking through proxy-aware shared fetch and ensure telemetry flushes on shutdown
- CI: increase Windows E2E test timeout to reduce flakiness
### Changed
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
- CLI provider selection: limit provider list to those remotely configured
- UI: consolidate ViewHeader component/styling across views
- Tools: add auto-approval support for `attempt_completion` commands
- Remotely configured MCP server schema now supports custom headers
## [3.57.1]
### Fixed
+2
View File
@@ -61,6 +61,8 @@
"noUselessElse": "info"
},
"suspicious": {
"noSkippedTests": "warn",
"noFocusedTests": "error",
"noDoubleEquals": "warn",
"noImplicitAnyLet": "info",
"noThenProperty": "off",
+70
View File
@@ -0,0 +1,70 @@
# cline
## [2.2.1]
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API
## [2.2.0]
### Added
- Subagent: replace legacy subagents with the native `use_subagents` tool
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
- Amazon Bedrock: support parallel tool calling
- New "double-check completion" experimental feature to verify work before marking tasks complete
- CLI: new task controls/flags including custom `--thinking` token budget and `--max-consecutive-mistakes` for yolo runs
- Remote config: new UI/options (including connection/test buttons) and support for syncing deletion of remotely configured MCP servers
- Vertex / Claude Code: add 1M context model options for Claude Opus 4.6
- ZAI/GLM: add GLM-5
### Fixed
- CLI: handle stdin redirection correctly in CI/headless environments
- CLI: preserve OAuth callback paths during auth redirects
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
- Terminal: surface command exit codes in results and improve long-running `execute_command` timeout behavior
- UI: add loading indicator and fix `api_req_started` rendering
- Task streaming: prevent duplicate streamed text rows after completion
- API: preserve selected Vercel model when model metadata is missing
- Telemetry: route PostHog networking through proxy-aware shared fetch and ensure telemetry flushes on shutdown
- CI: increase Windows E2E test timeout to reduce flakiness
### Changed
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
- CLI provider selection: limit provider list to those remotely configured
- UI: consolidate ViewHeader component/styling across views
- Tools: add auto-approval support for `attempt_completion` commands
- Remotely configured MCP server schema now supports custom headers
## [2.1.0]
### Minor Changes
- 42ce100: Add Generate API Key on Hicap Provider selection
### Patch Changes
- 195294f: Add support for bundled endpoints.json in enterprise distributions. Extensions can now include a pre-configured endpoints.json file that automatically switches Cline to self-hosted mode. Includes packaging scripts for VSIX, NPM, and JetBrains plugins.
- a1f2601: Replace the LiteLLM model list with a selector
- 739d75a: Add Claude Code provider support for Claude Opus 4.6 and Sonnet 4.5 1M variants via both full model names and aliases (`opus[1m]`, `sonnet[1m]`), and align the `opus` alias with Opus 4.6.
- 8440380: Add GitHub Actions workflow to build CLI from any commit for testing
- b1a8db2: fix(cli): prevent hang when spawned without TTY
- 7c87017: Add Claude Opus 4.6 model support
- d116ac5: Supports rendering markdown table in chat view.
- 6d8fb85: Fix CLI crashing in CI environments and with stdin redirection (e.g., `cline "prompt" < /dev/null`). Now checks both stdin and stdout TTY status before using Ink, and only errors on empty stdin when no prompt is provided.
- 70a9904: Fix JetBrains sign-in regression by adding fallback for openExternal RPC
- f440f3a: fix: use vscode.env.openExternal for auth in remote environments
Fixes OAuth authentication in VS Code Server and remote environments by routing browser URL opening through VS Code's native openExternal API instead of the npm 'open' package.
- 70a9904: fix: use vscode.env.asExternalUri for auth callback URLs only in VS Code Web
Fixes OAuth callback redirect in VS Code Web (`code serve-web`, Codespaces) by using `vscode.env.asExternalUri()` to resolve the callback URI. This is gated behind a `vscode.env.uiKind === UIKind.Web` check so regular desktop VS Code continues to use the `vscode://` URI directly. The `getCallbackUrl` API now accepts a `path` parameter so the full callback URI (including route) is resolved correctly, and callers pass their path directly instead of appending after.
- 5308ded: Updating script documentation and removing unnecessary continue on error
- b514f18: Prevent duplicate streamed text rows when a partial text update arrives after the same text was already finalized.
- 26391c9: Fix Bedrock model id
- d19a877: Unify ViewHeader Styles Across All Views
- 5dcaa8c: Add Vertex Claude Opus 4.6 1M model option and global endpoint support, and pass the 1M beta header for Vertex Claude requests.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.0.5",
"version": "2.2.1",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "dist/cli.mjs",
"bin": {
+10 -28
View File
@@ -12,11 +12,7 @@
import type * as acp from "@agentclientprotocol/sdk"
import type { TerminalHandle } from "@agentclientprotocol/sdk"
import {
DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT,
DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT,
PROCESS_HOT_TIMEOUT_NORMAL,
} from "@integrations/terminal/constants"
import { DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT, PROCESS_HOT_TIMEOUT_NORMAL } from "@integrations/terminal/constants"
import type {
ITerminal,
ITerminalManager,
@@ -142,12 +138,12 @@ export interface ManagedTerminal {
* Wraps ACP terminal operations and emits events compatible with ITerminalProcess.
*/
class AcpTerminalProcess extends EventEmitter<TerminalProcessEvents> implements ITerminalProcess {
isHot: boolean = false
waitForShellIntegration: boolean = false
isHot = false
waitForShellIntegration = false
private _unretrievedOutput: string = ""
private _continued: boolean = false
private _completed: boolean = false
private _unretrievedOutput = ""
private _continued = false
private _completed = false
private _hotTimeout: NodeJS.Timeout | null = null
private _exitWaitTimeout: NodeJS.Timeout | null = null
private readonly manager: AcpTerminalManager
@@ -397,7 +393,7 @@ export class AcpTerminalManager implements ITerminalManager {
private readonly numericIdToStringId: Map<number, string> = new Map()
/** Next numeric ID to assign */
private nextNumericId: number = 1
private nextNumericId = 1
/** Active processes indexed by numeric terminal ID */
private readonly processes: Map<number, AcpTerminalProcess> = new Map()
@@ -406,9 +402,8 @@ export class AcpTerminalManager implements ITerminalManager {
private readonly terminalInfos: Map<number, TerminalInfo> = new Map()
// Configuration options for ITerminalManager
private terminalReuseEnabled: boolean = true
private terminalReuseEnabled = true
private terminalOutputLineLimit: number = DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT
private subagentTerminalOutputLineLimit: number = DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT
/**
* Creates a new AcpTerminalManager.
@@ -667,14 +662,6 @@ export class AcpTerminalManager implements ITerminalManager {
this.terminalOutputLineLimit = limit
}
/**
* Set the maximum number of output lines for subagent commands.
* @param limit Maximum number of lines
*/
setSubagentTerminalOutputLineLimit(limit: number): void {
this.subagentTerminalOutputLineLimit = limit
}
/**
* Set the default terminal profile.
* @param profile The profile identifier
@@ -687,15 +674,10 @@ export class AcpTerminalManager implements ITerminalManager {
* Process output lines, potentially truncating if over limit.
* @param outputLines Array of output lines
* @param overrideLimit Optional limit override
* @param isSubagentCommand Whether this is a subagent command
* @returns Processed output string
*/
processOutput(outputLines: string[], overrideLimit?: number, isSubagentCommand?: boolean): string {
const limit = isSubagentCommand
? overrideLimit !== undefined
? overrideLimit
: this.subagentTerminalOutputLineLimit
: this.terminalOutputLineLimit
processOutput(outputLines: string[], overrideLimit?: number): string {
const limit = overrideLimit !== undefined ? overrideLimit : this.terminalOutputLineLimit
if (outputLines.length > limit) {
const halfLimit = Math.floor(limit / 2)
+4 -4
View File
@@ -173,7 +173,7 @@ export class ClineAgent implements acp.Agent {
async initialize(params: acp.InitializeRequest, connection?: acp.AgentSideConnection): Promise<acp.InitializeResponse> {
this.clientCapabilities = params.clientCapabilities
this.initializeHostProvider(this.clientCapabilities, connection)
await ClineEndpoint.initialize()
await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
await StateManager.initialize(this.ctx.extensionContext)
return {
@@ -246,8 +246,8 @@ export class ClineAgent implements acp.Agent {
},
hostBridgeClientProvider,
(message: string) => Logger.info(message),
async () => {
return AuthHandler.getInstance().getCallbackUrl()
async (path: string) => {
return AuthHandler.getInstance().getCallbackUrl(path)
},
async () => "", // get binary location not needed in ACP mode
this.ctx.EXTENSION_DIR,
@@ -973,7 +973,7 @@ export class ClineAgent implements acp.Agent {
// Get the callback URL first to ensure the server is ready
let callbackUrl: string
try {
callbackUrl = await authHandler.getCallbackUrl()
callbackUrl = await authHandler.getCallbackUrl("/auth")
Logger.debug("[ClineAgent] Callback URL ready:", callbackUrl)
} catch (error) {
Logger.error("[ClineAgent] Failed to get callback URL:", error)
+4
View File
@@ -312,6 +312,10 @@ function translateSayMessage(
// API request finished - no specific update needed
break
case "subagent_usage":
// Hidden aggregate metrics event used for task-level accounting.
break
case "task":
// Task started - don't echo the user's prompt back to them
// The ACP client already knows what they typed
+19 -8
View File
@@ -5,12 +5,13 @@
import { Box, Text, useApp, useInput } from "ink"
import Spinner from "ink-spinner"
// biome-ignore lint/style/useImportType: React is used as a value by JSX (jsx: "react" in tsconfig)
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { AuthService } from "@/services/auth/AuthService"
import { liteLlmDefaultModelId, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
import { openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
import { StringRequest } from "@/shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
@@ -31,6 +32,7 @@ import {
} from "./FeaturedModelPicker"
import { ImportView } from "./ImportView"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { getProviderLabel } from "./ProviderPicker"
type AuthStep =
@@ -43,6 +45,7 @@ type AuthStep =
| "success"
| "error"
| "cline_auth"
| "oca_employee_check"
| "oca_auth"
| "cline_model"
| "openai_codex_auth"
@@ -160,7 +163,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [modelId, setModelId] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [errorMessage, setErrorMessage] = useState("")
const [authStatus, setAuthStatus] = useState<string>("")
const [providerSearch, setProviderSearch] = useState("")
const [providerIndex, setProviderIndex] = useState(0)
const [clineModelIndex, setClineModelIndex] = useState(0)
@@ -171,11 +173,14 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
// OCA auth hook - enabled when step is oca_auth
const handleOcaAuthSuccess = useCallback(async () => {
await applyProviderConfig({ providerId: "oca", controller })
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
await refreshOcaModels(controller, StringRequest.create({ value: "" }))
const stateManager = StateManager.get()
stateManager.setGlobalState("welcomeViewCompleted", true)
await stateManager.flushPendingState()
setSelectedProvider("oca")
setModelId(liteLlmDefaultModelId)
const actModelId = stateManager.getGlobalSettingsKey("actModeOcaModelId") || ""
setModelId(actModelId)
setStep("success")
}, [controller])
@@ -317,7 +322,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const startClineAuth = useCallback(async () => {
try {
setStep("cline_auth")
setAuthStatus("Starting authentication...")
await AuthService.getInstance(controller).createAuthRequest()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
@@ -327,7 +331,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const startOcaAuth = useCallback(() => {
setStep("oca_auth")
setAuthStatus("Starting authentication...")
initiateOcaAuth()
}, [initiateOcaAuth])
@@ -358,7 +361,8 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
(value: string) => {
setSelectedProvider(value)
if (value === "oca") {
startOcaAuth()
// Show employee check screen before starting auth
setStep("oca_employee_check")
} else if (value === "openai-codex") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
@@ -534,9 +538,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setBaseUrl("")
setStep("modelid")
break
case "oca_auth":
case "oca_employee_check":
setStep("provider")
break
case "oca_auth":
setStep("oca_employee_check")
break
case "cline_auth":
setStep("menu")
break
@@ -675,6 +682,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
</Box>
)
case "oca_employee_check":
return <OcaEmployeeCheck isActive={step === "oca_employee_check"} onCancel={goBack} onSignIn={startOcaAuth} />
case "oca_auth":
case "cline_auth":
return (
@@ -760,6 +770,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [menuIndex, setMenuIndex] = useState(0)
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
// OcaEmployeeCheck handles its own escape key, so oca_employee_check is not in this list
const canGoBack = [
"provider",
"modelid",
+106
View File
@@ -0,0 +1,106 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { render } from "ink-testing-library"
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { ChatMessage } from "./ChatMessage"
vi.mock("../hooks/useTerminalSize", () => ({
useTerminalSize: () => ({
columns: 120,
rows: 40,
resizeKey: 0,
}),
}))
describe("ChatMessage subagent rendering", () => {
it("renders subagent approval prompts as a tree", () => {
const message: ClineMessage = {
ts: Date.now(),
type: "ask",
ask: "use_subagents",
text: JSON.stringify({
prompts: [
"Find codebase stats and size",
"Find funny comments and easter eggs",
"Find unusual patterns and history",
],
}),
}
const { lastFrame } = render(React.createElement(ChatMessage, { message, mode: "act" }))
const frame = lastFrame() || ""
expect(frame).toContain("Cline wants to run subagents")
expect(frame).toContain("├─ Find codebase stats and size")
expect(frame).toContain("├─ Find funny comments and easter eggs")
expect(frame).toContain("└─ Find unusual patterns and history")
})
it("renders subagent progress rows with compact token stats and completion checks", () => {
const message: ClineMessage = {
ts: Date.now(),
type: "say",
say: "subagent",
text: JSON.stringify({
status: "running",
total: 3,
completed: 1,
successes: 1,
failures: 0,
toolCalls: 21,
inputTokens: 0,
outputTokens: 0,
contextWindow: 0,
maxContextTokens: 0,
maxContextUsagePercentage: 0,
items: [
{
index: 1,
prompt: "Find codebase stats and size",
status: "completed",
toolCalls: 5,
inputTokens: 0,
outputTokens: 0,
totalCost: 0.034,
contextTokens: 24400,
contextWindow: 200000,
contextUsagePercentage: 12.2,
},
{
index: 2,
prompt: "Find funny comments and easter eggs",
status: "running",
toolCalls: 11,
inputTokens: 0,
outputTokens: 0,
totalCost: 0.056,
contextTokens: 31600,
contextWindow: 200000,
contextUsagePercentage: 15.8,
},
{
index: 3,
prompt: "Find unusual patterns and history",
status: "pending",
toolCalls: 5,
inputTokens: 0,
outputTokens: 0,
totalCost: 0,
contextTokens: 28900,
contextWindow: 200000,
contextUsagePercentage: 14.4,
},
],
}),
}
const { lastFrame } = render(React.createElement(ChatMessage, { isStreaming: true, message, mode: "act" }))
const frame = lastFrame() || ""
expect(frame).toContain("Cline is running subagents")
expect(frame).toContain("✓ Find codebase stats and size")
expect(frame).toContain("5 tool uses · 24.4k tokens · $0.03")
expect(frame).toContain("11 tool uses · 31.6k tokens · $0.06")
expect(frame).toContain("5 tool uses · 28.9k tokens · $0.00")
})
})
+16 -7
View File
@@ -17,6 +17,7 @@ import { useTerminalSize } from "../hooks/useTerminalSize"
import { jsonParseSafe } from "../utils/parser"
import { getToolDescription, isFileEditTool, parseToolFromMessage } from "../utils/tools"
import { DiffView } from "./DiffView"
import { SubagentMessage } from "./SubagentMessage"
/**
* Add "(Tab)" hint after "Act mode" mentions.
@@ -24,7 +25,7 @@ import { DiffView } from "./DiffView"
* Matches just "Act mode" without requiring "to " prefix because markdown
* processing may split "toggle to **Act mode**" into separate text chunks.
*/
function addActModeHint(text: string): React.ReactNode[] {
function addActModeHint(text: string, keyPrefix: string): React.ReactNode[] {
// Match "Act mode" in various capitalizations, but not if already followed by (Tab)
const actModeRegex = /\bact\s+mode\b(?!\s*\(tab\))/gi
const parts = text.split(actModeRegex)
@@ -41,7 +42,7 @@ function addActModeHint(text: string): React.ReactNode[] {
}
if (matches[i]) {
nodes.push(
<React.Fragment key={`act-mode-${i}`}>
<React.Fragment key={`${keyPrefix}-act-mode-${i}`}>
{matches[i]}
<Text color="gray"> (Tab)</Text>
</React.Fragment>,
@@ -59,6 +60,8 @@ function addActModeHint(text: string): React.ReactNode[] {
*/
function renderInlineMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []
let hintCallIndex = 0
const addHintedText = (value: string) => addActModeHint(value, `hint-${hintCallIndex++}`)
// Match **bold**, *italic*, or `code` - order matters (** before *)
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
let lastIndex = 0
@@ -68,7 +71,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
// Add text before match (with Act Mode hint processing)
if (match.index > lastIndex) {
const beforeText = text.slice(lastIndex, match.index)
nodes.push(...addActModeHint(beforeText))
nodes.push(...addHintedText(beforeText))
}
const fullMatch = match[0]
@@ -77,7 +80,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
// Bold - also process for Act Mode hints inside bold text
const boldContent = fullMatch.slice(2, -2)
const hintedContent = addActModeHint(boldContent)
const hintedContent = addHintedText(boldContent)
nodes.push(
<Text bold key={key}>
{hintedContent}
@@ -100,10 +103,10 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
// Add remaining text (with Act Mode hint processing)
if (lastIndex < text.length) {
nodes.push(...addActModeHint(text.slice(lastIndex)))
nodes.push(...addHintedText(text.slice(lastIndex)))
}
return nodes.length > 0 ? nodes : addActModeHint(text)
return nodes.length > 0 ? nodes : addHintedText(text)
}
/**
@@ -224,7 +227,7 @@ function truncate(text: string, maxLength: number): string {
/**
* Format tool result for display
*/
function formatToolResult(result: string, maxLines: number = 5): string[] {
function formatToolResult(result: string, maxLines = 5): string[] {
const lines = result.split("\n")
if (lines.length <= maxLines) {
return lines
@@ -446,6 +449,10 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStrea
)
}
if ((type === "ask" && ask === "use_subagents") || say === "use_subagents" || say === "subagent") {
return <SubagentMessage isStreaming={isStreaming} message={message} mode={mode} />
}
// MCP response
if (say === "mcp_server_response" && text) {
const lines = formatToolResult(text, 8)
@@ -800,6 +807,8 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxM
const displayMessages = messages.filter((m) => {
// Skip api_req_finished, they're just markers
if (m.say === "api_req_finished") return false
// Skip hidden aggregated usage messages
if (m.say === "subagent_usage") return false
// Skip empty text messages
if (m.say === "text" && !m.text?.trim()) return false
// Skip checkpoint messages
+57 -5
View File
@@ -153,6 +153,24 @@ import { SettingsPanelContent } from "./SettingsPanelContent"
import { SlashCommandMenu } from "./SlashCommandMenu"
import { ThinkingIndicator } from "./ThinkingIndicator"
/**
* Persistent input storage that survives React remounts (e.g., during terminal resize).
* Keyed by a stable identifier so each task/session maintains its own input state.
*/
interface PersistedInputState {
text: string
cursorPos: number
pastedTexts: Map<number, string>
pasteCounter: number
}
const inputStateStorage = new Map<string, PersistedInputState>()
function getInputStorageKey(controller: any, taskId?: string): string {
// Use taskId if available, otherwise fall back to controller instance
return taskId || (controller?.task?.taskId ?? "default")
}
interface ChatViewProps {
controller?: any
onExit?: () => void
@@ -351,6 +369,9 @@ export const ChatView: React.FC<ChatViewProps> = ({
insertText: insertTextAtCursor,
} = useTextInput()
// Get storage key for persisting input across remounts
const storageKey = useMemo(() => getInputStorageKey(ctrl, taskId), [ctrl, taskId])
// Refs for text input and cursor position (used by useHomeEndKeys and to avoid stale closures in useInput)
const textInputRef = useRef(textInput)
textInputRef.current = textInput
@@ -367,8 +388,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
const [userScrolled, setUserScrolled] = useState(false)
// Pasted text storage - maps placeholder number to full pasted content
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(new Map())
const pasteCounterRef = useRef(0)
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(() => {
return inputStateStorage.get(storageKey)?.pastedTexts ?? new Map()
})
const pasteCounterRef = useRef<number>(inputStateStorage.get(storageKey)?.pasteCounter ?? 0)
// Track paste timing to combine chunks that arrive in rapid succession
const lastPasteTimeRef = useRef<number>(0)
const activePasteNumRef = useRef<number>(0)
@@ -402,6 +425,29 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Track when we're exiting to hide UI elements before exit
const [isExiting, setIsExiting] = useState(false)
// Restore input state from storage on mount (after resize remount)
useEffect(() => {
const stored = inputStateStorage.get(storageKey)
if (stored) {
setTextInput(stored.text)
setCursorPos(stored.cursorPos)
setPastedTexts(stored.pastedTexts)
pasteCounterRef.current = stored.pasteCounter
}
}, [storageKey, setTextInput, setCursorPos])
// Persist input state to storage whenever it changes (survives remount)
useEffect(() => {
if (textInput || pastedTexts.size > 0) {
inputStateStorage.set(storageKey, {
text: textInput,
cursorPos,
pastedTexts: new Map(pastedTexts),
pasteCounter: pasteCounterRef.current,
})
}
}, [storageKey, textInput, cursorPos, pastedTexts])
// Task switch handling: when switching tasks via /history, we clear the terminal and
// increment a counter used as the root Box's key. This forces React to remount the tree,
// giving us a fresh Static instance. Mirrors how App.tsx handles resize with resizeKey.
@@ -494,12 +540,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
clearState() // Force clear React state (bypasses empty messages check)
setTextInput("")
setCursorPos(0)
// Clear persisted state
inputStateStorage.delete(storageKey)
// Post the now-empty state
if (ctrl) {
ctrl.postStateToWebview()
}
}, [ctrl, clearState])
}, [ctrl, clearState, storageKey])
const refs = useRef({
searchTimeout: null as NodeJS.Timeout | null,
@@ -760,6 +808,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
setCursorPos(0)
setPastedTexts(new Map()) // Clear stored pastes
pasteCounterRef.current = 0
// Clear persisted state
inputStateStorage.delete(storageKey)
try {
await ctrl.task.handleWebviewAskResponse(responseType, expandedText)
@@ -767,7 +817,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Controller may be disposed
}
},
[ctrl, pendingAsk, pastedTexts],
[ctrl, pendingAsk, pastedTexts, storageKey],
)
// Handle cancel/interrupt
@@ -858,6 +908,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
setCursorPos(0)
setPastedTexts(new Map()) // Clear stored pastes
pasteCounterRef.current = 0
// Clear persisted state
inputStateStorage.delete(storageKey)
try {
// Convert image paths to data URLs if needed
@@ -885,7 +937,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
onError?.()
}
},
[ctrl, onError, pastedTexts],
[ctrl, onError, pastedTexts, storageKey],
)
// Auto-submit initial prompt if provided
+124 -18
View File
@@ -13,6 +13,7 @@ import {
import { Box, Text, useApp, useInput } from "ink"
import React, { useMemo, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import { fuzzyFilter } from "../utils/fuzzy-search"
import {
BooleanSelect,
buildConfigEntries,
@@ -21,6 +22,8 @@ import {
HookInfo,
HookRow,
MAX_VISIBLE,
ObjectEditorPanel,
ObjectEditorState,
parseValue,
SEPARATOR,
SectionHeader,
@@ -105,6 +108,8 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
const [isEditing, setIsEditing] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [editValue, setEditValue] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const [objectEditor, setObjectEditor] = useState<ObjectEditorState | null>(null)
// Build entries for settings tab
const configEntries = useMemo(
@@ -112,6 +117,13 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
[globalState, workspaceState],
)
const filteredConfigEntries = useMemo(() => {
if (!searchQuery.trim()) {
return configEntries
}
return fuzzyFilter(configEntries, searchQuery, (entry) => `${entry.key} ${String(entry.value ?? "")}`)
}, [configEntries, searchQuery])
// Build entries for rules tab
const ruleEntries = useMemo(() => {
const entries: ToggleEntry[] = []
@@ -159,7 +171,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
const currentListLength = useMemo(() => {
switch (currentTab) {
case "settings":
return configEntries.length
return filteredConfigEntries.length
case "rules":
return ruleEntries.length
case "workflows":
@@ -171,7 +183,14 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
default:
return 0
}
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
}, [
currentTab,
filteredConfigEntries.length,
ruleEntries.length,
workflowEntries.length,
hookEntries.length,
skillEntries.length,
])
// Get available tabs
const availableTabs = useMemo(() => {
@@ -191,10 +210,11 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
setCurrentTab(newTab)
setSelectedIndex(0)
setIsEditing(false)
setObjectEditor(null)
}
// Settings tab handlers
const selectedConfigEntry = configEntries[selectedIndex]
const selectedConfigEntry = filteredConfigEntries[selectedIndex]
const handleSettingsSave = (value: string | boolean) => {
if (!selectedConfigEntry) {
@@ -210,6 +230,43 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
setIsEditing(false)
}
const getObjectAtPath = (root: Record<string, unknown>, path: string[]): Record<string, unknown> => {
let current: unknown = root
for (const segment of path) {
if (!current || typeof current !== "object") {
return {}
}
current = (current as Record<string, unknown>)[segment]
}
return current && typeof current === "object" ? (current as Record<string, unknown>) : {}
}
const setObjectValueAtPath = (
root: Record<string, unknown>,
path: string[],
key: string,
value: unknown,
): Record<string, unknown> => {
if (path.length === 0) {
return { ...root, [key]: value }
}
const [head, ...rest] = path
const child = root[head]
const childObj = child && typeof child === "object" ? (child as Record<string, unknown>) : {}
return {
...root,
[head]: setObjectValueAtPath(childObj, rest, key, value),
}
}
const persistObjectEditor = (nextObject: Record<string, unknown>, source: "global" | "workspace", key: string) => {
if (source === "global" && onUpdateGlobal) {
onUpdateGlobal(key as GlobalStateAndSettingsKey, nextObject as never)
} else if (source === "workspace" && onUpdateWorkspace) {
onUpdateWorkspace(key as LocalStateKey, nextObject as never)
}
}
const handleSettingsReset = () => {
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
return
@@ -240,15 +297,22 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
// Input handling
useInput(
(input, key) => {
if (input.toLowerCase() === "q" || key.escape) {
if (objectEditor) {
return
}
if (key.escape) {
exit()
}
// Tab navigation with Tab key or number keys
if (key.tab || (input >= "1" && input <= "5")) {
const targetIdx = key.tab
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
: parseInt(input) - 1
if (key.leftArrow || key.rightArrow || (input >= "1" && input <= "5")) {
const currentTabIndex = availableTabs.findIndex((t) => t.key === currentTab)
const targetIdx =
input >= "1" && input <= "5"
? Number.parseInt(input) - 1
: key.leftArrow
? (currentTabIndex - 1 + availableTabs.length) % availableTabs.length
: (currentTabIndex + 1) % availableTabs.length
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
handleTabChange(availableTabs[targetIdx].key)
}
@@ -256,21 +320,45 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
}
// List navigation (arrow keys and vim-style j/k)
if (key.upArrow || input === "k") {
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
} else if (key.downArrow || input === "j") {
} else if (key.downArrow) {
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
}
// Tab-specific actions
if (currentTab === "settings") {
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
if ((key.return || key.tab) && selectedConfigEntry?.isEditable) {
if (selectedConfigEntry.type === "boolean") {
handleSettingsSave(!selectedConfigEntry.value)
return
}
if (selectedConfigEntry.type === "object") {
const value =
selectedConfigEntry.value && typeof selectedConfigEntry.value === "object"
? (selectedConfigEntry.value as Record<string, unknown>)
: {}
setObjectEditor({
source: selectedConfigEntry.source,
key: selectedConfigEntry.key,
path: [],
value,
selectedIndex: 0,
isEditingValue: false,
editValue: "",
})
return
}
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
setIsEditing(true)
} else if (input === "r") {
} else if (key.ctrl && input.toLowerCase() === "r") {
handleSettingsReset()
} else if (key.backspace || key.delete) {
setSearchQuery((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow) {
setSearchQuery((prev) => prev + input)
}
} else if (key.return || input === " ") {
} else if (key.return || key.tab || input === " ") {
// Toggle for rules/workflows/hooks/skills
handleToggle()
}
@@ -338,13 +426,31 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
)
}
if (objectEditor && currentTab === "settings") {
return (
<ObjectEditorPanel
getObjectAtPath={getObjectAtPath}
onClose={() => setObjectEditor(null)}
onPersist={(nextObject) => persistObjectEditor(nextObject, objectEditor.source, objectEditor.key)}
setObjectValueAtPath={setObjectValueAtPath}
setState={setObjectEditor}
state={objectEditor}
/>
)
}
// Render tab content
const renderTabContent = () => {
switch (currentTab) {
case "settings": {
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
const visibleEntries = filteredConfigEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<React.Fragment>
<Box>
<Text>Search: </Text>
<Text color="white">{searchQuery}</Text>
<Text inverse> </Text>
</Box>
<Box>
<Text>Data directory: </Text>
<Text color="blue" underline>
@@ -507,12 +613,12 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
// Help text based on current tab
const getHelpText = () => {
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
const base = "↑/↓ Navigate • ←/→ tabs • 1-5 tabs • Esc Exit"
if (currentTab === "settings") {
return `${base} • Enter/e Edit • r Reset`
return `${base} Type to search • Enter/Tab Edit (booleans toggle) • Backspace clear search • Ctrl+R Reset`
}
const openFolder = onOpenFolder ? " • o Open folder" : ""
return `${base} • Enter/Space Toggle${openFolder}`
return `${base} • Enter/Tab/Space Toggle${openFolder}`
}
return (
+180 -11
View File
@@ -46,16 +46,19 @@ export interface SkillInfo {
enabled: boolean
}
export const EXCLUDED_KEYS = new Set([
"taskHistory",
"primaryRootIndex",
"subagentsEnabled",
"subagentTerminalOutputLineLimit",
"welcomeViewCompleted",
"isNewUser",
])
export interface ObjectEditorState {
source: "global" | "workspace"
key: string
path: string[]
value: Record<string, unknown>
selectedIndex: number
isEditingValue: boolean
editValue: string
}
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
export const EXCLUDED_KEYS = new Set(["taskHistory", "primaryRootIndex", "welcomeViewCompleted", "isNewUser"])
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
export const MAX_VISIBLE = 12
export const SEPARATOR = "─".repeat(80)
@@ -135,7 +138,7 @@ export function parseValue(input: string, type: ValueType): unknown {
return input.toLowerCase() === "true" || input === "1"
}
if (type === "number") {
const num = parseFloat(input)
const num = Number.parseFloat(input)
return Number.isNaN(num) ? 0 : num
}
if (type === "object") {
@@ -217,7 +220,7 @@ export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel,
</Text>
<Box>
<Text color="white">{value}</Text>
<Text inverse> </Text>
<Text color="cyan">|</Text>
</Box>
<Text color="gray">Type: {type} Enter to save Esc to cancel</Text>
</Box>
@@ -379,3 +382,169 @@ export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
</Text>
</Box>
)
interface ObjectEditorPanelProps {
state: ObjectEditorState
setState: React.Dispatch<React.SetStateAction<ObjectEditorState | null>>
onClose: () => void
onPersist: (nextObject: Record<string, unknown>) => void
getObjectAtPath: (root: Record<string, unknown>, path: string[]) => Record<string, unknown>
setObjectValueAtPath: (root: Record<string, unknown>, path: string[], key: string, value: unknown) => Record<string, unknown>
}
export const ObjectEditorPanel: React.FC<ObjectEditorPanelProps> = ({
state,
setState,
onClose,
onPersist,
getObjectAtPath,
setObjectValueAtPath,
}) => {
const { isRawModeSupported } = useStdinContext()
const currentNode = getObjectAtPath(state.value, state.path)
const objectEntries = Object.entries(currentNode).sort(([a], [b]) => a.localeCompare(b))
const selectedEntry = objectEntries[state.selectedIndex]
const breadcrumb = [state.key, ...state.path].join(" ")
useInput(
(input, key) => {
if (state.isEditingValue) {
if (key.escape) {
setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev))
return
}
if (key.return) {
if (!selectedEntry) {
setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev))
return
}
const [entryKey, entryValue] = selectedEntry
let parsed: unknown = state.editValue
if (typeof entryValue === "boolean") {
parsed = state.editValue.toLowerCase() === "true" || state.editValue === "1"
} else if (typeof entryValue === "number") {
const maybeNum = Number(state.editValue)
parsed = Number.isNaN(maybeNum) ? 0 : maybeNum
}
const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, parsed)
onPersist(nextObject)
setState((prev) => (prev ? { ...prev, value: nextObject, isEditingValue: false, editValue: "" } : prev))
return
}
if (key.backspace || key.delete) {
setState((prev) => (prev ? { ...prev, editValue: prev.editValue.slice(0, -1) } : prev))
return
}
if (input && !key.ctrl && !key.meta) {
setState((prev) => (prev ? { ...prev, editValue: prev.editValue + input } : prev))
}
return
}
if (key.escape) {
if (state.path.length > 0) {
setState((prev) => (prev ? { ...prev, path: prev.path.slice(0, -1), selectedIndex: 0 } : prev))
} else {
onClose()
}
return
}
if (key.upArrow || input === "k") {
setState((prev) =>
prev
? {
...prev,
selectedIndex:
objectEntries.length > 0
? prev.selectedIndex > 0
? prev.selectedIndex - 1
: objectEntries.length - 1
: 0,
}
: prev,
)
return
}
if (key.downArrow || input === "j") {
setState((prev) =>
prev
? {
...prev,
selectedIndex:
objectEntries.length > 0
? prev.selectedIndex < objectEntries.length - 1
? prev.selectedIndex + 1
: 0
: 0,
}
: prev,
)
return
}
if (key.return || key.tab) {
if (!selectedEntry) {
return
}
const [entryKey, entryValue] = selectedEntry
if (typeof entryValue === "boolean") {
const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, !entryValue)
onPersist(nextObject)
setState((prev) => (prev ? { ...prev, value: nextObject } : prev))
return
}
if (entryValue && typeof entryValue === "object" && !Array.isArray(entryValue)) {
setState((prev) => (prev ? { ...prev, path: [...prev.path, entryKey], selectedIndex: 0 } : prev))
return
}
setState((prev) =>
prev
? { ...prev, isEditingValue: true, editValue: entryValue !== undefined ? String(entryValue) : "" }
: prev,
)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column">
<Text bold color="white">
Edit Nested Object
</Text>
<Text color="gray">{SEPARATOR}</Text>
<Text color="cyan">{breadcrumb}</Text>
{state.isEditingValue ? (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text color="white">{state.editValue}</Text>
<Text color="cyan">|</Text>
</Box>
<Text color="gray">Enter to save Esc to cancel</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
{objectEntries.length === 0 ? (
<Text color="gray">No nested keys at this level.</Text>
) : (
objectEntries.map(([key, value], idx) => {
const isSelected = idx === state.selectedIndex
const valueText =
value && typeof value === "object" && !Array.isArray(value) ? "{...}" : String(value)
return (
<Text color={isSelected ? "cyan" : undefined} key={key}>
{isSelected ? " " : " "}
<Text color="cyan">{key}</Text>
<Text color="gray">: </Text>
<Text color="white">{valueText}</Text>
</Text>
)
})
)}
<Text color="gray">/ Navigate Enter/Tab Edit or drill in Esc Back/Close</Text>
</Box>
)}
</Box>
)
}
+24
View File
@@ -43,6 +43,30 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
</Text>
</Box>
<Box flexDirection="column">
<Text bold>Keyboard Shortcuts</Text>
<Text>
{" "}
<Text color="white">Ctrl+U</Text> - Clear entire input (delete to start)
</Text>
<Text>
{" "}
<Text color="white">Ctrl+K</Text> - Delete from cursor to end
</Text>
<Text>
{" "}
<Text color="white">Ctrl+W</Text> - Delete word backwards
</Text>
<Text>
{" "}
<Text color="white">Ctrl+A / Ctrl+E</Text> - Jump to start / end of input
</Text>
<Text>
{" "}
<Text color="white">Alt/Option+/</Text> - Move by word
</Text>
</Box>
<Box flexDirection="column">
<Text bold>Slash Commands</Text>
<Text>
+18 -4
View File
@@ -6,6 +6,7 @@
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React, { useEffect, useMemo, useState } from "react"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
import {
type ApiProvider,
@@ -64,6 +65,7 @@ import {
xaiDefaultModelId,
xaiModels,
} from "@/shared/api"
import { StringRequest } from "@/shared/proto/cline/common"
import { filterOpenRouterModelIds } from "@/shared/utils/model-filters"
import { COLORS } from "../constants/colors"
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
@@ -105,7 +107,7 @@ export function hasStaticModels(provider: string): boolean {
}
export function hasModelPicker(provider: string): boolean {
return hasStaticModels(provider) || usesOpenRouterModels(provider)
return hasStaticModels(provider) || usesOpenRouterModels(provider) || provider === "oca"
}
export function getDefaultModelId(provider: string): string {
@@ -132,7 +134,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
const [isLoading, setIsLoading] = useState(false)
const [asyncModels, setAsyncModels] = useState<string[]>([])
// Fetch OpenRouter models when needed using shared core function
// Fetch async models (OpenRouter or OCA) when needed
useEffect(() => {
if (usesOpenRouterModels(provider)) {
setIsLoading(true)
@@ -145,11 +147,23 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
.finally(() => {
setIsLoading(false)
})
} else if (provider === "oca") {
setIsLoading(true)
refreshOcaModels(controller, StringRequest.create({ value: "" }))
.then((result) => {
if (result.models) {
const modelIds = Object.keys(result.models).sort((a, b) => a.localeCompare(b))
setAsyncModels(modelIds)
}
})
.finally(() => {
setIsLoading(false)
})
}
}, [provider, controller])
const modelList = useMemo(() => {
if (usesOpenRouterModels(provider)) {
if (usesOpenRouterModels(provider) || provider === "oca") {
return asyncModels
}
return getModelList(provider)
@@ -180,7 +194,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
}
// If async fetch returned no models, render nothing
if (usesOpenRouterModels(provider) && modelList.length === 0) {
if ((usesOpenRouterModels(provider) || provider === "oca") && modelList.length === 0) {
return null
}
+88
View File
@@ -0,0 +1,88 @@
/**
* OCA (Oracle Cloud Assist) employee check component.
* Shows a checkbox for "I'm an Oracle Employee" and a sign-in button.
* Sets ocaMode in state before triggering the OAuth flow.
*/
import { Box, Text, useInput } from "ink"
// biome-ignore lint/style/useImportType: React is used as a value by JSX (jsx: "react" in tsconfig)
import React, { useCallback, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
interface OcaEmployeeCheckProps {
/** Whether this component is active and should handle input */
isActive: boolean
/** Called when user confirms and wants to proceed with sign-in */
onSignIn: () => void
/** Called when user presses Escape to go back */
onCancel: () => void
}
export const OcaEmployeeCheck: React.FC<OcaEmployeeCheckProps> = ({ isActive, onSignIn, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [isEmployee, setIsEmployee] = useState(true) // Default to checked (internal), matching extension behavior
const [selectedIndex, setSelectedIndex] = useState(0) // 0 = checkbox, 1 = sign in button
const ITEM_COUNT = 2
const handleSignIn = useCallback(async () => {
// Persist ocaMode to state before starting auth
const stateManager = StateManager.get()
stateManager.setGlobalState("ocaMode", isEmployee ? "internal" : "external")
await stateManager.flushPendingState()
onSignIn()
}, [isEmployee, onSignIn])
useInput(
(_input, key) => {
if (key.escape) {
onCancel()
return
}
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : ITEM_COUNT - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < ITEM_COUNT - 1 ? prev + 1 : 0))
} else if (key.tab || (key.return && selectedIndex === 0)) {
// Toggle checkbox when Tab is pressed or Enter on checkbox item
if (selectedIndex === 0) {
setIsEmployee((prev) => !prev)
}
} else if (key.return && selectedIndex === 1) {
// Sign in button
handleSignIn()
}
},
{ isActive: isRawModeSupported && isActive },
)
return (
<Box flexDirection="column">
<Text color="white">Oracle Code Assist</Text>
<Text> </Text>
{/* Checkbox: I'm an Oracle Employee */}
<Text>
<Text bold color={selectedIndex === 0 ? COLORS.primaryBlue : undefined}>
{selectedIndex === 0 ? "" : " "}{" "}
</Text>
<Text color={selectedIndex === 0 || isEmployee ? COLORS.primaryBlue : "gray"}>{isEmployee ? "[✓]" : "[ ]"}</Text>
<Text color={selectedIndex === 0 ? COLORS.primaryBlue : "white"}> I'm an Oracle Employee</Text>
{selectedIndex === 0 && <Text color="gray"> (Tab to toggle)</Text>}
</Text>
{/* Sign in button */}
<Text>
<Text bold color={selectedIndex === 1 ? COLORS.primaryBlue : undefined}>
{selectedIndex === 1 ? "" : " "}{" "}
</Text>
<Text color={selectedIndex === 1 ? COLORS.primaryBlue : "white"}>Sign in with Oracle Code Assist</Text>
{selectedIndex === 1 && <Text color="gray"> (Enter)</Text>}
</Text>
<Text> </Text>
<Text color="gray">Please ask your IT administrator to set up Oracle Code Assist as a model provider.</Text>
<Text> </Text>
<Text color="gray">Arrows to navigate, Tab to toggle, Enter to continue, Esc to go back</Text>
</Box>
)
}
+35 -3
View File
@@ -14,10 +14,12 @@ import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { StringRequest } from "@/shared/proto/cline/common"
import { openExternal } from "@/utils/env"
import { supportsReasoningEffortForModel } from "@/utils/model-utils"
import { version as CLI_VERSION } from "../../package.json"
@@ -37,6 +39,7 @@ import {
} from "./FeaturedModelPicker"
import { LanguagePicker } from "./LanguagePicker"
import { hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { OrganizationPicker } from "./OrganizationPicker"
import { Panel, PanelTab } from "./Panel"
import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
@@ -82,6 +85,12 @@ const TABS: PanelTab[] = [
// Settings configuration for simple boolean toggles
const FEATURE_SETTINGS = {
subagents: {
stateKey: "subagentsEnabled",
default: false,
label: "Subagents",
description: "Let Cline run focused subagents in parallel to explore the codebase for you",
},
autoCondense: {
stateKey: "useAutoCondense",
default: false,
@@ -112,6 +121,12 @@ const FEATURE_SETTINGS = {
label: "Parallel tool calling",
description: "Allow multiple tools in a single response",
},
doubleCheckCompletion: {
stateKey: "doubleCheckCompletionEnabled",
default: false,
label: "Double-check completion",
description: "Reject first completion attempt and require re-verification",
},
} as const
type FeatureKey = keyof typeof FEATURE_SETTINGS
@@ -150,6 +165,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
const [isShowingOcaEmployeeCheck, setIsShowingOcaEmployeeCheck] = useState(false)
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
const [apiKeyValue, setApiKeyValue] = useState("")
@@ -223,6 +239,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
// OCA auth hook
const handleOcaAuthSuccess = useCallback(async () => {
await applyProviderConfig({ providerId: "oca", controller })
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
await refreshOcaModels(controller!, StringRequest.create({ value: "" }))
setProvider("oca")
refreshModelIds()
}, [controller, refreshModelIds])
@@ -1066,8 +1084,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
setProvider("oca")
refreshModelIds()
} else {
// Not logged in - trigger OAuth
startOcaAuth()
// Not logged in - show employee check before auth
setIsShowingOcaEmployeeCheck(true)
}
return
}
@@ -1358,7 +1376,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
return
}
},
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
)
// Render content
@@ -1534,6 +1552,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
)
}
if (isShowingOcaEmployeeCheck) {
return (
<OcaEmployeeCheck
isActive={isShowingOcaEmployeeCheck}
onCancel={() => setIsShowingOcaEmployeeCheck(false)}
onSignIn={() => {
setIsShowingOcaEmployeeCheck(false)
startOcaAuth()
}}
/>
)
}
if (isWaitingForOcaAuth) {
return (
<Box flexDirection="column">
@@ -1715,6 +1746,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
!!codexAuthError ||
isPickingOrganization ||
isWaitingForClineAuth ||
isShowingOcaEmployeeCheck ||
isWaitingForOcaAuth ||
isEditing
+361
View File
@@ -0,0 +1,361 @@
import type { ClineAskUseSubagents, ClineMessage, ClineSaySubagentStatus } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React from "react"
import { COLORS } from "../constants/colors"
import { useTerminalSize } from "../hooks/useTerminalSize"
import { jsonParseSafe } from "../utils/parser"
interface SubagentMessageProps {
message: ClineMessage
isStreaming?: boolean
mode?: "act" | "plan"
}
const TREE_PREFIX_WIDTH = 5
const MIN_PROMPT_WIDTH = 20
const DotRow: React.FC<{ children: React.ReactNode; color?: string; flashing?: boolean }> = ({
children,
color,
flashing = false,
}) => (
<Box flexDirection="row">
<Box width={2}>
{flashing ? (
<Text color={color}>
<Spinner type="toggle8" />
</Text>
) : (
<Text color={color}></Text>
)}
</Box>
<Box flexGrow={1}>{children}</Box>
</Box>
)
function formatCompactTokens(tokens: number | undefined): string {
const value = Number.isFinite(tokens) ? Math.max(0, tokens || 0) : 0
return new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
})
.format(value)
.toLowerCase()
}
function formatCompactCost(cost: number | undefined): string {
const value = Number.isFinite(cost) ? Math.max(0, cost || 0) : 0
const maximumFractionDigits = value >= 0.01 ? 2 : 4
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits,
}).format(value)
}
function formatSubagentStatsValues(
toolCalls: number | undefined,
contextTokens: number | undefined,
totalCost: number | undefined,
latestToolCall?: string,
) {
const safeToolCalls = Number.isFinite(toolCalls) ? Math.max(0, toolCalls || 0) : 0
const toolUses = safeToolCalls === 1 ? "tool use" : "tool uses"
const tokensUsed = formatCompactTokens(contextTokens || 0)
const formattedCost = formatCompactCost(totalCost || 0)
const stats = `${safeToolCalls} ${toolUses} · ${tokensUsed} tokens · ${formattedCost}`
const latestTool = latestToolCall?.trim()
return latestTool ? `${latestTool} · ${stats}` : stats
}
function wrapPrompt(text: string, width: number): string[] {
if (!text) {
return [""]
}
const normalizedWidth = Math.max(1, width)
const wrappedLines: string[] = []
const paragraphs = text.split("\n")
for (const paragraph of paragraphs) {
const words = paragraph.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) {
wrappedLines.push("")
continue
}
let line = ""
for (const word of words) {
if (!line) {
if (word.length <= normalizedWidth) {
line = word
continue
}
let remaining = word
while (remaining.length > normalizedWidth) {
wrappedLines.push(remaining.slice(0, normalizedWidth))
remaining = remaining.slice(normalizedWidth)
}
line = remaining
continue
}
if (line.length + 1 + word.length <= normalizedWidth) {
line = `${line} ${word}`
continue
}
wrappedLines.push(line)
if (word.length <= normalizedWidth) {
line = word
continue
}
let remaining = word
while (remaining.length > normalizedWidth) {
wrappedLines.push(remaining.slice(0, normalizedWidth))
remaining = remaining.slice(normalizedWidth)
}
line = remaining
}
if (line) {
wrappedLines.push(line)
}
}
return wrappedLines.length > 0 ? wrappedLines : [text]
}
const TreePromptRow: React.FC<{
prefix: React.ReactNode
continuationPrefix: string
prompt: string
promptWidth: number
color?: string
}> = ({ prefix, continuationPrefix, prompt, promptWidth, color }) => {
const lines = wrapPrompt(prompt, promptWidth)
return (
<Box flexDirection="column" width="100%">
{lines.map((line, index) => (
<Box flexDirection="row" key={`${line}-${index}`} width="100%">
<Box flexShrink={0} width={TREE_PREFIX_WIDTH}>
{index === 0 ? prefix : <Text color="gray">{continuationPrefix}</Text>}
</Box>
<Box flexGrow={1}>
<Text color={color}>{line}</Text>
</Box>
</Box>
))}
</Box>
)
}
const TreeStatsRow: React.FC<{ prefix: string; stats: string }> = ({ prefix, stats }) => (
<Box flexDirection="row" width="100%">
<Box flexShrink={0} width={TREE_PREFIX_WIDTH}>
<Text color="gray">{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text color="gray"> {stats}</Text>
</Box>
</Box>
)
export const SubagentMessage: React.FC<SubagentMessageProps> = ({ message, mode, isStreaming }) => {
const { type, ask, say, text, partial } = message
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
const { columns } = useTerminalSize()
const promptWidth = Math.max(MIN_PROMPT_WIDTH, columns - 2 - TREE_PREFIX_WIDTH)
if ((type === "ask" && ask === "use_subagents") || say === "use_subagents") {
const parsed = text
? jsonParseSafe<ClineAskUseSubagents>(text, {
prompts: [],
})
: { prompts: [] }
const prompts = (parsed.prompts || []).map((prompt) => prompt?.trim()).filter(Boolean)
if (prompts.length === 0) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<Text color={toolColor}>Cline wants to run subagents:</Text>
</DotRow>
</Box>
)
}
const singular = prompts.length === 1
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
<Text color={toolColor}>{singular ? "Cline wants to run a subagent:" : "Cline wants to run subagents:"}</Text>
</DotRow>
<Box flexDirection="column" marginLeft={2} width="100%">
{prompts.map((prompt, index) => {
const isLastPrompt = index === prompts.length - 1
const branch = isLastPrompt ? "└─" : "├─"
const continuationPrefix = isLastPrompt ? " " : "│ "
const shouldShowPromptStats = partial !== true || !isLastPrompt
return (
<Box flexDirection="column" key={`${prompt}-${index}`}>
<TreePromptRow
color={toolColor}
continuationPrefix={continuationPrefix}
prefix={<Text color={toolColor}>{`${branch} `}</Text>}
prompt={prompt}
promptWidth={promptWidth}
/>
{shouldShowPromptStats && (
<TreeStatsRow
prefix={continuationPrefix}
stats={formatSubagentStatsValues(undefined, undefined, undefined)}
/>
)}
</Box>
)
})}
</Box>
</Box>
)
}
if (say === "subagent" && text) {
const parsed = jsonParseSafe<ClineSaySubagentStatus>(text, {
status: "running",
total: 0,
completed: 0,
successes: 0,
failures: 0,
toolCalls: 0,
inputTokens: 0,
outputTokens: 0,
contextWindow: 0,
maxContextTokens: 0,
maxContextUsagePercentage: 0,
items: [],
})
const items = parsed.items || []
if (items.length === 0) {
return null
}
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
<Text color={toolColor}>
{items.length === 1 ? "Cline is running a subagent:" : "Cline is running subagents:"}
</Text>
</DotRow>
<Box flexDirection="column" marginLeft={2} width="100%">
{items.map((entry, index) => {
const isLastEntry = index === items.length - 1
const branch = isLastEntry ? "└─" : "├─"
const continuationPrefix = isLastEntry ? " " : "│ "
const key = `${entry.index}-${index}`
const shouldShowStats = true
if (entry.status === "completed") {
return (
<Box flexDirection="column" key={key}>
<TreePromptRow
color="green"
continuationPrefix={continuationPrefix}
prefix={
<Box flexDirection="row">
<Text color="gray">{`${branch} `}</Text>
<Text color="green"></Text>
</Box>
}
prompt={entry.prompt}
promptWidth={promptWidth}
/>
<TreeStatsRow
prefix={continuationPrefix}
stats={formatSubagentStatsValues(
entry.toolCalls,
entry.contextTokens,
entry.totalCost,
entry.latestToolCall,
)}
/>
</Box>
)
}
if (entry.status === "failed") {
return (
<Box flexDirection="column" key={key}>
<TreePromptRow
color="red"
continuationPrefix={continuationPrefix}
prefix={
<Box flexDirection="row">
<Text color="gray">{`${branch} `}</Text>
<Text color="red"></Text>
</Box>
}
prompt={entry.prompt}
promptWidth={promptWidth}
/>
<TreeStatsRow
prefix={continuationPrefix}
stats={formatSubagentStatsValues(
entry.toolCalls,
entry.contextTokens,
entry.totalCost,
entry.latestToolCall,
)}
/>
</Box>
)
}
return (
<Box flexDirection="column" key={key}>
<TreePromptRow
color={toolColor}
continuationPrefix={continuationPrefix}
prefix={
<Box flexDirection="row">
<Text color="gray">{branch} </Text>
{entry.status === "running" ? (
<Text color={toolColor}>
<Spinner type="dots" />
</Text>
) : (
<Text color={toolColor}></Text>
)}
</Box>
}
prompt={entry.prompt}
promptWidth={promptWidth}
/>
{shouldShowStats && (
<TreeStatsRow
prefix={continuationPrefix}
stats={formatSubagentStatsValues(
entry.toolCalls,
entry.contextTokens,
entry.totalCost,
entry.latestToolCall,
)}
/>
)}
</Box>
)
})}
</Box>
</Box>
)
}
return null
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest"
import { getAllFeaturedModels } from "./featured-models"
describe("featured models", () => {
it("includes display names for all featured models", () => {
const models = getAllFeaturedModels()
for (const model of models) {
expect(model.name).toBeTruthy()
}
})
})
+8 -14
View File
@@ -10,7 +10,7 @@ export interface FeaturedModel {
labels: string[]
}
export const FEATURED_MODELS = {
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
recommended: [
{
id: "anthropic/claude-opus-4.6",
@@ -30,33 +30,27 @@ export const FEATURED_MODELS = {
description: "1M context window for large codebases",
labels: ["TRENDING"],
},
] as FeaturedModel[],
],
free: [
{
id: "minimax/minimax-m2.1",
name: "MiniMax M2.1",
description: "Exceptional Multi-Programming Language Capabilities",
labels: ["FREE"],
},
{
id: "moonshotai/kimi-k2.5",
name: "Kimi K2.5",
description: "State-of-the-art model topping benchmarks",
id: "minimax/minimax-m2.5",
name: "MiniMax M2.5",
description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows",
labels: ["FREE"],
},
{
id: "kwaipilot/kat-coder-pro",
name: "KAT Coder Pro",
description: "Advanced agentic coding model",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
labels: ["FREE"],
},
{
id: "arcee-ai/trinity-large-preview:free",
name: "Trinity Large Preview",
description: "US built open source coding model",
description: "Arcee AI's advanced large preview model in the Trinity series",
labels: ["FREE"],
},
] as FeaturedModel[],
],
}
export function getAllFeaturedModels(): FeaturedModel[] {
+13 -1
View File
@@ -6,6 +6,7 @@
* - Ctrl+A/E: start/end of line
* - Ctrl+W: delete word backwards
* - Ctrl+U: delete to start of line
* - Ctrl+K: delete to end of line
*
* Note: Home/End keys are handled by useHomeEndKeys hook because Ink doesn't
* expose them in useInput (it sets input='' for these keys).
@@ -152,6 +153,14 @@ export function useTextInput(): UseTextInputReturn {
}
}, [])
const deleteToEnd = useCallback(() => {
const pos = cursorRef.current
if (pos < textRef.current.length) {
setTextState((prev) => prev.slice(0, pos))
// Cursor stays at same position (now at end of text)
}
}, [])
// Cursor movement (internal, used by handlers)
const moveToStart = useCallback(() => setCursorPosState(0), [])
const moveToEnd = useCallback(() => setCursorPosState(textRef.current.length), [])
@@ -190,6 +199,9 @@ export function useTextInput(): UseTextInputReturn {
case "u": // Ctrl+U - delete to start
deleteToStart()
return true
case "k": // Ctrl+K - delete to end
deleteToEnd()
return true
case "w": // Ctrl+W - delete word backwards
deleteWordBefore()
return true
@@ -197,7 +209,7 @@ export function useTextInput(): UseTextInputReturn {
return false
}
},
[moveToStart, moveToEnd, deleteToStart, deleteWordBefore],
[moveToStart, moveToEnd, deleteToStart, deleteToEnd, deleteWordBefore],
)
return {
+14
View File
@@ -32,6 +32,7 @@ describe("CLI Commands", () => {
.option("--config <path>", "Configuration directory")
.option("--thinking [tokens]", "Enable extended thinking")
.option("--reasoning-effort <effort>", "Reasoning effort")
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
.action(() => {})
program
@@ -70,6 +71,7 @@ describe("CLI Commands", () => {
.option("--config <path>", "Configuration directory")
.option("--thinking [tokens]", "Enable extended thinking")
.option("--reasoning-effort <effort>", "Reasoning effort")
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
.action(() => {})
})
@@ -162,6 +164,13 @@ describe("CLI Commands", () => {
expect(taskCmd.opts().reasoningEffort).toBe("high")
})
it("should parse --max-consecutive-mistakes option", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--max-consecutive-mistakes", "999"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
})
it("should parse short flags", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
@@ -307,6 +316,11 @@ describe("CLI Commands", () => {
program.parse(["node", "cli", "--reasoning-effort", "medium"])
expect(program.opts().reasoningEffort).toBe("medium")
})
it("should parse --max-consecutive-mistakes option", () => {
program.parse(["node", "cli", "--max-consecutive-mistakes", "7"])
expect(program.opts().maxConsecutiveMistakes).toBe("7")
})
})
describe("command structure", () => {
+124 -67
View File
@@ -15,10 +15,9 @@ import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
import { BannerService } from "@/services/banner/BannerService"
import { ErrorService } from "@/services/error/ErrorService"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/PostHogClientProvider"
import { HistoryItem } from "@/shared/HistoryItem"
import { Logger } from "@/shared/services/Logger"
import { Session } from "@/shared/services/Session"
@@ -56,12 +55,49 @@ interface TaskOptions {
config?: string
thinking?: boolean | string
reasoningEffort?: string
maxConsecutiveMistakes?: string
yolo?: boolean
doubleCheckCompletion?: boolean
timeout?: string
json?: boolean
stdinWasPiped?: boolean
}
let telemetryDisposed = false
async function disposeTelemetryServices(): Promise<void> {
if (telemetryDisposed) {
return
}
telemetryDisposed = true
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
}
/**
* Restore yoloModeToggled to its original value from before this CLI session.
* This ensures the --yolo flag is session-only and doesn't leak into future runs.
* Must be called before flushPendingState so the restored value gets persisted.
*/
function restoreYoloState(): void {
if (savedYoloModeToggled !== null) {
try {
StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled)
savedYoloModeToggled = null
} catch {
// StateManager may not be initialized (e.g., early exit before init)
}
}
}
async function disposeCliContext(ctx: CliContext): Promise<void> {
restoreYoloState()
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeTelemetryServices()
}
function setModeScopedState(currentMode: "act" | "plan", setter: (mode: "act" | "plan") => void): void {
const stateManager = StateManager.get()
setter(currentMode)
@@ -84,9 +120,23 @@ function normalizeReasoningEffort(value?: string): OpenaiReasoningEffort | undef
}
printWarning(
`Invalid --reasoning-effort '${value}'. Using 'low'. Valid values: ${OPENAI_REASONING_EFFORT_OPTIONS.join(", ")}.`,
`Invalid --reasoning-effort '${value}'. Using 'medium'. Valid values: ${OPENAI_REASONING_EFFORT_OPTIONS.join(", ")}.`,
)
return "low"
return "medium"
}
function normalizeMaxConsecutiveMistakes(value?: string): number | undefined {
if (value === undefined) {
return undefined
}
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed) || parsed < 1) {
printWarning(`Invalid --max-consecutive-mistakes value '${value}'. Expected integer >= 1.`)
return undefined
}
return parsed
}
/**
@@ -148,11 +198,26 @@ function applyTaskOptions(options: TaskOptions): void {
telemetryService.captureHostEvent("reasoning_effort_flag", reasoningEffort)
}
// Set yolo mode based on --yolo flag
const maxConsecutiveMistakes = normalizeMaxConsecutiveMistakes(options.maxConsecutiveMistakes)
if (maxConsecutiveMistakes !== undefined) {
StateManager.get().setGlobalState("maxConsecutiveMistakes", maxConsecutiveMistakes)
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
}
// Override yolo mode only if --yolo flag is explicitly passed.
// The original value is saved in initializeCli and restored on exit.
if (options.yolo) {
StateManager.get().setGlobalState("yoloModeToggled", true)
const state = StateManager.get()
savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false
state.setGlobalState("yoloModeToggled", true)
telemetryService.captureHostEvent("yolo_flag", "true")
}
// Set double-check completion based on flag
if (options.doubleCheckCompletion) {
StateManager.get().setGlobalState("doubleCheckCompletionEnabled", true)
telemetryService.captureHostEvent("double_check_completion_flag", "true")
}
}
/**
@@ -204,9 +269,7 @@ async function runTaskInPlainTextMode(
const hasAuth = await isAuthConfigured()
if (!hasAuth) {
printWarning("Not authenticated. Please run 'cline auth' first to configure your API credentials.")
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
exit(1)
}
@@ -225,9 +288,7 @@ async function runTaskInPlainTextMode(
})
// Cleanup
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
// Ensure stdout is fully drained before exiting - critical for piping
await drainStdout()
@@ -239,9 +300,7 @@ async function runTaskInPlainTextMode(
*/
function createInkCleanup(ctx: CliContext, onTaskError?: () => boolean): () => Promise<void> {
return async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
if (onTaskError?.()) {
printWarning("Task ended with errors.")
exit(1)
@@ -255,6 +314,9 @@ let activeContext: CliContext | null = null
let isShuttingDown = false
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
let isPlainTextMode = false
// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit.
// The --yolo flag should only affect the current invocation, not persist across runs.
let savedYoloModeToggled: boolean | null = null
/**
* Wait for stdout to fully drain before exiting.
@@ -296,15 +358,26 @@ function setupSignalHandlers() {
printWarning(`${signal} received, shutting down...`)
try {
// Restore yolo state before any cleanup - this is idempotent and safe
// even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null)
restoreYoloState()
if (activeContext) {
const task = activeContext.controller.task
if (task) {
task.abortTask()
}
await activeContext.controller.stateManager.flushPendingState()
await activeContext.controller.dispose()
await disposeCliContext(activeContext)
} else {
// Best-effort flush of restored yolo state when no active context
try {
await StateManager.get().flushPendingState()
} catch {
// StateManager may not be initialized yet
}
await ErrorService.get().dispose()
await disposeTelemetryServices()
}
await ErrorService.get().dispose()
} catch {
// Best effort cleanup
}
@@ -357,8 +430,17 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
workspaceDir: workspacePath,
})
await ClineEndpoint.initialize()
await initializeDistinctId(extensionContext)
// Set up output channel and Logger early so ClineEndpoint.initialize logs are captured
const outputChannel = window.createOutputChannel("Cline CLI")
const logToChannel = (message: string) => outputChannel.appendLine(message)
// Configure the shared Logging class early to capture all initialization logs
Logger.subscribe(logToChannel)
await ClineEndpoint.initialize(EXTENSION_DIR)
// Auto-update check (after endpoints initialized, so we can detect bundled configs)
autoUpdateOnStartup(CLI_VERSION)
// Initialize/reset session tracking for this CLI run
Session.reset()
@@ -367,11 +449,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
AuthHandler.getInstance().setEnabled(true)
}
const outputChannel = window.createOutputChannel("Cline CLI")
outputChannel.appendLine(
`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}, Log dir: ${CLINE_CLI_DIR.log}`,
)
const logToChannel = (message: string) => outputChannel.appendLine(message)
HostProvider.initialize(
() => new CliWebviewProvider(extensionContext as any),
@@ -380,28 +460,24 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
() => new StandaloneTerminalManager(),
createCliHostBridgeProvider(workspacePath),
logToChannel,
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
async (path: string) => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl(path) : ""),
getCliBinaryPath,
EXTENSION_DIR,
DATA_DIR,
)
await StateManager.initialize(extensionContext as any)
await ErrorService.initialize()
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(extensionContext)
// Configure the shared Logging class to use HostProvider's output channel
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg))
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
const controller = webview.controller
BannerService.initialize(webview.controller)
telemetryService.captureExtensionActivated()
telemetryService.captureHostEvent("cline_cli", "initialized")
await telemetryService.captureExtensionActivated()
await telemetryService.captureHostEvent("cline_cli", "initialized")
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
activeContext = ctx
@@ -483,8 +559,7 @@ async function runTask(prompt: string, options: TaskOptions & { images?: string[
taskError = true
},
onWelcomeExit: () => {
// User pressed Esc
exit(0)
// User pressed Esc; Ink exits and cleanup handles process exit.
},
}),
createInkCleanup(ctx, () => taskError),
@@ -509,9 +584,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
if (sortedHistory.length === 0) {
printInfo("No task history found.")
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
exit(0)
}
@@ -525,9 +598,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
isRawModeSupported: checkRawModeSupport(),
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
exit(0)
},
)
@@ -556,9 +627,7 @@ async function showConfig(options: { config?: string }) {
isRawModeSupported: checkRawModeSupport(),
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
exit(0)
},
)
@@ -634,17 +703,15 @@ async function runAuth(options: {
baseurl: options.baseurl,
})
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
if (!result.success) {
printWarning(result.error || "Quick setup failed")
telemetryService.captureHostEvent("auth", "error")
await telemetryService.captureHostEvent("auth", "error")
await disposeCliContext(ctx)
exit(1)
}
telemetryService.captureHostEvent("auth", "completed")
await telemetryService.captureHostEvent("auth", "completed")
await disposeCliContext(ctx)
exit(0)
}
@@ -658,7 +725,6 @@ async function runAuth(options: {
isRawModeSupported: checkRawModeSupport(),
onComplete: () => {
telemetryService.captureHostEvent("auth", "completed")
exit(0)
},
onError: () => {
telemetryService.captureHostEvent("auth", "error")
@@ -666,16 +732,10 @@ async function runAuth(options: {
},
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
await disposeCliContext(ctx)
exit(authError ? 1 : 0)
},
)
if (authError) {
process.exit(1)
}
}
// Setup CLI commands
@@ -701,7 +761,9 @@ program
.option("--config <path>", "Path to Cline configuration directory")
.option("--thinking [tokens]", "Enable extended thinking (default: 1024 tokens)")
.option("--reasoning-effort <effort>", "Reasoning effort: none|low|medium|high|xhigh")
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
.option("--json", "Output messages as JSON instead of styled text")
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
.option("-T, --taskId <id>", "Resume an existing task by ID")
.action((prompt, options) => {
if (options.taskId) {
@@ -842,9 +904,7 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
if (!historyItem) {
printWarning(`Task not found: ${taskId}`)
printInfo("Use 'cline history' to see available tasks.")
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
exit(1)
}
@@ -877,7 +937,7 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
taskError = true
},
onWelcomeExit: () => {
exit(0)
// User pressed Esc; Ink exits and cleanup handles process exit.
},
}),
createInkCleanup(ctx, () => taskError),
@@ -904,16 +964,14 @@ async function showWelcome(options: { verbose?: boolean; cwd?: string; config?:
controller: ctx.controller,
isRawModeSupported: checkRawModeSupport(),
onWelcomeExit: () => {
exit(0)
// User pressed Esc; Ink exits and cleanup handles process exit.
},
onError: () => {
hadError = true
},
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
await disposeCliContext(ctx)
exit(hadError ? 1 : 0)
},
)
@@ -932,7 +990,9 @@ program
.option("--config <path>", "Configuration directory")
.option("--thinking [tokens]", "Enable extended thinking (default: 1024 tokens)")
.option("--reasoning-effort <effort>", "Reasoning effort: none|low|medium|high|xhigh")
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
.option("--json", "Output messages as JSON instead of styled text")
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
.option("-T, --taskId <id>", "Resume an existing task by ID")
.action(async (prompt, options) => {
@@ -1002,8 +1062,5 @@ program
}
})
// Background auto-update check (non-blocking)
autoUpdateOnStartup(CLI_VERSION)
// Parse and run
program.parse()
+28
View File
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { emitTaskStartedMessage } from "./task-start-output"
describe("emitTaskStartedMessage", () => {
afterEach(() => {
vi.restoreAllMocks()
})
it("writes structured task_started JSON to stdout in json mode", () => {
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
emitTaskStartedMessage("task-123", true)
expect(stdoutWriteSpy).toHaveBeenCalledWith('{"type":"task_started","taskId":"task-123"}\n')
expect(stderrWriteSpy).not.toHaveBeenCalled()
})
it("writes human-readable task started line to stderr in non-json mode", () => {
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
emitTaskStartedMessage("task-456", false)
expect(stderrWriteSpy).toHaveBeenCalledWith("Task started: task-456\n")
expect(stdoutWriteSpy).not.toHaveBeenCalled()
})
})
+18
View File
@@ -17,6 +17,7 @@ import type { Controller } from "@/core/controller"
import { getRequestRegistry } from "@/core/controller/grpc-handler"
import { subscribeToState } from "@/core/controller/state/subscribeToState"
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { emitTaskStartedMessage } from "./task-start-output"
export interface PlainTextTaskOptions {
controller: Controller
@@ -52,6 +53,7 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
})
let hasError = false
let hasEmittedTaskStarted = false
// Track which messages have been processed (by timestamp)
const processedMessages = new Map<number, string>()
@@ -62,6 +64,20 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
// results AFTER this time should trigger task completion.
const completionCutoffTs = Date.now()
const emitTaskStarted = () => {
if (hasEmittedTaskStarted) {
return
}
const taskId = controller.task?.taskId
if (!taskId) {
return
}
emitTaskStartedMessage(taskId, Boolean(jsonOutput))
hasEmittedTaskStarted = true
}
// Helper to process a message and track completion state
const processMessage = (message: ClineMessage) => {
const ts = message.ts || 0
@@ -119,6 +135,7 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
if (options.taskId) {
// Load the existing task
await showTaskWithId(controller, StringRequest.create({ value: options.taskId }))
emitTaskStarted()
// If a prompt was provided, send it as a message to the resumed task
if (prompt && controller.task) {
@@ -131,6 +148,7 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
} else if (prompt) {
// Start a new task with the prompt
await controller.initTask(prompt, imageDataUrls)
emitTaskStarted()
} else {
throw new Error("Either taskId or prompt must be provided")
}
+8
View File
@@ -0,0 +1,8 @@
export function emitTaskStartedMessage(taskId: string, jsonOutput: boolean): void {
if (jsonOutput) {
process.stdout.write(JSON.stringify({ type: "task_started", taskId }) + "\n")
return
}
process.stderr.write(`Task started: ${taskId}\n`)
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process"
import { realpathSync } from "node:fs"
import { exit } from "node:process"
import { ClineEndpoint } from "@/config"
import { fetch } from "@/shared/net"
import { printInfo, printWarning } from "./display"
@@ -107,7 +108,7 @@ async function getLatestVersion(currentVersion: string): Promise<string | null>
* process to install if a newer version is available.
*
* Supports npm, pnpm, yarn, and bun global installs.
* Skipped for npx, local dev, and unknown installations.
* Skipped for npx, local dev, unknown installations, and bundled enterprise packages.
* Can be disabled with CLINE_NO_AUTO_UPDATE=1 environment variable.
*/
export function autoUpdateOnStartup(currentVersion: string): void {
@@ -121,6 +122,11 @@ export function autoUpdateOnStartup(currentVersion: string): void {
return
}
// Skip if using bundled enterprise config (single source of truth)
if (ClineEndpoint.isBundledConfig()) {
return
}
const { updateCommand } = getInstallationInfo(currentVersion)
if (!updateCommand) {
return
+7 -2
View File
@@ -4,6 +4,7 @@
*/
import { mkdirSync } from "node:fs"
import { fileURLToPath } from "node:url"
import os from "os"
import path from "path"
import { ExtensionRegistryInfo } from "@/registry"
@@ -11,6 +12,10 @@ import { ClineExtensionContext } from "@/shared/cline"
import { ClineFileStorage } from "@/shared/storage"
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SETTINGS_SUBFOLDER = "data"
/**
@@ -140,8 +145,8 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
// For CLI, extension dir is the root of the project (parent of cli)
const EXTENSION_DIR = path.resolve(__dirname, "..", "..")
// For CLI, extension dir is the package root (one level up from dist/)
const EXTENSION_DIR = path.resolve(__dirname, "..")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
const extension: ClineExtensionContext["extension"] = {
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
},
resolve: {
alias: {
vscode: path.resolve(__dirname, "src/vscode-shim.ts"),
// Match tsconfig paths - baseUrl is parent directory
"@": path.resolve(__dirname, "../src"),
"@api": path.resolve(__dirname, "../src/core/api"),
+1
View File
@@ -164,6 +164,7 @@
"features/multiroot-workspace",
"features/plan-and-act",
"features/skills",
"features/subagents",
{
"group": "Slash Commands",
"pages": [
@@ -0,0 +1,287 @@
---
title: "Bundled Endpoints Configuration"
description: "Enterprise guide for distributing Cline with pre-configured endpoints"
---
# Bundled Endpoints Configuration
This guide explains how enterprise customers can distribute Cline with pre-configured endpoints bundled directly into the installation packages.
## Overview
Cline supports bundling custom endpoint configurations directly into distribution packages (VSIX, NPM, or JetBrains). This eliminates the need for end users to manually configure endpoints, ensuring consistent configuration across your organization.
### Configuration Priority
When Cline starts, it checks for endpoints configuration in this order:
1. **Bundled endpoints.json** (in extension installation directory) - Highest priority
2. **User endpoints.json** (`~/.cline/endpoints.json`) - Fallback
3. **Built-in endpoints** (standard Cline URLs) - Default
When a bundled `endpoints.json` is found, Cline automatically switches to self-hosted mode and uses those endpoints exclusively.
## Prerequisites
- Official Cline release package (VSIX, TGZ, or ZIP)
- Your `endpoints.json` configuration file
- `jq` command-line tool (for JSON validation)
- `unzip`, `zip`, `tar` utilities
## Creating endpoints.json
Create a JSON file with your organization's endpoints:
```json
{
"appBaseUrl": "https://cline.yourcompany.com",
"apiBaseUrl": "https://api-cline.yourcompany.com",
"mcpBaseUrl": "https://api-cline.yourcompany.com/v1/mcp"
}
```
### Required Fields
All three fields are required and must be valid URLs:
- **appBaseUrl**: Web application base URL
- **apiBaseUrl**: API server base URL
- **mcpBaseUrl**: MCP (Model Context Protocol) server URL
### Validation
The packaging scripts automatically validate:
- Valid JSON syntax
- All required fields present
- Non-empty string values
- Valid URL format (must start with `http://` or `https://`)
## Packaging Scripts
Cline provides three scripts for adding bundled endpoints to packages:
### VSCode Extension (VSIX)
```bash
./scripts/add-endpoints-to-vsix.sh \
cline-3.55.0.vsix \
cline-3.55.0-enterprise.vsix \
endpoints.json
```
This script:
1. Extracts the VSIX package
2. Adds `endpoints.json` to the `extension/` directory
3. Repackages as a new VSIX file
### NPM Package (CLI)
```bash
./scripts/add-endpoints-to-npm.sh \
cline-3.55.0.tgz \
cline-3.55.0-enterprise.tgz \
endpoints.json
```
This script:
1. Extracts the NPM tarball
2. Adds `endpoints.json` to the package root
3. Repackages as a new tarball
### JetBrains Plugin (ZIP)
```bash
./scripts/add-endpoints-to-jetbrains.sh \
cline-jetbrains-3.55.0.zip \
cline-jetbrains-3.55.0-enterprise.zip \
endpoints.json
```
This script:
1. Extracts the ZIP package
2. Adds `endpoints.json` to the plugin directory
3. Repackages as a new ZIP file
## Distribution Workflow
### 1. Download Official Release
Download the official Cline package for your platform:
```bash
# VSCode - from marketplace or GitHub releases
curl -LO https://github.com/cline/cline/releases/download/v3.55.0/cline-3.55.0.vsix
# NPM - from npm registry
npm pack @cline/cline@3.55.0
# JetBrains - from marketplace or GitHub releases
curl -LO https://github.com/cline/cline/releases/download/v3.55.0/cline-jetbrains-3.55.0.zip
```
### 2. Create Endpoints Configuration
Create your `endpoints.json` file:
```json
{
"appBaseUrl": "https://cline.internal.company.com",
"apiBaseUrl": "https://cline-api.internal.company.com",
"mcpBaseUrl": "https://cline-api.internal.company.com/v1/mcp"
}
```
### 3. Run Packaging Script
Choose the appropriate script for your platform:
```bash
# VSCode
./scripts/add-endpoints-to-vsix.sh \
cline-3.55.0.vsix \
cline-3.55.0-yourcompany.vsix \
endpoints.json
# CLI
./scripts/add-endpoints-to-npm.sh \
cline-3.55.0.tgz \
cline-3.55.0-yourcompany.tgz \
endpoints.json
# JetBrains
./scripts/add-endpoints-to-jetbrains.sh \
cline-jetbrains-3.55.0.zip \
cline-jetbrains-3.55.0-yourcompany.zip \
endpoints.json
```
### 4. Distribute to Users
Distribute the enterprise package to your users through your internal channels:
- **VSCode**: Install via `code --install-extension cline-3.55.0-yourcompany.vsix`
- **CLI**: Install via `npm install -g cline-3.55.0-yourcompany.tgz`
- **JetBrains**: Install through IDE plugin manager from disk
## Verification
After installation, verify the configuration is active:
1. Launch Cline
2. Check the logs for: `"Cline running in self-hosted mode with custom endpoints"`
3. Confirm that environment switching is disabled (as expected in self-hosted mode)
## User Experience
### What Users See
- Cline automatically uses the bundled endpoints
- No manual configuration required
- Environment switching is disabled (prevents accidental misconfiguration)
- All API calls route to your organization's infrastructure
### User Override
Users **cannot** override bundled endpoints through the UI. The bundled configuration takes absolute precedence. This ensures:
- Consistent configuration across the organization
- No accidental connections to external services
- Simplified deployment and support
If users have a `~/.cline/endpoints.json` file, it will be ignored when bundled configuration is present.
## Troubleshooting
### Invalid Configuration Error
If users see an error about invalid configuration on startup:
```
ClineConfigurationError: Invalid JSON in bundled endpoints configuration file
```
**Solution**: The bundled `endpoints.json` is malformed. Repackage with a valid JSON file.
### Missing Required Field Error
```
ClineConfigurationError: Missing required field "apiBaseUrl" in endpoints configuration file
```
**Solution**: Ensure all three required fields are present in `endpoints.json`.
### Invalid URL Error
```
ClineConfigurationError: Field "appBaseUrl" must be a valid URL. Got: "not-a-url"
```
**Solution**: All URLs must start with `http://` or `https://`.
## Security Considerations
1. **Bundle Validation**: The packaging scripts validate JSON structure and required fields
2. **Read-Only Configuration**: Users cannot modify bundled endpoints through the UI
3. **Self-Hosted Mode**: Automatic switch to self-hosted mode prevents external connections
4. **Audit Trail**: All endpoint access is logged with configuration source
## Updating Endpoints
To update endpoints for existing installations:
1. Create updated `endpoints.json`
2. Repackage the same Cline version with new endpoints
3. Distribute updated package
4. Users reinstall/update the package
The version number remains the same since only configuration changed, not the Cline code.
## Support
For questions or issues with bundled endpoints:
1. Verify your `endpoints.json` is valid JSON with all required fields
2. Check that URLs are accessible from user networks
3. Review Cline logs for configuration loading messages
4. Contact your Cline support representative for assistance
## Example: Complete Workflow
Here's a complete example for VSCode deployment:
```bash
# 1. Download official release
curl -LO https://github.com/cline/cline/releases/download/v3.55.0/cline-3.55.0.vsix
# 2. Create endpoints configuration
cat > endpoints.json << 'EOF'
{
"appBaseUrl": "https://cline.acme.internal",
"apiBaseUrl": "https://cline-api.acme.internal",
"mcpBaseUrl": "https://cline-api.acme.internal/v1/mcp"
}
EOF
# 3. Validate JSON
jq empty endpoints.json # Should succeed silently
# 4. Run packaging script
./scripts/add-endpoints-to-vsix.sh \
cline-3.55.0.vsix \
cline-3.55.0-acme.vsix \
endpoints.json
# 5. Verify output
unzip -l cline-3.55.0-acme.vsix | grep endpoints.json
# Should show: extension/endpoints.json
# 6. Test installation (on test machine)
code --install-extension cline-3.55.0-acme.vsix
# 7. Distribute to organization
# Upload to internal package repository
# or distribute via configuration management system
```
## Changelog
- **v3.55.0**: Initial release of bundled endpoints support
+5 -4
View File
@@ -62,15 +62,16 @@ The description is critical because it's how Cline decides whether to activate a
Skills can be stored in two locations:
**Global Skills** apply to all your projects:
- **macOS/Linux:** `~/.cline/skills/`
- **Windows:** `C:\Users\USERNAME\.cline\skills\`
- **macOS/Linux:** `~/.agents/skills/` (recommended) or `~/.cline/skills/`
- **Windows:** `C:\Users\USERNAME\.agents\skills\` (recommended) or `C:\Users\USERNAME\.cline\skills\`
**Project Skills** apply only to the current workspace:
- `.cline/skills/` (recommended)
- `.agents/skills/` (recommended)
- `.cline/skills/`
- `.clinerules/skills/`
- `.claude/skills/` (for Claude Code compatibility)
When a global skill and project skill have the same name, the global skill takes precedence. This lets you customize skills for your personal workflow while still using project defaults.
When a global skill and project skill have the same name, the global skill takes precedence. Skills in `.agents/skills` directories take precedence over other locations with the same name, letting you customize skills for your personal workflow while still using project defaults.
## Managing Skills
+85
View File
@@ -0,0 +1,85 @@
---
title: "Subagents"
sidebarTitle: "Subagents"
description: "Run parallel research agents to explore your codebase without filling the main agent's context window."
---
Subagents let Cline spawn focused research agents that run in parallel. Each subagent gets its own prompt and context window, explores the codebase independently, and returns a detailed report to the main agent. This keeps the main agent's context clean while gathering broad information fast.
<Tip>
Subagents is an experimental feature. Behavior may change in future releases.
</Tip>
## How It Works
When Cline uses the `use_subagents` tool, it launches independent agents simultaneously. Each one:
- Gets its own prompt describing what to investigate
- Runs with a separate context window and token budget
- Can read files, search code, list directories, run read-only commands, and use skills
- Cannot edit files, use the browser, access MCP servers, or spawn nested subagents
- Returns a result focused on the most relevant file paths for the main agent to read next
Subagent costs (tokens and API spend) are tracked separately per subagent and rolled into the task's total cost. You can see per-subagent stats (tool calls, tokens, cost) in the chat UI as they run.
## Enabling Subagents
Subagents are disabled by default. To turn them on:
1. Open Cline Settings (click the gear icon in the Cline panel)
2. Go to **Features**
3. Under the **Agent** section, toggle **Subagents** on
This setting applies across all editors (VS Code, JetBrains, CLI).
## Using Subagents
Cline does not automatically decide to use subagents. You need to ask for them in your prompt. When the feature is enabled and you mention subagents (or describe a task that benefits from parallel exploration), Cline will use the `use_subagents` tool.
Example prompts:
- "Use subagents to explore how authentication works and where the database models are defined"
- "Spin up subagents to investigate the API routes, the test setup, and the deployment config"
- "I'm new to this codebase. Use subagents to map out the main entry points, the routing layer, and the data access patterns"
Each subagent prompt should describe a focused research question. Cline will run them in parallel and synthesize the results.
You can also run only one subagent when the task is small enough that parallel discovery would be unnecessary overhead.
## Auto-Approve Behavior
Subagents follow the **Read project files** auto-approve permission. If you have "Read project files" enabled in [Auto Approve](/features/auto-approve), subagent launches will be auto-approved.
In [YOLO mode](/features/yolo-mode), subagents are always auto-approved.
If auto-approve is off, Cline will ask for your approval before launching subagents, showing you the prompts it plans to send.
## What Subagents Can Do
Subagents are read-only research agents. Here is what they have access to:
| Tool | Purpose |
|------|---------|
| `read_file` | Read file contents |
| `list_files` | List directory contents |
| `search_files` | Regex search across files |
| `list_code_definition_names` | List top-level classes, functions, and methods |
| `execute_command` | Run read-only commands (`ls`, `grep`, `git log`, `git diff`, etc.) |
| `use_skill` | Load and activate skills |
Subagents cannot write files, apply patches, use the browser, access MCP servers, or perform web searches. They also cannot spawn their own subagents.
<Note>
Commands run by subagents execute in the background and are restricted to read-only operations. Subagents will not run commands that modify files or system state.
Subagents also benefit from command pipelines and filters to narrow output quickly before reading files, for example `rg ... | sort | uniq`.
</Note>
## When to Use Subagents
Subagents work best when you need broad context from multiple areas of a codebase at once:
- **Onboarding to an unfamiliar project**: Ask subagents to map out the architecture, key entry points, and data flow in parallel.
- **Investigating cross-cutting concerns**: Have separate subagents trace authentication, logging, and error handling simultaneously.
- **Pre-edit research**: Before making changes, use subagents to gather context from related files so the main agent can make informed edits without burning through its context window.
- **Large codebases**: When reading many files sequentially would consume too much of the main agent's context, subagents let you explore broadly without that tradeoff.
For small, focused tasks where you already know which files to look at, subagents add unnecessary overhead. Just ask Cline directly.
+1 -1
View File
@@ -53,7 +53,7 @@ Vertex AI supports multiple regions. Select a region that meets your latency, co
- **asia-southeast1 (Singapore)**
- **global (Global)**
The Global endpoint may offer higher availability and reduce resource exhausted errors. Only Gemini models are supported.
The Global endpoint may offer higher availability and reduce resource exhausted errors. Gemini models and supported Claude models can use it, depending on model availability in your project.
#### 2.2 Enable the Claude 3.5 Sonnet v2 Model
+16 -11
View File
@@ -2,23 +2,28 @@ repositories
temp-files
results
# Tool precision - results and databases
benchmarks/tool-precision/replace-in-file/results/
benchmarks/tool-precision/replace-in-file/*.db
benchmarks/tool-precision/replace-in-file/*.db-wal
benchmarks/tool-precision/replace-in-file/*.db-shm
# Tool precision - private test cases (from real sessions)
# Public/synthetic cases (example-*.json) ARE committed
benchmarks/tool-precision/replace-in-file/cases/private-*.json
benchmarks/tool-precision/replace-in-file/cases/session-*.json
# Legacy paths (kept for backwards compatibility)
diff-edits/cases/
diff-edits/results/
diff_editing/test_cases/
diff_editing/test_outputs/
diff-edits/cases.zip
# Environment variables
.env
# backwards compatible
diff_editing/test_cases/
diff_editing/test_outputs/
*.db
*.db-wal
*.db-shm
.cache
# Python bytecode cache
*__pycache__/
diff-edits/cases.zip
.cache
+288
View File
@@ -0,0 +1,288 @@
# Cline Evals Architecture
## Overview
The evals system provides multi-layered testing for Cline's AI capabilities.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ TESTING PYRAMID │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ / E2E \ Layer 3: Full Agent │
│ / cline- \ - Real coding tasks │
│ / bench \ - Harbor execution │
│ /_______________\ - Nightly runs │
│ │
│ ┌───────────────────┐ │
│ / Smoke Tests \ Layer 2: Provider │
│ / run-smoke-tests \ - 5 curated scenarios │
│ / (cline provider) \ - 3 models via Vercel │
│ /_________________________\ - pass@k metrics │
│ │
│ ┌─────────────────────────────────┐ │
│ / Contract Tests \ Layer 1: Unit │
│ / thinking-traces.test.ts \ - No LLM calls │
│ / tool-parsing.test.ts \ - Fast, deterministic │
│ /______________________________________ \ - API format validation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Directory Structure
```
evals/
├── ARCHITECTURE.md # This file
├── README.md # Quick start guide
├── analysis/ # Shared metrics & reporting
│ └── src/
│ ├── metrics.ts # pass@k, pass^k, flakiness calculations
│ └── cli.ts # Analysis CLI
├── smoke-tests/ # Layer 2: Provider smoke tests
│ ├── run-smoke-tests.ts # Main runner
│ ├── README.md # Usage docs
│ ├── scenarios/ # Test definitions
│ │ ├── 01-create-file/
│ │ │ ├── config.json # Prompt, expected files/content
│ │ │ ├── template/ # Initial files (if any)
│ │ │ └── workspace/ # Working dir (cleaned each run)
│ │ ├── 02-edit-file/
│ │ ├── 03-read-summarize/
│ │ ├── 04-multi-file/
│ │ └── 05-typescript-function/
│ └── results/ # Generated outputs
│ ├── latest -> 2026-01-27T.../ # Symlink to most recent
│ └── 2026-01-27T19-50-54-391Z/
│ ├── report.json # Full results
│ ├── summary.md # CI-friendly markdown
│ └── 01-create-file/
│ └── claude-sonnet/
│ ├── trial-1.log # CLI stdout/stderr
│ └── workspace-trial-1/ # Kept for failures only
├── e2e/ # Layer 3: Full agent E2E
│ ├── run-cline-bench.ts # Harbor runner
│ └── README.md
└── cline-bench/ # Git submodule with real coding tasks
└── tasks/ # SWE-bench style problems
```
## Smoke Test Workflow
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ SMOKE TEST EXECUTION FLOW │
└──────────────────────────────────────────────────────────────────────────────┘
npm run eval:smoke
┌───────────────────┐
│ Load scenarios │ Read config.json from each scenarios/* dir
│ from disk │
└────────┬──────────┘
┌───────────────────┐
│ Create results │ evals/smoke-tests/results/2026-01-27T.../
│ directory │
└────────┬──────────┘
┌───────────────────────────────────────────────────────────────┐
│ FOR EACH SCENARIO │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ FOR EACH MODEL │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ RUN 3 TRIALS SEQUENTIALLY │ │ │
│ │ │ │ │ │
│ │ │ Trial 1 ──► Trial 2 ──► Trial 3 ──► Results │ │ │
│ │ │ (Sequential - Cline instance handles one at a time) │ │
│ │ │ │ │ │
│ │ │ Each trial: │ │ │
│ │ │ 1. Create workspace-trial-N/ │ │ │
│ │ │ 2. Copy template files (if any) │ │ │
│ │ │ 3. Run: cline -y -o "prompt" │ │ │
│ │ │ 4. Verify expected files exist │ │ │
│ │ │ 5. Verify expected content │ │ │
│ │ │ 6. Save trial-N.log │ │ │
│ │ │ 7. If failed, copy workspace to results/ │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ Calculate metrics: pass@1, pass@3, pass^3, flaky │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
┌───────────────────┐
│ Generate outputs │
│ - report.json │
│ - summary.md │
│ - latest symlink │
└───────────────────┘
```
## Models Tested
```
┌─────────────────────────────────────────────────────────────────┐
│ CLINE PROVIDER ROUTING │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ Smoke Test │ │
│ │ Runner │ │
│ └──────┬──────┘ │
│ │ │
│ │ cline -y -o "prompt" --model <model> │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Cline │ │
│ │ Provider │ ◄─── Uses your Cline auth (cline auth) │
│ └──────┬──────┘ │
│ │ │
│ │ Routes to backend │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Default Models │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ claude-sonnet-4-20250514 │ │
│ │ gpt-4o │ │
│ │ gemini-2.5-pro-preview-06-05 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Metrics Explained
```
┌─────────────────────────────────────────────────────────────────┐
│ METRICS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ pass@k "What's the probability of getting at least one │
│ success if I run k trials?" │
│ │
│ Example: 2/3 trials pass → pass@3 ≈ 96% │
│ (Very likely to pass if you run 3 times) │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ pass^k "What's the probability of ALL k trials succeeding?" │
│ │
│ Example: 2/3 trials pass → pass^3 ≈ 30% │
│ (Only 30% chance all 3 would pass) │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Status PASS = All trials passed │
│ FLAKY = Some passed, some failed │
│ FAIL = All trials failed │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Quick Commands
```bash
# Run all smoke tests (all models, 3 trials each)
npm run eval:smoke
# Run single model (use exact model ID for reproducibility)
npm run eval:smoke -- --model claude-sonnet-4-20250514
# Run single scenario
npm run eval:smoke -- --scenario 01-create-file
# Quick check (1 trial)
npm run eval:smoke -- --trials 1
# CI-like run (builds CLI from source, single trial)
npm run eval:smoke:ci
# View latest results
cat evals/smoke-tests/results/latest/summary.md
# Debug a failure
cat evals/smoke-tests/results/latest/<scenario>/<model>/trial-1.log
ls evals/smoke-tests/results/latest/<scenario>/<model>/workspace-trial-1/
```
## CI Integration
Smoke tests run automatically on merge to `main` via `.github/workflows/cline-evals-regression.yml`.
**Triggers:**
- Push to `main` branch (paths: `src/core/**`, `src/shared/**`, `proto/**`)
- Manual dispatch via `workflow_dispatch`
**What it does:**
1. Builds the Go CLI from source via `scripts/run-smoke-tests.sh`
2. Runs all 5 scenarios × 3 models × 1 trial
3. Uploads results as artifact
4. Posts summary to GitHub Actions job summary
```bash
# The CI runs this script which handles proto generation + CLI build:
bash scripts/run-smoke-tests.sh --trials 1
```
### Viewing CI Results
1. **Job Summary**: Each run posts results to the Actions tab
2. **Artifacts**: Full results downloadable as `smoke-test-results-<run_id>`
### Running CI-like Tests Locally
```bash
# One command - builds CLI from source and runs tests
npm run eval:smoke:ci
# Or manually:
npm run protos-go
cd cli && go build -o cline ./cmd/cline
export PATH="$(pwd)/cli:$PATH"
npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1
```
### Why Build CLI in CI?
We build the Go CLI from source rather than using a pre-built release because:
- Tests actual CLI code from the commit (catches CLI regressions)
- Proto definitions may have changed
- No dependency on external releases
## Contract Tests (Layer 1)
> **Note**: The old `evals/benchmarks/tool-precision/` tests have been removed. Their functionality is now covered by contract tests in `src/core/**/__tests__/` and the 52 system prompt snapshot tests that run with `npm run test:unit`.
Located in `src/core/api/transform/__tests__/`:
```
thinking-traces.test.ts
├── convertToOpenAiMessages preserves reasoning_details
├── convertToAnthropicMessage preserves thinking blocks
└── sanitizeGeminiMessages handles provider-specific cleaning
tool-parsing.test.ts
├── Anthropic tool_use → OpenAI tool_calls conversion
├── Tool call ID truncation (>40 chars)
├── OpenAI Responses API ID transformation
└── Tool result matching
```
Run with:
```bash
npm run test:unit -- --grep "Thinking Trace" # 9 tests
npm run test:unit -- --grep "Tool Call" # 11 tests
```
+114 -306
View File
@@ -1,341 +1,149 @@
# Cline Evaluation System
# Cline Evaluation Framework
This directory contains the evaluation system for benchmarking Cline against various coding evaluation frameworks.
## Overview
The Cline Evaluation System allows you to:
1. Run Cline against standardized coding benchmarks
2. Collect comprehensive metrics on performance
3. Generate detailed reports on evaluation results
4. Compare performance across different models and benchmarks
## Architecture
The evaluation system consists of two main components:
1. **CLI Tool**: Command-line interface in `evals/cli/` for orchestrating evaluations
2. **Diff Edit Benchmark**: Separate command using the CLI tool that runs a comprehensive diff editing benchmark suite on real world cases, along with a streamlit dashboard displaying the results. For more details, see the Diff Edit Benchmark [README](./diff-edits/README.md). Make sure you add a `evals/diff-edits/cases` folder with all the conversation jsons.
A layered testing system for measuring Cline's performance at different levels.
## Directory Structure
```
evals/ # Main directory for evaluation system
├── cli/ # CLI tool for orchestrating evaluations
── src/
├── index.ts # CLI entry point
├── commands/ # CLI commands (setup, run, report)
│ ├── adapters/ # Benchmark adapters
├── db/ # Database management
└── utils/ # Utility functions
├── diff-edits/ # Diff editing evaluation suite
── cases/ # Test case JSON files
├── results/ # Evaluation results
│ ├── diff-apply/ # Diff application logic
│ ├── parsing/ # Assistant message parsing
└── prompts/ # System prompts
├── repositories/ # Cloned benchmark repositories
│ └── exercism/ # Exercism (Aider Polyglot)
├── results/ # Evaluation results storage
├── runs/ # Individual run results
└── reports/ # Generated reports
└── README.md # This file
evals/
├── smoke-tests/ # Quick provider validation (minutes)
── run-smoke-tests.ts
└── scenarios/ # 5 curated test scenarios
├── e2e/ # Full E2E with cline-bench (hours)
└── run-cline-bench.ts
├── cline-bench/ # Real-world tasks (git submodule)
── tasks/ # 12 production bug fixes
├── analysis/ # Metrics and reporting framework
│ ├── src/
│ ├── metrics.ts # pass@k, pass^k calculations
│ │ ├── classifier.ts # Failure pattern matching
│ └── reporters/ # Markdown, JSON output
│ └── patterns/
└── cline-failures.yaml
└── baselines/ # Performance baselines for regression detection
```
## Getting Started
## Test Layers
### Prerequisites
### Layer 1: Contract Tests (Unit)
- Node.js 16+
- VSCode with Cline extension installed
- Git
Location: `src/core/api/transform/__tests__/`
### Installation
1. Build the CLI tool:
Tests API transform logic without LLM calls:
- Thinking trace preservation
- Tool call parsing (XML, native formats)
- Provider format conversions
```bash
cd evals
npm install
npm run build:cli
npm run test:unit -- --grep "Thinking\|Tool Call"
```
### Usage
### Layer 2: Smoke Tests (Minutes)
#### Setting Up Benchmarks
Location: `evals/smoke-tests/`
Quick validation across providers with real LLM calls:
- 5 curated scenarios
- 3 trials per test for pass@k metrics
- Runs via cline CLI with `-s` flags
```bash
cd evals/cli
node dist/index.js setup
# Set API key (Cline provider)
export CLINE_API_KEY=sk-...
# Run smoke tests
npm run eval:smoke
# Run specific scenario
npm run eval:smoke -- --scenario 01-create-file
# Run with specific model (overrides per-scenario models)
npm run eval:smoke -- --model anthropic/claude-sonnet-4.5
```
This will clone and set up all benchmark repositories. You can specify specific benchmarks:
### Layer 3: E2E Tests (Hours)
Location: `evals/e2e/` + `evals/cline-bench/`
Full agent tests on production-grade tasks:
- 12 real-world coding problems
- Docker/Daytona execution via Harbor
- Nightly CI runs
```bash
node dist/index.js setup --benchmarks exercism
# Prerequisites: Python 3.13, Harbor, Docker
npm run eval:e2e
# Specific task
npm run eval:e2e -- --tasks discord
# Different provider
npm run eval:e2e -- --provider openai --model gpt-4o
```
#### Running Evaluations
```bash
node dist/index.js run --benchmark exercism --count 10
```
Options:
- `--benchmark`: Specific benchmark to run (default: exercism)
- `--count`: Number of tasks to run (default: all available tasks)
**Note:** Model selection is currently configured through the Cline CLI itself, not through evaluation flags.
#### Generating Reports
```bash
node dist/index.js report
```
Options:
- `--format`: Report format (json, markdown) (default: markdown)
- `--output`: Output path for the report
## Benchmarks
### Exercism
Modified Exercism exercises from the [polyglot-benchmark](https://github.com/Aider-AI/polyglot-benchmark) repository. These are small, focused programming exercises in various languages.
### SWE-Bench (Coming Soon)
Real-world software engineering tasks from the [SWE-bench](https://github.com/SWE-bench/SWE-bench) repository.
### SWELancer (Coming Soon)
Freelance-style programming tasks from the SWELancer benchmark.
### Multi-SWE-Bench (Coming Soon)
Multi-file software engineering tasks from the Multi-SWE-Bench repository.
## Diff Edit Evaluations
The Cline Evaluation System includes a specialized suite for evaluating how well models can make precise edits to files using the `replace_in_file` tool.
### Overview
Diff edit evaluations test a model's ability to:
1. Understand file content and identify specific sections to modify
2. Generate correct SEARCH/REPLACE blocks for targeted edits
3. Successfully apply changes without introducing errors
### Directory Structure
```
diff-edits/
├── cases/ # Test case JSON files
├── results/ # Evaluation results
├── ClineWrapper.ts # Wrapper for model interaction
├── TestRunner.ts # Main test execution logic
├── types.ts # Type definitions
├── diff-apply/ # Diff application logic
├── parsing/ # Assistant message parsing
└── prompts/ # System prompts
```
### Creating Test Cases
Test cases are defined as JSON files in the `diff-edits/cases/` directory. Each test case should include:
```json
{
"test_id": "example_test_1",
"messages": [
{
"role": "user",
"text": "Please fix the bug in this code...",
"images": []
},
{
"role": "assistant",
"text": "I'll help you fix that bug..."
}
],
"file_contents": "// Original file content here\nfunction example() {\n // Code with bug\n}",
"file_path": "src/example.js",
"system_prompt_details": {
"mcp_string": "",
"cwd_value": "/path/to/working/directory",
"browser_use": false,
"width": 900,
"height": 600,
"os_value": "macOS",
"shell_value": "/bin/zsh",
"home_value": "/Users/username",
"user_custom_instructions": ""
},
"original_diff_edit_tool_call_message": ""
}
```
### Running Diff Edit Evaluations
#### Single Model Evaluation
```bash
cd evals/cli
node dist/index.js run-diff-eval --model-ids "anthropic/claude-3-5-sonnet-20241022"
```
#### Multi-Model Evaluation
Compare multiple models in a single evaluation run:
```bash
# Compare Claude and Grok models
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-beta" \
--max-cases 10 \
--valid-attempts-per-case 3 \
--verbose
# Compare multiple Claude variants
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,anthropic/claude-3-5-haiku-20241022,anthropic/claude-3-opus-20240229" \
--max-cases 5 \
--valid-attempts-per-case 2 \
--parallel
```
#### Options
- `--model-ids`: Comma-separated list of model IDs to evaluate (required)
- `--system-prompt-name`: System prompt to use (default: "basicSystemPrompt")
- `--valid-attempts-per-case`: Number of attempts per test case per model (default: 1)
- `--max-cases`: Maximum number of test cases to run (default: all available)
- `--parsing-function`: Function to parse assistant messages (default: "parseAssistantMessageV2")
- `--diff-edit-function`: Function to apply diffs (default: "constructNewFileContentV2")
- `--test-path`: Path to test cases (default: diff-edits/cases)
- `--thinking-budget`: Tokens allocated for thinking (default: 0)
- `--parallel`: Run tests in parallel (flag)
- `--replay`: Use pre-recorded LLM output (flag)
- `--verbose`: Enable detailed logging (flag)
#### Examples
```bash
# Quick test with 2 models, 4 cases, 2 attempts each
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-beta" \
--max-cases 4 \
--valid-attempts-per-case 2 \
--verbose
# Comprehensive evaluation with parallel execution
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,anthropic/claude-3-5-haiku-20241022" \
--system-prompt-name claude4SystemPrompt \
--valid-attempts-per-case 5 \
--max-cases 20 \
--parallel \
--verbose
```
### Database Storage & Analytics
All evaluation results are automatically stored in a SQLite database (`diff-edits/evals.db`) for advanced analytics and comparison. The database includes:
- **System Prompts**: Versioned system prompt content with hashing for deduplication
- **Processing Functions**: Versioned parsing and diff-edit function configurations
- **Files**: Original and edited file content with content-based hashing
- **Runs**: Evaluation run metadata and configuration
- **Cases**: Individual test case information with context tokens
- **Results**: Detailed results with timing, cost, and success metrics
### Interactive Dashboard
Launch the Streamlit dashboard to visualize and analyze evaluation results:
```bash
cd diff-edits/dashboard
streamlit run app.py
```
The dashboard provides:
- **Model Performance Comparison**: Side-by-side comparison of success rates, latency, and costs
- **Interactive Charts**: Success rate trends, latency vs cost analysis, and performance metrics
- **Detailed Drill-Down**: Individual result analysis with file content viewing
- **Run Selection**: Browse and compare different evaluation runs
- **Real-time Updates**: Automatically refreshes with new evaluation data
#### Dashboard Features
1. **Hero Section**: Overview of current run with key metrics
2. **Model Cards**: Performance cards with grades and detailed metrics
3. **Comparison Charts**: Interactive Plotly charts for visual analysis
4. **Result Explorer**: Detailed view of individual test results including:
- Original and edited file content
- Raw model output
- Parsed tool calls
- Timing and cost metrics
- Error analysis
#### Quick Start Dashboard
```bash
# Run a quick evaluation
node cli/dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-beta" \
--max-cases 4 \
--valid-attempts-per-case 2 \
--verbose
# Launch dashboard to view results
cd diff-edits/dashboard && streamlit run app.py
```
### Legacy Results
For backward compatibility, results are also saved as JSON files in the `diff-edits/results/` directory. The JSON results include:
- Success/failure status
- Extracted tool calls
- Diff edit content
- Token usage and cost metrics
## Metrics
The evaluation system collects the following metrics:
The framework calculates:
- **Token Usage**: Input and output tokens
- **Cost**: Estimated cost of API calls
- **Duration**: Time taken to complete tasks
- **Tool Usage**: Number of tool calls and failures
- **Success Rate**: Percentage of tasks completed successfully
- **Test Success Rate**: Percentage of tests passed
- **Functional Correctness**: Ratio of tests passed to total tests
| Metric | Formula | Interpretation |
|--------|---------|----------------|
| **pass@k** | P(≥1 of k passes) | Solution finding capability |
| **pass^k** | P(all k pass) | Reliability |
| **Flakiness** | Entropy of pass rate | Consistency |
## Reports
With 3 trials:
- All pass → `pass` (reliable)
- All fail → `fail` (broken)
- Mixed → `flaky` (needs investigation)
Reports are generated in Markdown or JSON format and include:
## CI Integration
- Overall summary
- Benchmark-specific results
- Model-specific results
- Tool usage statistics
- Charts and visualizations
- **PR Gate**: Contract tests + smoke tests (fast, ~3min)
- **Nightly**: E2E tests with cline-bench (not yet implemented, see TODO)
## Development
## Quick Start
### Adding a New Benchmark
```bash
# Run all fast tests
npm run test:unit
npm run eval:smoke
1. Create a new adapter in `evals/cli/src/adapters/`
2. Implement the `BenchmarkAdapter` interface
3. Register the adapter in `evals/cli/src/adapters/index.ts`
# Run E2E (requires setup)
cd evals/cline-bench
# Follow README.md for Harbor setup
npm run eval:e2e
```
### Extending Metrics
## Adding Tests
To add new metrics:
### Smoke Test Scenario
1. Update the database schema in `evals/cli/src/db/schema.ts`
2. Add collection logic in `evals/cli/src/utils/results.ts`
3. Update report generation in `evals/cli/src/commands/report.ts`
1. Create `evals/smoke-tests/scenarios/<name>/config.json`
2. Add optional `template/` directory with starting files
3. Run to verify: `npm run eval:smoke -- --scenario <name>`
### Contract Test
1. Add to `src/core/api/transform/__tests__/`
2. Run: `npm run test:unit -- --grep "YourTest"`
### E2E Task
Contribute to [cline/cline-bench](https://github.com/cline/cline-bench)
## Resources
- [cline-bench tasks](evals/cline-bench/README.md)
- [Smoke test scenarios](evals/smoke-tests/README.md)
## TODO
- [ ] **Nightly E2E CI**: Add scheduled workflow for cline-bench tests
- Requires: Docker runner, Harbor setup, ~1-2 hour timeout
- Should run on schedule (e.g., nightly) not per-PR
- Separate secrets for E2E environment
- [ ] **Native tool calling smoke tests**: Add CLI support for `native_tool_call_enabled` setting to test Claude 4 with native tools
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@cline/analysis",
"version": "1.0.0",
"description": "Analysis framework for Cline evaluations with failure classification and metrics",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "tsx src/cli.ts",
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"keywords": [
"cline",
"evaluation",
"benchmarking",
"ai-testing",
"metrics"
],
"dependencies": {
"commander": "^12.0.0",
"js-yaml": "^4.1.0",
"chalk": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/js-yaml": "^4.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
}
}
@@ -0,0 +1,57 @@
# Cline-specific failure patterns for classification
# Version 1.0
version: "1.0"
patterns:
# Provider-specific bugs (Cline integration issues)
- name: "gemini_signature"
pattern: "missing.?signature|thoughtSignature"
category: "provider_bug"
issue: "https://github.com/cline/cline/issues/7974"
description: "Gemini 3 Pro requires thoughtSignature for native tool calls"
- name: "claude_tool_format"
pattern: "write_to_file.*missing.*content|content.*parameter.*required"
category: "provider_bug"
issue: "https://github.com/cline/cline/issues/7998"
description: "Claude tool parameter extraction failure"
# Transient failures (retriable)
- name: "rate_limit"
pattern: "429|rate.?limit|too.?many.?requests|quota.?exceeded"
category: "transient"
description: "API rate limiting"
- name: "network_timeout"
pattern: "ECONNREFUSED|ETIMEDOUT|ENOTFOUND|timed.?out"
category: "transient"
description: "Network connectivity issues"
- name: "model_overloaded"
pattern: "503|service.?unavailable|overloaded"
category: "transient"
description: "Provider service unavailable"
# Infrastructure/harness failures
- name: "harness_error"
pattern: "verifier.*failed|test.*harness.*error|missing.*test.*file"
category: "harness"
description: "Test harness or verification script failure"
- name: "environment_failure"
pattern: "docker.*failed|container.*exit|OCI.*runtime|pod.*error"
category: "environment"
description: "Docker/Daytona environment setup failure"
# Policy/safety failures
- name: "safety_refusal"
pattern: "content.*policy|safety.*filter|inappropriate.*request"
category: "policy"
description: "Model refused due to safety/content policy"
# Auth issues (non-retriable)
- name: "auth_error"
pattern: "401|unauthorized|invalid.?api.?key"
category: "auth"
description: "Invalid API credentials"
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest"
import { FailureClassifier } from "../classifier"
describe("FailureClassifier", () => {
const classifier = new FailureClassifier()
describe("Provider Bug Detection", () => {
it("detects Gemini signature issue", () => {
const logs = `
Error: Function call is missing a thought_signature in functionCall parts.
This is required for tools to work correctly with Gemini 3 Pro...
`
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("gemini_signature")
expect(failures[0].category).toBe("provider_bug")
expect(failures[0].issue_url).toBe("https://github.com/cline/cline/issues/7974")
})
it("detects Claude tool format issue", () => {
const logs = `Cline tried to use write_to_file without value for required parameter 'content'`
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("claude_tool_format")
expect(failures[0].category).toBe("provider_bug")
expect(failures[0].issue_url).toBe("https://github.com/cline/cline/issues/7998")
})
})
describe("Transient Failure Detection", () => {
it("detects rate limiting", () => {
const logs = "Error: 429 Too Many Requests - Rate limit exceeded"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("rate_limit")
expect(failures[0].category).toBe("transient")
})
it("detects network timeout", () => {
const logs = "Error: ETIMEDOUT - Connection timed out"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("network_timeout")
expect(failures[0].category).toBe("transient")
})
it("detects service unavailable", () => {
const logs = "503 Service Unavailable - Model is currently overloaded"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("model_overloaded")
expect(failures[0].category).toBe("transient")
})
})
describe("Infrastructure Failure Detection", () => {
it("detects harness errors", () => {
const logs = "verifier script failed with exit code 1"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("harness_error")
expect(failures[0].category).toBe("harness")
})
it("detects environment failures", () => {
const logs = "Error: docker container exit code 137"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("environment_failure")
expect(failures[0].category).toBe("environment")
})
})
describe("Policy and Auth Failures", () => {
it("detects safety refusals", () => {
const logs = "Request blocked: Content policy violation"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("safety_refusal")
expect(failures[0].category).toBe("policy")
})
it("detects auth errors", () => {
const logs = "401 Unauthorized: Invalid API key"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("auth_error")
expect(failures[0].category).toBe("auth")
})
})
describe("Excerpt Extraction", () => {
it("extracts context around the matched pattern", () => {
const logs = `
This is some context before the error.
Error: 429 Too Many Requests - Rate limit exceeded
This is some context after the error.
`
const failures = classifier.classify(logs)
expect(failures[0].excerpt).toContain("Rate limit exceeded")
expect(failures[0].excerpt.length).toBeLessThan(500)
})
})
describe("Helper Methods", () => {
it("hasProviderBug returns true for provider bugs", () => {
const logs = "Error: missing thoughtSignature in function call"
expect(classifier.hasProviderBug(logs)).toBe(true)
})
it("hasProviderBug returns false for non-provider bugs", () => {
const logs = "Error: 429 Too Many Requests"
expect(classifier.hasProviderBug(logs)).toBe(false)
})
it("hasTransientFailure returns true for transient errors", () => {
const logs = "Error: ETIMEDOUT"
expect(classifier.hasTransientFailure(logs)).toBe(true)
})
it("hasTransientFailure returns false for non-transient errors", () => {
const logs = "Error: missing thoughtSignature"
expect(classifier.hasTransientFailure(logs)).toBe(false)
})
it("getPatternsByCategory returns correct patterns", () => {
const providerBugs = classifier.getPatternsByCategory("provider_bug")
expect(providerBugs).toContain("gemini_signature")
expect(providerBugs).toContain("claude_tool_format")
const transient = classifier.getPatternsByCategory("transient")
expect(transient).toContain("rate_limit")
expect(transient).toContain("network_timeout")
expect(transient).toContain("model_overloaded")
})
})
describe("Multiple Pattern Matching", () => {
it("detects multiple failures in same log", () => {
const logs = `
Error: 429 Too Many Requests
Later: Error: ETIMEDOUT
`
const failures = classifier.classify(logs)
expect(failures.length).toBe(2)
expect(failures.map((f) => f.name)).toContain("rate_limit")
expect(failures.map((f) => f.name)).toContain("network_timeout")
})
})
describe("Case Insensitivity", () => {
it("matches patterns case-insensitively", () => {
const logs = "error: RATE LIMIT exceeded"
const failures = classifier.classify(logs)
expect(failures.length).toBeGreaterThan(0)
expect(failures[0].name).toBe("rate_limit")
})
})
describe("No Match", () => {
it("returns empty array when no patterns match", () => {
const logs = "Everything completed successfully"
const failures = classifier.classify(logs)
expect(failures).toEqual([])
})
})
})
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest"
import { MetricsCalculator } from "../metrics"
describe("MetricsCalculator", () => {
const calc = new MetricsCalculator()
describe("pass@k (solution finding)", () => {
it("calculates 100% when at least k trials pass", () => {
expect(calc.passAtK([true, true, true], 1)).toBe(1.0)
expect(calc.passAtK([true, true, false], 1)).toBe(1.0)
expect(calc.passAtK([true, true, true], 3)).toBe(1.0)
})
it("calculates 0% when fewer than k trials pass", () => {
expect(calc.passAtK([false, false, false], 1)).toBe(0.0)
})
it("calculates correct probability for mixed results", () => {
// With n=3, c=2, k=2: 1 - C(1,2)/C(3,2) = 1 - 0/3 = 1.0
expect(calc.passAtK([true, true, false], 2)).toBe(1.0)
// With n=3, c=1, k=2: 1 - C(2,2)/C(3,2) = 1 - 1/3 = 2/3
expect(calc.passAtK([true, false, false], 2)).toBeCloseTo(0.6667, 4)
})
it("throws error when k > n", () => {
expect(() => calc.passAtK([true, false], 3)).toThrow()
})
it("handles k=1 correctly (most common case)", () => {
expect(calc.passAtK([true, false, false], 1)).toBe(1.0)
expect(calc.passAtK([false, false, false], 1)).toBe(0.0)
})
it("handles all-pass scenarios", () => {
expect(calc.passAtK([true, true, true, true, true], 3)).toBe(1.0)
expect(calc.passAtK([true, true, true, true, true], 5)).toBe(1.0)
})
it("handles all-fail scenarios", () => {
expect(calc.passAtK([false, false, false], 1)).toBe(0.0)
expect(calc.passAtK([false, false, false], 3)).toBe(0.0)
})
})
describe("pass^k (reliability)", () => {
it("calculates 100% when all k trials must and do pass", () => {
expect(calc.passCaretK([true, true, true], 3)).toBe(1.0)
expect(calc.passCaretK([true, true, true, true], 3)).toBeCloseTo(1.0, 4)
})
it("calculates 0% when fewer than k trials pass", () => {
expect(calc.passCaretK([true, true, false], 3)).toBe(0.0)
expect(calc.passCaretK([true, false, false], 2)).toBe(0.0)
expect(calc.passCaretK([false, false, false], 1)).toBe(0.0)
})
it("calculates correct probability for sufficient passes", () => {
// With n=4, c=3, k=2: C(3,2)/C(4,2) = 3/6 = 0.5
expect(calc.passCaretK([true, true, true, false], 2)).toBeCloseTo(0.5, 4)
// With n=5, c=3, k=2: C(3,2)/C(5,2) = 3/10 = 0.3
expect(calc.passCaretK([true, true, true, false, false], 2)).toBeCloseTo(0.3, 4)
})
it("throws error when k > n", () => {
expect(() => calc.passCaretK([true, false], 3)).toThrow()
})
it("diverges from pass@k as trials increase", () => {
const trials = [true, true, false, false, false]
// pass@k increases (eventually finds solution)
const passAt1 = calc.passAtK(trials, 1)
const passAt3 = calc.passAtK(trials, 3)
expect(passAt3).toBeGreaterThanOrEqual(passAt1)
// pass^k decreases (reliability drops)
const passCaret1 = calc.passCaretK(trials, 1)
const passCaret3 = calc.passCaretK(trials, 3)
expect(passCaret3).toBeLessThanOrEqual(passCaret1)
})
})
describe("flakinessScore (variance)", () => {
it("returns 0 for all-pass scenarios", () => {
expect(calc.flakinessScore([true, true, true])).toBe(0)
})
it("returns 0 for all-fail scenarios", () => {
expect(calc.flakinessScore([false, false, false])).toBe(0)
})
it("returns 1 for maximum variance (50% pass rate)", () => {
expect(calc.flakinessScore([true, false])).toBe(1)
expect(calc.flakinessScore([true, true, false, false])).toBe(1)
})
it("returns values between 0 and 1 for partial variance", () => {
const score1 = calc.flakinessScore([true, true, true, false])
expect(score1).toBeGreaterThan(0)
expect(score1).toBeLessThan(1)
const score2 = calc.flakinessScore([true, false, false, false])
expect(score2).toBeGreaterThan(0)
expect(score2).toBeLessThan(1)
})
it("symmetric around 50% pass rate", () => {
const score25 = calc.flakinessScore([true, false, false, false])
const score75 = calc.flakinessScore([true, true, true, false])
expect(score25).toBeCloseTo(score75, 4)
})
it("higher variance for rates closer to 50%", () => {
const score25 = calc.flakinessScore([true, false, false, false])
const score50 = calc.flakinessScore([true, true, false, false])
expect(score50).toBeGreaterThan(score25)
})
})
describe("binomial coefficient", () => {
it("calculates C(n, 0) = 1", () => {
expect(calc["binomial"](5, 0)).toBe(1)
})
it("calculates C(n, n) = 1", () => {
expect(calc["binomial"](5, 5)).toBe(1)
})
it("calculates C(n, 1) = n", () => {
expect(calc["binomial"](5, 1)).toBe(5)
})
it("calculates C(n, k) correctly", () => {
expect(calc["binomial"](5, 2)).toBe(10)
expect(calc["binomial"](6, 3)).toBe(20)
expect(calc["binomial"](10, 3)).toBe(120)
})
it("returns 0 when k > n", () => {
expect(calc["binomial"](3, 5)).toBe(0)
})
it("optimizes by using smaller k", () => {
// C(10, 8) = C(10, 2) = 45
expect(calc["binomial"](10, 8)).toBe(45)
expect(calc["binomial"](10, 2)).toBe(45)
})
})
describe("calculateTaskMetrics", () => {
it("calculates all metrics for 3 trials", () => {
const metrics = calc.calculateTaskMetrics([true, true, false])
expect(metrics.passAt1).toBe(1.0)
expect(metrics.passAt3).toBeGreaterThan(0)
expect(metrics.passCaret3).toBe(0.0)
expect(metrics.flakinessScore).toBeGreaterThan(0)
})
it("calculates all metrics for perfect pass", () => {
const metrics = calc.calculateTaskMetrics([true, true, true])
expect(metrics.passAt1).toBe(1.0)
expect(metrics.passAt3).toBe(1.0)
expect(metrics.passCaret3).toBe(1.0)
expect(metrics.flakinessScore).toBe(0)
})
it("calculates all metrics for perfect fail", () => {
const metrics = calc.calculateTaskMetrics([false, false, false])
expect(metrics.passAt1).toBe(0.0)
expect(metrics.passAt3).toBe(0.0)
expect(metrics.passCaret3).toBe(0.0)
expect(metrics.flakinessScore).toBe(0)
})
it("throws error for empty trials", () => {
expect(() => calc.calculateTaskMetrics([])).toThrow()
})
it("handles fewer than 3 trials gracefully", () => {
const metrics = calc.calculateTaskMetrics([true, false])
expect(metrics.passAt1).toBe(1.0)
expect(metrics.passAt3).toBe(0) // Not enough trials
expect(metrics.passCaret3).toBe(0)
expect(metrics.flakinessScore).toBe(1)
})
})
describe("getTaskStatus", () => {
it("returns 'pass' when all trials pass", () => {
expect(calc.getTaskStatus([true, true, true])).toBe("pass")
})
it("returns 'fail' when all trials fail", () => {
expect(calc.getTaskStatus([false, false, false])).toBe("fail")
})
it("returns 'flaky' when some trials pass and some fail", () => {
expect(calc.getTaskStatus([true, false, false])).toBe("flaky")
expect(calc.getTaskStatus([true, true, false])).toBe("flaky")
})
it("handles single trial", () => {
expect(calc.getTaskStatus([true])).toBe("pass")
expect(calc.getTaskStatus([false])).toBe("fail")
})
})
describe("Real-world scenarios", () => {
it("handles typical cline-bench results", () => {
// Scenario: Task passed 2/3 times
const trials = [true, true, false]
const metrics = calc.calculateTaskMetrics(trials)
expect(metrics.passAt1).toBe(1.0) // Found solution
expect(metrics.passAt3).toBeGreaterThan(0.5) // Likely to solve
expect(metrics.passCaret3).toBe(0) // Not reliable
expect(metrics.flakinessScore).toBeGreaterThan(0) // Has variance
expect(calc.getTaskStatus(trials)).toBe("flaky")
})
it("handles consistent success", () => {
const trials = [true, true, true]
const metrics = calc.calculateTaskMetrics(trials)
expect(metrics.passAt1).toBe(1.0)
expect(metrics.passAt3).toBe(1.0)
expect(metrics.passCaret3).toBe(1.0)
expect(metrics.flakinessScore).toBe(0)
expect(calc.getTaskStatus(trials)).toBe("pass")
})
it("handles consistent failure", () => {
const trials = [false, false, false]
const metrics = calc.calculateTaskMetrics(trials)
expect(metrics.passAt1).toBe(0)
expect(metrics.passAt3).toBe(0)
expect(metrics.passCaret3).toBe(0)
expect(metrics.flakinessScore).toBe(0)
expect(calc.getTaskStatus(trials)).toBe("fail")
})
})
})
+124
View File
@@ -0,0 +1,124 @@
/**
* Failure classification system for Cline evaluations
*
* Classifies failures by matching log patterns against known issues:
* - Provider bugs (Gemini #7974, Claude #7998)
* - Transient failures (rate limits, timeouts)
* - Infrastructure issues (harness, environment)
* - Policy/safety refusals
* - Auth errors
*/
import * as fs from "fs"
import * as yaml from "js-yaml"
import * as path from "path"
import type { FailureCategory, FailureInfo } from "./schemas"
export interface FailurePattern {
name: string
pattern: string // Regex pattern as string
category: FailureCategory
issue?: string // GitHub issue URL
description: string
}
export interface FailurePatternsConfig {
version: string
patterns: FailurePattern[]
}
export class FailureClassifier {
private patterns: Array<FailurePattern & { regex: RegExp }>
constructor(patternsPath?: string) {
const defaultPath = path.join(__dirname, "../patterns/cline-failures.yaml")
const configPath = patternsPath || defaultPath
const config = this.loadPatternsFromYaml(configPath)
this.patterns = config.patterns.map((p) => ({
...p,
regex: new RegExp(p.pattern, "i"), // Case-insensitive matching
}))
}
private loadPatternsFromYaml(filePath: string): FailurePatternsConfig {
const content = fs.readFileSync(filePath, "utf-8")
const config = yaml.load(content) as FailurePatternsConfig
if (!config.version || !config.patterns) {
throw new Error("Invalid patterns YAML: missing version or patterns")
}
return config
}
/**
* Classify failures in log text
* @param logs Full log text (e.g., cline.txt content)
* @returns Array of matched failure categories with excerpts
*/
classify(logs: string): FailureInfo[] {
const failures: FailureInfo[] = []
for (const pattern of this.patterns) {
const match = pattern.regex.exec(logs)
if (match) {
failures.push({
name: pattern.name,
category: pattern.category,
excerpt: this.extractExcerpt(logs, match.index, match[0].length),
issue_url: pattern.issue,
})
}
}
return failures
}
/**
* Extract a context snippet around the matched pattern
* @param logs Full log text
* @param matchIndex Index where pattern matched
* @param matchLength Length of the matched text
* @returns Context snippet (up to 200 chars before/after match)
*/
private extractExcerpt(logs: string, matchIndex: number, matchLength: number): string {
const contextSize = 200
const start = Math.max(0, matchIndex - contextSize)
const end = Math.min(logs.length, matchIndex + matchLength + contextSize)
let excerpt = logs.slice(start, end)
// Trim to complete lines for readability
excerpt = excerpt.replace(/^\s*\S*\s*/, "") // Remove partial first line
excerpt = excerpt.replace(/\s*\S*\s*$/, "") // Remove partial last line
// Truncate if still too long
if (excerpt.length > 400) {
excerpt = excerpt.slice(0, 400) + "..."
}
return excerpt.trim()
}
/**
* Check if logs contain any known provider bug patterns
*/
hasProviderBug(logs: string): boolean {
return this.classify(logs).some((f) => f.category === "provider_bug")
}
/**
* Check if logs contain transient failure patterns (retriable)
*/
hasTransientFailure(logs: string): boolean {
return this.classify(logs).some((f) => f.category === "transient")
}
/**
* Get all pattern names for a specific category
*/
getPatternsByCategory(category: FailureCategory): string[] {
return this.patterns.filter((p) => p.category === category).map((p) => p.name)
}
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env node
/**
* Cline Analysis Framework CLI
*
* Commands:
* - analyze: Parse Harbor job output and generate reports
* - compare: Compare baseline vs current results for regression detection
*/
import chalk from "chalk"
import { Command } from "commander"
import * as fs from "fs"
import { HarborParser } from "./parsers"
import { JsonReporter, MarkdownReporter } from "./reporters"
import type { AnalysisOutputV1, ComparisonResult } from "./schemas"
const program = new Command()
program.name("cline-analysis").description("Analysis framework for Cline evaluations").version("1.0.0")
// Analyze command
program
.command("analyze <job-dir>")
.description("Parse Harbor job output and generate analysis report")
.option("-f, --format <format>", "Output format: markdown, json, or minimal", "markdown")
.option("-o, --output <file>", "Write report to file (default: stdout)")
.option("--no-color", "Disable colored output")
.action(async (jobDir: string, options: any) => {
try {
// Validate job directory
if (!fs.existsSync(jobDir)) {
console.error(chalk.red(`Error: Job directory not found: ${jobDir}`))
process.exit(1)
}
console.error(chalk.blue(`Analyzing Harbor job: ${jobDir}`))
// Parse Harbor output
const parser = new HarborParser()
const analysis = parser.parseJob(jobDir)
// Generate report
let report: string
if (options.format === "json") {
const jsonReporter = new JsonReporter()
report = jsonReporter.generate(analysis, true)
} else if (options.format === "minimal") {
const jsonReporter = new JsonReporter()
report = jsonReporter.generateMinimal(analysis)
} else {
const markdownReporter = new MarkdownReporter()
report = markdownReporter.generate(analysis, options.color)
}
// Output report
if (options.output) {
fs.writeFileSync(options.output, report)
console.error(chalk.green(`✓ Report written to: ${options.output}`))
// Also write full JSON for future reference
if (options.format === "markdown") {
const jsonPath = options.output.replace(/\.md$/, ".json")
const jsonReporter = new JsonReporter()
fs.writeFileSync(jsonPath, jsonReporter.generate(analysis, true))
console.error(chalk.gray(` (Full JSON saved to: ${jsonPath})`))
}
} else {
console.log(report)
}
// Summary on stderr
const markdownReporter = new MarkdownReporter()
const summary = markdownReporter.generateCompactSummary(analysis)
console.error("\n" + chalk.bold("Summary:"))
console.error(summary)
} catch (error) {
console.error(chalk.red("Error during analysis:"))
console.error(error)
process.exit(1)
}
})
// Compare command
program
.command("compare <baseline> <current>")
.description("Compare baseline and current analysis results")
.option("-t, --threshold <number>", "Regression threshold (percentage points)", "10")
.option("--no-color", "Disable colored output")
.action(async (baselinePath: string, currentPath: string, options: any) => {
try {
// Load both analysis outputs
if (!fs.existsSync(baselinePath)) {
console.error(chalk.red(`Error: Baseline file not found: ${baselinePath}`))
process.exit(1)
}
if (!fs.existsSync(currentPath)) {
console.error(chalk.red(`Error: Current file not found: ${currentPath}`))
process.exit(1)
}
const baseline: AnalysisOutputV1 = JSON.parse(fs.readFileSync(baselinePath, "utf-8"))
const current: AnalysisOutputV1 = JSON.parse(fs.readFileSync(currentPath, "utf-8"))
const threshold = parseFloat(options.threshold)
// Compare results
const comparison = compareAnalyses(baseline, current, threshold)
// Display comparison
displayComparison(comparison, options.color)
// Exit with error if regression detected
if (comparison.regression_detected) {
console.error(chalk.red("\n✗ Regression detected! See details above."))
process.exit(1)
} else {
console.error(chalk.green("\n✓ No significant regression detected."))
process.exit(0)
}
} catch (error) {
console.error(chalk.red("Error during comparison:"))
console.error(error)
process.exit(1)
}
})
program.parse()
/**
* Compare two analysis outputs for regression detection
*/
function compareAnalyses(baseline: AnalysisOutputV1, current: AnalysisOutputV1, threshold: number): ComparisonResult {
const delta = {
pass_at_1: (current.summary.pass_at_1 - baseline.summary.pass_at_1) * 100,
pass_at_3: (current.summary.pass_at_3 - baseline.summary.pass_at_3) * 100,
pass_caret_3: (current.summary.pass_caret_3 - baseline.summary.pass_caret_3) * 100,
cost_usd: current.summary.total_cost_usd - baseline.summary.total_cost_usd,
duration_sec: current.summary.total_duration_sec - baseline.summary.total_duration_sec,
}
// Detect regression (drop in pass rates exceeding threshold)
const regression_detected = delta.pass_at_1 < -threshold || delta.pass_at_3 < -threshold
// Find tasks that regressed or improved
const tasks_regressed: string[] = []
const tasks_improved: string[] = []
const baselineTaskMap = new Map(baseline.tasks.map((t) => [t.task_id, t]))
for (const currentTask of current.tasks) {
const baselineTask = baselineTaskMap.get(currentTask.task_id)
if (!baselineTask) {
continue
}
const taskDelta = (currentTask.metrics.pass_at_3 - baselineTask.metrics.pass_at_3) * 100
if (taskDelta < -threshold) {
tasks_regressed.push(currentTask.task_name)
} else if (taskDelta > threshold) {
tasks_improved.push(currentTask.task_name)
}
}
return {
baseline: baseline.summary,
current: current.summary,
delta,
regression_detected,
tasks_regressed,
tasks_improved,
}
}
/**
* Display comparison results with color coding
*/
function displayComparison(comparison: ComparisonResult, useColor: boolean): void {
const separator = "━".repeat(79)
console.log(useColor ? chalk.bold(separator) : separator)
console.log(useColor ? chalk.bold.cyan("Baseline vs Current Comparison") : "Baseline vs Current Comparison")
console.log(useColor ? chalk.bold(separator) : separator)
console.log("")
// Pass rate changes
console.log(useColor ? chalk.bold("Pass Rate Changes:") : "Pass Rate Changes:")
console.log(` pass@1: ${formatDelta(comparison.delta.pass_at_1, useColor)} percentage points`)
console.log(` pass@3: ${formatDelta(comparison.delta.pass_at_3, useColor)} percentage points`)
console.log(` pass^3: ${formatDelta(comparison.delta.pass_caret_3, useColor)} percentage points`)
console.log("")
// Cost and duration changes
console.log(useColor ? chalk.bold("Resource Changes:") : "Resource Changes:")
console.log(` Cost: ${formatDelta(comparison.delta.cost_usd, useColor, true)} USD`)
console.log(` Duration: ${formatDelta(comparison.delta.duration_sec, useColor)} seconds`)
console.log("")
// Tasks regressed
if (comparison.tasks_regressed.length > 0) {
console.log(useColor ? chalk.bold.red("Tasks Regressed:") : "Tasks Regressed:")
for (const task of comparison.tasks_regressed) {
console.log(`${task}`)
}
console.log("")
}
// Tasks improved
if (comparison.tasks_improved.length > 0) {
console.log(useColor ? chalk.bold.green("Tasks Improved:") : "Tasks Improved:")
for (const task of comparison.tasks_improved) {
console.log(`${task}`)
}
console.log("")
}
}
/**
* Format delta value with color coding
*/
function formatDelta(value: number, useColor: boolean, invertSign = false): string {
const sign = invertSign ? -Math.sign(value) : Math.sign(value)
const absValue = Math.abs(value).toFixed(2)
const signStr = sign > 0 ? "+" : sign < 0 ? "-" : " "
if (!useColor) {
return `${signStr}${absValue}`
}
if (sign > 0) {
return chalk.green(`${signStr}${absValue}`)
}
if (sign < 0) {
return chalk.red(`${signStr}${absValue}`)
}
return chalk.gray(`${signStr}${absValue}`)
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Metrics calculation for nondeterministic AI testing
*
* Implements:
* - pass@k: P(at least 1 of k trials passes) - solution finding capability
* - pass^k: P(all k trials pass) - reliability measure
* - Flakiness score: Entropy-based variance measurement
*
* References:
* - HumanEval paper: https://arxiv.org/abs/2107.03374
* - pass@k methodology: https://github.com/openai/human-eval
*/
export class MetricsCalculator {
/**
* Calculate pass@k: Probability that at least 1 of k trials succeeds
*
* Formula: 1 - C(n-c, k) / C(n, k)
* where n = total trials, c = number of passes, k = sample size
*
* Interpretation: "Can this model solve the problem?"
*
* @param trials Array of boolean trial results (true = pass, false = fail)
* @param k Number of trials to sample
* @returns Probability [0, 1]
*/
passAtK(trials: boolean[], k: number): number {
const n = trials.length
const c = trials.filter(Boolean).length
if (n < k) {
throw new Error(`Cannot calculate pass@${k} with only ${n} trials`)
}
// If we have at least k passes, probability is 100%
if (c >= k) {
return 1.0
}
// Calculate: 1 - C(n-c, k) / C(n, k)
const numerator = this.binomial(n - c, k)
const denominator = this.binomial(n, k)
return 1 - numerator / denominator
}
/**
* Calculate pass^k: Probability that ALL k trials succeed
*
* Formula: C(c, k) / C(n, k)
* where n = total trials, c = number of passes, k = sample size
*
* Interpretation: "Can I rely on this model?" (reliability metric)
*
* @param trials Array of boolean trial results
* @param k Number of trials that must all pass
* @returns Probability [0, 1]
*/
passCaretK(trials: boolean[], k: number): number {
const n = trials.length
const c = trials.filter(Boolean).length
if (n < k) {
throw new Error(`Cannot calculate pass^${k} with only ${n} trials`)
}
// If we have fewer than k passes, probability is 0%
if (c < k) {
return 0.0
}
// Calculate: C(c, k) / C(n, k)
const numerator = this.binomial(c, k)
const denominator = this.binomial(n, k)
return numerator / denominator
}
/**
* Calculate flakiness score: Entropy-based measure of variance
*
* Formula: -p*log2(p) - (1-p)*log2(1-p)
* where p = pass rate
*
* Returns:
* - 0.0: No variance (all pass or all fail)
* - 1.0: Maximum variance (50% pass rate)
*
* Interpretation: How unpredictable/inconsistent is this task?
*
* @param trials Array of boolean trial results
* @returns Flakiness score [0, 1]
*/
flakinessScore(trials: boolean[]): number {
const passRate = trials.filter(Boolean).length / trials.length
// No variance if all pass or all fail
if (passRate === 0 || passRate === 1) {
return 0
}
// Binary entropy
const entropy = -passRate * Math.log2(passRate) - (1 - passRate) * Math.log2(1 - passRate)
return entropy // Already in [0, 1] range
}
/**
* Binomial coefficient C(n, k) = n! / (k! * (n-k)!)
*
* Uses iterative calculation to avoid factorial overflow
*
* @param n Total items
* @param k Items to choose
* @returns Number of ways to choose k items from n
*/
private binomial(n: number, k: number): number {
if (k > n) {
return 0
}
if (k === 0 || k === n) {
return 1
}
// Optimize by using smaller k
if (k > n - k) {
k = n - k
}
let result = 1
for (let i = 1; i <= k; i++) {
result *= n - i + 1
result /= i
}
return result
}
/**
* Calculate all metrics for a task's trials
*
* @param trials Array of boolean trial results
* @returns Object with pass@1, pass@3, pass^3, and flakiness scores
*/
calculateTaskMetrics(trials: boolean[]): {
passAt1: number
passAt3: number
passCaret3: number
flakinessScore: number
} {
if (trials.length === 0) {
throw new Error("Cannot calculate metrics with no trials")
}
// Calculate pass@k and pass^k for available trials
const passAt1 = trials.length >= 1 ? this.passAtK(trials, 1) : 0
const passAt3 = trials.length >= 3 ? this.passAtK(trials, 3) : 0
const passCaret3 = trials.length >= 3 ? this.passCaretK(trials, 3) : 0
return {
passAt1,
passAt3,
passCaret3,
flakinessScore: this.flakinessScore(trials),
}
}
/**
* Determine task status based on trial results
*
* @param trials Array of boolean trial results
* @returns "pass" | "fail" | "flaky"
*/
getTaskStatus(trials: boolean[]): "pass" | "fail" | "flaky" {
const passCount = trials.filter(Boolean).length
const totalCount = trials.length
if (passCount === totalCount) {
return "pass"
}
if (passCount === 0) {
return "fail"
}
return "flaky"
}
}
+301
View File
@@ -0,0 +1,301 @@
/**
* Parser for Harbor framework job output
*
* Parses jobs/ directory structure created by Harbor to extract:
* - Trial results (pass/fail, duration, cost, tokens)
* - Task groupings and metrics
* - Failure classifications
*/
import * as fs from "fs"
import * as path from "path"
import { FailureClassifier } from "../classifier"
import { MetricsCalculator } from "../metrics"
import type {
AnalysisMetadata,
AnalysisOutputV1,
AnalysisSummary,
FailureAnalysis,
TaskResultV1,
TrialResultV1,
} from "../schemas"
export interface HarborParserOptions {
patternsPath?: string
}
export class HarborParser {
private classifier: FailureClassifier
private metrics: MetricsCalculator
constructor(options: HarborParserOptions = {}) {
this.classifier = new FailureClassifier(options.patternsPath)
this.metrics = new MetricsCalculator()
}
/**
* Parse a complete Harbor job directory
*
* @param jobDir Path to job directory (e.g., jobs/2025-01-25__10-30-00/)
* @returns Structured analysis output with schema version 1.0
*/
parseJob(jobDir: string): AnalysisOutputV1 {
const configPath = path.join(jobDir, "config.json")
const resultPath = path.join(jobDir, "result.json")
if (!fs.existsSync(configPath) || !fs.existsSync(resultPath)) {
throw new Error(`Invalid Harbor job directory: ${jobDir}`)
}
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"))
const result = JSON.parse(fs.readFileSync(resultPath, "utf-8"))
// Find all trial directories
const trialDirs = this.findTrialDirectories(jobDir)
const trials = trialDirs.map((dir) => this.parseTrialDirectory(dir))
// Group trials by task ID
const taskResults = this.groupTrialsByTask(trials)
// Calculate aggregate metrics
const summary = this.calculateSummary(taskResults)
// Analyze failures
const failures = this.analyzeFailures(taskResults)
const metadata: AnalysisMetadata = {
generated_at: new Date().toISOString(),
analysis_version: "1.0.0", // TODO: Get from package.json
job_id: path.basename(jobDir),
model: config.model,
agent: config.agent || "cline-cli",
environment: config.environment || "docker",
}
return {
schema_version: "1.0",
metadata,
summary,
tasks: taskResults,
failures,
}
}
/**
* Find all trial directories in a job
*/
private findTrialDirectories(jobDir: string): string[] {
const entries = fs.readdirSync(jobDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.filter((entry) => {
// Trial dirs have format: 01k7a12s...disco__fhSEuhr
const configExists = fs.existsSync(path.join(jobDir, entry.name, "config.json"))
return configExists
})
.map((entry) => path.join(jobDir, entry.name))
}
/**
* Parse a single trial directory
*/
private parseTrialDirectory(trialDir: string): ParsedTrial {
const configPath = path.join(trialDir, "config.json")
const resultPath = path.join(trialDir, "result.json")
const rewardPath = path.join(trialDir, "verifier", "reward.txt")
const logsPath = path.join(trialDir, "agent", "cline.txt")
const testOutputPath = path.join(trialDir, "verifier", "test-stdout.txt")
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"))
const result = JSON.parse(fs.readFileSync(resultPath, "utf-8"))
const reward = fs.readFileSync(rewardPath, "utf-8").trim()
const logs = fs.existsSync(logsPath) ? fs.readFileSync(logsPath, "utf-8") : ""
const testOutput = fs.existsSync(testOutputPath) ? fs.readFileSync(testOutputPath, "utf-8") : ""
const passed = reward === "1"
const failures = passed ? [] : this.classifier.classify(logs)
return {
taskId: config.task_id,
trialHash: path.basename(trialDir).split("__")[1] || "",
passed,
duration: result.duration_sec || 0,
cost: result.cost_usd || 0,
tokensIn: result.tokens_in,
tokensOut: result.tokens_out,
logs,
testOutput,
failures,
}
}
/**
* Group trials by task ID and calculate metrics
*/
private groupTrialsByTask(trials: ParsedTrial[]): TaskResultV1[] {
const taskMap = new Map<string, ParsedTrial[]>()
// Group trials by task ID
for (const trial of trials) {
const existing = taskMap.get(trial.taskId) || []
existing.push(trial)
taskMap.set(trial.taskId, existing)
}
// Convert to TaskResultV1 format
const taskResults: TaskResultV1[] = []
for (const [taskId, taskTrials] of taskMap.entries()) {
const trialResults: TrialResultV1[] = taskTrials.map((trial, index) => ({
trial_index: index,
trial_hash: trial.trialHash,
passed: trial.passed,
duration_sec: trial.duration,
cost_usd: trial.cost,
tokens_in: trial.tokensIn,
tokens_out: trial.tokensOut,
failures: trial.failures,
}))
const passResults = taskTrials.map((t) => t.passed)
const metrics = this.metrics.calculateTaskMetrics(passResults)
const status = this.metrics.getTaskStatus(passResults)
const totalCost = taskTrials.reduce((sum, t) => sum + t.cost, 0)
const avgDuration = taskTrials.reduce((sum, t) => sum + t.duration, 0) / taskTrials.length
// Extract readable task name from ID
const taskName = this.extractTaskName(taskId)
taskResults.push({
task_id: taskId,
task_name: taskName,
trials: trialResults,
metrics,
status,
total_cost_usd: totalCost,
avg_duration_sec: avgDuration,
})
}
return taskResults.sort((a, b) => a.task_name.localeCompare(b.task_name))
}
/**
* Extract human-readable task name from task ID
* Example: 01k7a12sd1nk15j08e6x0x7v9e-discord-trivia-approval-keyerror → discord-trivia
*/
private extractTaskName(taskId: string): string {
const parts = taskId.split("-")
if (parts.length > 1) {
// Remove the ID prefix and get first 2-3 meaningful words
const words = parts.slice(1, 4)
return words.join("-")
}
return taskId
}
/**
* Calculate aggregate summary metrics
*/
private calculateSummary(taskResults: TaskResultV1[]): AnalysisSummary {
const totalTasks = taskResults.length
const totalTrials = taskResults.reduce((sum, task) => sum + task.trials.length, 0)
// Calculate overall pass@k metrics
const allTrials = taskResults.flatMap((task) => task.trials.map((t) => t.passed))
let passAt1 = 0
let passAt3 = 0
let passCaret3 = 0
if (allTrials.length >= 1) {
passAt1 = this.metrics.passAtK(allTrials, 1)
}
if (allTrials.length >= 3) {
passAt3 = this.metrics.passAtK(allTrials, 3)
passCaret3 = this.metrics.passCaretK(allTrials, 3)
}
const totalCost = taskResults.reduce((sum, task) => sum + task.total_cost_usd, 0)
const totalDuration = taskResults.reduce((sum, task) => sum + task.avg_duration_sec * task.trials.length, 0)
const flakyTaskCount = taskResults.filter((task) => task.status === "flaky").length
return {
total_tasks: totalTasks,
total_trials: totalTrials,
pass_at_1: passAt1,
pass_at_3: passAt3,
pass_caret_3: passCaret3,
total_cost_usd: totalCost,
total_duration_sec: totalDuration,
flaky_task_count: flakyTaskCount,
}
}
/**
* Analyze failure patterns across all tasks
*/
private analyzeFailures(taskResults: TaskResultV1[]): FailureAnalysis {
const categoryCount = new Map<string, number>()
const patternCount = new Map<string, { count: number; issue_url?: string; examples: any[] }>()
for (const task of taskResults) {
for (const trial of task.trials) {
if (!trial.passed) {
for (const failure of trial.failures) {
// Count by category
categoryCount.set(failure.category, (categoryCount.get(failure.category) || 0) + 1)
// Count by pattern
const existing = patternCount.get(failure.name) || {
count: 0,
issue_url: failure.issue_url,
examples: [],
}
existing.count++
// Add example if not too many
if (existing.examples.length < 3) {
existing.examples.push({
task_id: task.task_id,
trial_index: trial.trial_index,
excerpt: failure.excerpt,
})
}
patternCount.set(failure.name, existing)
}
}
}
}
const byCategory: Record<string, number> = {}
for (const [category, count] of categoryCount.entries()) {
byCategory[category] = count
}
const byPattern = Array.from(patternCount.entries()).map(([name, data]) => ({
name,
count: data.count,
issue_url: data.issue_url,
examples: data.examples,
}))
return { by_category: byCategory as any, by_pattern: byPattern }
}
}
interface ParsedTrial {
taskId: string
trialHash: string
passed: boolean
duration: number
cost: number
tokensIn?: number
tokensOut?: number
logs: string
testOutput: string
failures: any[]
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Parser exports for Cline Analysis Framework
*
* Parsers for different benchmark types:
* - Harbor: Real-world tasks via cline-bench
*/
export * from "./harbor"
+10
View File
@@ -0,0 +1,10 @@
/**
* Reporter exports for Cline Analysis Framework
*
* Available reporters:
* - JsonReporter: Structured JSON output with schema validation
* - MarkdownReporter: Human-readable terminal reports
*/
export * from "./json"
export * from "./markdown"
+96
View File
@@ -0,0 +1,96 @@
/**
* JSON reporter for Cline analysis results
*
* Outputs structured JSON with schema versioning for:
* - CI integration (baseline diffing, regression detection)
* - Programmatic analysis
* - Data archival
*/
import type { AnalysisOutputV1 } from "../schemas"
export class JsonReporter {
/**
* Generate JSON report from analysis output
*
* @param output Analysis output (already structured)
* @param pretty Whether to pretty-print the JSON
* @returns JSON string
*/
generate(output: AnalysisOutputV1, pretty = true): string {
return JSON.stringify(output, null, pretty ? 2 : undefined)
}
/**
* Validate that output conforms to AnalysisOutputV1 schema
*
* @param output Analysis output to validate
* @throws Error if schema validation fails
*/
validate(output: any): asserts output is AnalysisOutputV1 {
if (output.schema_version !== "1.0") {
throw new Error(`Unsupported schema version: ${output.schema_version}`)
}
// Basic structure validation
const required = ["metadata", "summary", "tasks", "failures"]
for (const field of required) {
if (!(field in output)) {
throw new Error(`Missing required field: ${field}`)
}
}
// Validate metadata
if (!output.metadata.generated_at || !output.metadata.job_id || !output.metadata.model) {
throw new Error("Invalid metadata: missing required fields")
}
// Validate summary
if (typeof output.summary.total_tasks !== "number") {
throw new Error("Invalid summary: total_tasks must be a number")
}
// Validate tasks array
if (!Array.isArray(output.tasks)) {
throw new Error("Invalid tasks: must be an array")
}
}
/**
* Generate a minimal JSON report (without full logs and excerpts)
*
* Useful for CI artifacts where space is limited
*
* @param output Analysis output
* @returns Minified JSON string
*/
generateMinimal(output: AnalysisOutputV1): string {
const minimal = {
schema_version: output.schema_version,
metadata: {
job_id: output.metadata.job_id,
model: output.metadata.model,
generated_at: output.metadata.generated_at,
},
summary: output.summary,
tasks: output.tasks.map((task) => ({
task_id: task.task_id,
task_name: task.task_name,
status: task.status,
metrics: task.metrics,
total_cost_usd: task.total_cost_usd,
avg_duration_sec: task.avg_duration_sec,
})),
failures: {
by_category: output.failures.by_category,
by_pattern: output.failures.by_pattern.map((p) => ({
name: p.name,
count: p.count,
issue_url: p.issue_url,
})),
},
}
return JSON.stringify(minimal, null, 2)
}
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Markdown reporter for Cline analysis results
*
* Generates human-readable reports with:
* - Summary metrics (pass@k, cost, duration)
* - Task-by-task results
* - Failure analysis with issue links
* - Terminal-friendly formatting
*/
import chalk from "chalk"
import type { AnalysisOutputV1, TaskResultV1 } from "../schemas"
export class MarkdownReporter {
/**
* Generate markdown report from analysis output
*
* @param output Analysis output
* @param useColor Whether to use terminal colors
* @returns Markdown-formatted report string
*/
generate(output: AnalysisOutputV1, useColor = true): string {
const sections: string[] = []
sections.push(this.generateHeader(output, useColor))
sections.push(this.generateSummary(output, useColor))
sections.push(this.generateTaskResults(output, useColor))
sections.push(this.generateFailureAnalysis(output, useColor))
sections.push(this.generateCostPerformance(output, useColor))
return sections.join("\n\n")
}
private generateHeader(output: AnalysisOutputV1, useColor: boolean): string {
const separator = "━".repeat(79)
const title = "Cline Bench Analysis Report"
const lines = [
useColor ? chalk.bold(separator) : separator,
useColor ? chalk.bold.cyan(title) : title,
useColor ? chalk.bold(separator) : separator,
"",
`Job: ${output.metadata.job_id}`,
`Model: ${output.metadata.model}`,
`Tasks: ${output.summary.total_tasks} | Trials per task: ${Math.round(output.summary.total_trials / output.summary.total_tasks)}`,
]
return lines.join("\n")
}
private generateSummary(output: AnalysisOutputV1, useColor: boolean): string {
const { summary } = output
const separator = "━".repeat(79)
const passAt1Pct = (summary.pass_at_1 * 100).toFixed(1)
const passAt3Pct = (summary.pass_at_3 * 100).toFixed(1)
const passCaret3Pct = (summary.pass_caret_3 * 100).toFixed(1)
const lines = [
useColor ? chalk.bold(separator) : separator,
useColor ? chalk.bold("Results Summary") : "Results Summary",
useColor ? chalk.bold(separator) : separator,
"",
"Overall Metrics:",
` pass@1: ${passAt1Pct}% (solution finding)`,
` pass@3: ${passAt3Pct}% (with 3 attempts)`,
` pass^3: ${passCaret3Pct}% (reliability - all 3 pass)`,
"",
]
if (summary.flaky_task_count > 0) {
lines.push(
useColor
? chalk.yellow(`Flakiness: ${summary.flaky_task_count} tasks showed variance across trials`)
: `Flakiness: ${summary.flaky_task_count} tasks showed variance across trials`,
)
} else {
lines.push("Flakiness: No variance detected (all tasks consistent)")
}
return lines.join("\n")
}
private generateTaskResults(output: AnalysisOutputV1, useColor: boolean): string {
const separator = "━".repeat(79)
const lines = [
useColor ? chalk.bold(separator) : separator,
useColor ? chalk.bold("Task Results") : "Task Results",
useColor ? chalk.bold(separator) : separator,
"",
]
for (const task of output.tasks) {
const statusIcon = this.getStatusIcon(task, useColor)
const passAt1Pct = (task.metrics.pass_at_1 * 100).toFixed(0)
const passCaret3Pct = (task.metrics.pass_caret_3 * 100).toFixed(0)
const trialPattern = this.getTrialPattern(task, useColor)
const flakyWarning =
task.status === "flaky" && useColor ? chalk.yellow(" ⚠️ FLAKY") : task.status === "flaky" ? " ⚠️ FLAKY" : ""
const taskLine = `${statusIcon} ${task.task_name.padEnd(30)} | pass@1: ${passAt1Pct.padStart(3)}% | pass^3: ${passCaret3Pct.padStart(3)}% | ${trialPattern}${flakyWarning}`
lines.push(taskLine)
}
return lines.join("\n")
}
private getStatusIcon(task: TaskResultV1, useColor: boolean): string {
if (task.status === "pass") {
return useColor ? chalk.green("✓") : "✓"
}
if (task.status === "fail") {
return useColor ? chalk.red("✗") : "✗"
}
return useColor ? chalk.yellow("◐") : "◐"
}
private getTrialPattern(task: TaskResultV1, useColor: boolean): string {
const pattern = task.trials
.map((t) => {
if (t.passed) {
return useColor ? chalk.green("P") : "P"
}
return useColor ? chalk.red("F") : "F"
})
.join("")
return `[${pattern}]`
}
private generateFailureAnalysis(output: AnalysisOutputV1, useColor: boolean): string {
const separator = "━".repeat(79)
const lines = [
useColor ? chalk.bold(separator) : separator,
useColor ? chalk.bold("Failure Analysis") : "Failure Analysis",
useColor ? chalk.bold(separator) : separator,
"",
]
// Known issues (provider bugs)
const providerBugs = output.failures.by_pattern.filter((p) => p.issue_url)
if (providerBugs.length > 0) {
lines.push("Known Issues Detected:")
for (const bug of providerBugs) {
const line = `${bug.name} (${bug.count} occurrence${bug.count > 1 ? "s" : ""}) - ${bug.issue_url}`
lines.push(useColor ? chalk.yellow(line) : line)
// Show first example
if (bug.examples.length > 0) {
const example = bug.examples[0]
lines.push(` Task: ${example.task_id}, Trial ${example.trial_index}`)
}
}
lines.push("")
}
// Transient failures
const transient = Object.entries(output.failures.by_category).filter(([cat]) =>
["transient", "harness", "environment"].includes(cat),
)
if (transient.length > 0) {
lines.push("Infrastructure/Transient Failures:")
for (const [category, count] of transient) {
lines.push(`${category}: ${count} occurrence${count > 1 ? "s" : ""}`)
}
lines.push("")
}
// Task failures (model couldn't solve)
const taskFailures = output.tasks.filter((t) => t.status === "fail")
if (taskFailures.length > 0) {
lines.push("Task Failures (Model couldn't solve):")
for (const task of taskFailures) {
const allFailed = task.trials.every((t) => !t.passed)
if (allFailed) {
lines.push(`${task.task_name}: All ${task.trials.length} trials failed verification tests`)
}
}
}
return lines.join("\n")
}
private generateCostPerformance(output: AnalysisOutputV1, useColor: boolean): string {
const separator = "━".repeat(79)
const avgCostPerTask = output.summary.total_cost_usd / output.summary.total_tasks
const avgDurationPerTask = output.summary.total_duration_sec / output.summary.total_tasks
const formattedDuration = this.formatDuration(output.summary.total_duration_sec)
const avgFormattedDuration = this.formatDuration(avgDurationPerTask)
const lines = [
useColor ? chalk.bold(separator) : separator,
useColor ? chalk.bold("Cost & Performance") : "Cost & Performance",
useColor ? chalk.bold(separator) : separator,
"",
`Total Cost: $${output.summary.total_cost_usd.toFixed(2)}`,
`Avg per task: $${avgCostPerTask.toFixed(2)}`,
`Total Duration: ${formattedDuration}`,
`Avg per task: ${avgFormattedDuration}`,
"",
`Full report saved to: ${output.metadata.job_id}/analysis_report.md`,
]
return lines.join("\n")
}
private formatDuration(seconds: number): string {
if (seconds < 60) {
return `${seconds.toFixed(0)}s`
}
const minutes = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${minutes}m ${secs}s`
}
/**
* Generate a compact summary (for CI output)
*/
generateCompactSummary(output: AnalysisOutputV1): string {
const passAt1 = (output.summary.pass_at_1 * 100).toFixed(1)
const passAt3 = (output.summary.pass_at_3 * 100).toFixed(1)
const cost = output.summary.total_cost_usd.toFixed(2)
return [
`✓ pass@1: ${passAt1}% | pass@3: ${passAt3}%`,
` Cost: $${cost} | Tasks: ${output.summary.total_tasks}`,
` Flaky: ${output.summary.flaky_task_count}`,
].join("\n")
}
}
@@ -0,0 +1,146 @@
/**
* Type definitions for Cline Analysis Framework output
* These schemas define the structured JSON output from our analysis tools
*/
/**
* Versioned analysis output schema (V1)
* Breaking changes should increment the version number
*/
export interface AnalysisOutputV1 {
schema_version: "1.0"
metadata: AnalysisMetadata
summary: AnalysisSummary
tasks: TaskResultV1[]
failures: FailureAnalysis
}
export interface AnalysisMetadata {
generated_at: string // ISO 8601 timestamp
analysis_version: string // Package version (from package.json)
job_id: string // Job directory name (e.g., "2025-01-25__10-30-00")
model: string // Model used for the run
agent: string // Agent name (e.g., "cline-cli")
environment: string // "docker" | "daytona"
}
export interface AnalysisSummary {
total_tasks: number
total_trials: number
pass_at_1: number // Probability at least 1 of k=1 succeeds
pass_at_3: number // Probability at least 1 of k=3 succeeds
pass_caret_3: number // Probability ALL k=3 succeed (reliability)
total_cost_usd: number
total_duration_sec: number
flaky_task_count: number // Tasks with variance across trials
}
export interface TaskResultV1 {
task_id: string
task_name: string
trials: TrialResultV1[]
metrics: TaskMetrics
status: "pass" | "fail" | "flaky"
total_cost_usd: number
avg_duration_sec: number
}
export interface TrialResultV1 {
trial_index: number // 0-indexed trial number
trial_hash: string // Hash from trial directory name
passed: boolean
duration_sec: number
cost_usd: number
tokens_in?: number
tokens_out?: number
failures: FailureInfo[] // Classified failure patterns
}
export interface TaskMetrics {
pass_at_1: number // P(at least 1 of 1 succeeds)
pass_at_3: number // P(at least 1 of 3 succeeds)
pass_caret_3: number // P(all 3 succeed) - reliability metric
flakiness_score: number // Entropy-based variance measure (0-1)
}
export interface FailureInfo {
name: string // Pattern name (e.g., "gemini_signature")
category: FailureCategory
excerpt: string // Log excerpt showing the failure
issue_url?: string // GitHub issue link if applicable
}
export type FailureCategory =
| "provider_bug" // Cline integration bugs (Gemini #7974, Claude #7998)
| "transient" // Rate limits, network timeouts, service unavailable
| "harness" // Test harness or verification script failure
| "environment" // Docker/Daytona setup failure
| "policy" // Model safety/content policy refusal
| "auth" // Invalid API credentials
| "task_failure" // Model couldn't solve the task
export interface FailureAnalysis {
by_category: Record<FailureCategory, number>
by_pattern: FailurePatternSummary[]
}
export interface FailurePatternSummary {
name: string
count: number
issue_url?: string
examples: FailureExample[]
}
export interface FailureExample {
task_id: string
trial_index: number
excerpt: string
}
/**
* Tool Precision Test Result (for replace_in_file benchmarks)
*/
export interface ToolPrecisionResult {
schema_version: "1.0"
benchmark: "tool-precision/replace-in-file"
total_cases: number
passed: number
failed: number
pass_rate: number
avg_latency_ms: number
known_failures: string[]
timestamp: string
}
/**
* Coding Exercises Result (for small task benchmarks)
*/
export interface CodingExercisesResult {
schema_version: "1.0"
benchmark: "coding-exercises"
total_exercises: number
passed: number
failed: number
pass_at_1: number
avg_duration_sec: number
known_failures: string[]
timestamp: string
}
/**
* Comparison result between two analysis outputs
*/
export interface ComparisonResult {
baseline: AnalysisSummary
current: AnalysisSummary
delta: {
pass_at_1: number // Percentage point change
pass_at_3: number
pass_caret_3: number
cost_usd: number
duration_sec: number
}
regression_detected: boolean
tasks_regressed: string[] // Task IDs that got worse
tasks_improved: string[] // Task IDs that got better
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Type definitions for Harbor framework output structure
* These schemas document Harbor's jobs/ directory format (read-only, we validate against this)
*
* Harbor is the execution framework used by cline-bench.
* See: https://harborframework.com
*/
export interface HarborTrialConfig {
task_id: string
model: string
agent: string
environment?: string
retries?: number
}
export interface HarborTrialResult {
reward: 0 | 1 // Binary pass/fail from verifier
duration_sec: number
cost_usd: number
tokens_in?: number
tokens_out?: number
timestamp?: string
}
export interface HarborTrialFiles {
agent: {
"cline.txt": string // Full conversation log
"setup/stdout.txt": string
"setup/stderr.txt": string
"setup/return-code.txt": string
[key: string]: string // command-N/ directories with stdout, stderr, return-code.txt
}
verifier: {
"reward.txt": "0" | "1"
"test-stdout.txt": string
"test-stderr.txt": string
}
}
/**
* Structure of a single trial directory
* Example: jobs/2025-01-25__10-30-00/01k7a12s...disco__fhSEuhr/
*/
export interface HarborTrialDirectory {
"config.json": HarborTrialConfig
"result.json": HarborTrialResult
agent: HarborTrialFiles["agent"]
verifier: HarborTrialFiles["verifier"]
}
/**
* Structure of a job's config.json
*/
export interface HarborJobConfig {
model: string
agent: string
tasks: string[] // Task IDs
trials_per_task: number
environment: string // "docker" | "daytona"
created_at?: string
}
/**
* Structure of a job's result.json (aggregate)
*/
export interface HarborJobResult {
total_tasks: number
passed_tasks: number
failed_tasks: number
total_cost_usd: number
total_duration_sec: number
started_at?: string
completed_at?: string
}
/**
* Complete job directory structure
* Example: jobs/2025-01-25__10-30-00/
*/
export interface HarborJobDirectory {
"config.json": HarborJobConfig
"result.json": HarborJobResult
trials: Record<string, HarborTrialDirectory> // trial-hash -> trial data
}
/**
* Parsed trial result from Harbor output
*/
export interface ParsedHarborTrial {
taskId: string
trialHash: string
passed: boolean
duration: number
cost: number
tokensIn?: number
tokensOut?: number
logs: string // Full cline.txt content
testOutput: string // Test verification output
errors: string[] // Setup or command errors
}
/**
* Utility type for extracting trial directory paths
*/
export interface HarborTrialPath {
jobDir: string
trialDir: string
taskId: string
trialHash: string
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Schema exports for Cline Analysis Framework
*
* This module provides TypeScript type definitions for:
* - Harbor framework output (read-only validation)
* - Analysis framework output (our structured results)
* - Comparison results (baseline vs current)
*/
export * from "./analysis-output"
export * from "./harbor-output"
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": [
"node",
"vitest/globals"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"src/**/*.test.ts"
]
}
@@ -0,0 +1,17 @@
# DEPRECATED
This framework is kept for backward compatibility but is scheduled for removal.
## Why It's Deprecated
1. **Narrow scope**: Tests diff schema compliance, not general model capability
2. **Better alternatives**: `cline-bench` provides real-world task evaluation
3. **Maintenance burden**: Requires manual test case generation from conversations
## If You're Using This
Contact @ara or @robin before this gets removed. We can help migrate your use case to the smoke tests or cline-bench framework.
## Removal Timeline
Target: Q2 2026 (or when cline-bench is fully operational for model comparison)

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