Compare commits

..

8 Commits

Author SHA1 Message Date
abeatrix 43ddc8c846 fix(test): increase hook test timeouts for Windows in taskcancel tests
Add WINDOWS_HOOK_TEST_TIMEOUT_MS (15s) to taskcancel hook tests to
account for the slower PowerShell bridge that spawns a child Node
process on Windows CI. The double-process startup is variable and can
exceed Mocha's default 2s timeout, causing flaky failures.

Mirrors the approach already used in taskresume, hook-factory, and
user-prompt-submit tests.
2026-06-01 20:36:24 -07:00
Bee 386ded5126 feat(cli): bundle and serve Cline Hub dashboard with cline dashboard (#11195)
* feat(cli): bundle and serve Cline Hub dashboard with cline dashboard

Add the @cline/cline-hub workspace dependency to the CLI and build the
Hub webview as part of CLI packaging. Copy the generated dashboard assets
into platform-specific CLI distributions so the dashboard is available in
built artifacts.

Refactor the Cline Hub server startup into an exported function so the CLI
can start and stop the dashboard server programmatically.

* fix(cli): resolve dashboard webview in wrapper installs

Detect the platform-specific CLI package from the published wrapper layout
and use its bundled cline-hub webview assets when no explicit dist path is
set. Add test coverage for resolving assets via CLINE_WRAPPER_PATH.

* patches

* patch

* fix server detachHub on stop

Imported detachHub.
Changed ClineHubDashboardServer.stop to () => Promise<void>.
Made stop() idempotent with a stopped guard.
Clears the health interval.
Calls server.stop(true).
Always calls await detachHub(ctx) in a finally, so hub client teardown still happens if the HTTP server stop throws.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-01 17:35:24 -07:00
WaylandYang 44e15319e4 fix(plugin-sandbox): expose CLINE_PLUGIN_IMPORT_TIMEOUT_MS env override (#11065) (#11084)
* fix(plugin-sandbox): expose CLINE_PLUGIN_IMPORT_TIMEOUT_MS env override

The hardcoded 4000 ms importTimeoutMs default is too tight on Windows
cold-start; the plugin-sandbox tests already use 30_000 ms for the same
reason. This patch lets hosts raise the ceiling via env var without
touching code or adding a CLI flag, with explicit options.importTimeoutMs
still taking precedence.

Precedence: options.importTimeoutMs > env var > 4000.

Refs: #11065

* fix(plugin-sandbox): tighten env parsing + use vi.stubEnv (PR feedback)

- Number.parseInt accepts trailing garbage ("4000ms" -> 4000); switch
  to Number() + Number.isInteger() so malformed env values fall back
  to the default instead of silently consuming the numeric prefix.
- Replace manual process.env save/restore in the regression test with
  the idiomatic vi.stubEnv() / vi.unstubAllEnvs() pattern.

Per Greptile review on #11084.
2026-06-01 17:34:13 -07:00
Saoud Rizwan e424b28702 feat(cli): add plugins slash command (#11193)
* feat(cli): add plugins slash command

* fix(cli): address plugins command review feedback
2026-06-01 16:59:38 -07:00
Robin Newhouse db9971890e Add SDK telemetry for run_commands timeouts (#11149)
* feat(sdk): add run_commands timeout telemetry

* docs(sdk): document timeout telemetry event

* docs(sdk): move telemetry catalog to core docs

* fix(sdk): omit undefined timeout telemetry fields

* fix(sdk): mark timed out run_commands unsuccessful

* docs(sdk): defer telemetry catalog entry

* fix(sdk): limit run_commands timeout success override

* fix(sdk): tighten timeout telemetry plumbing
2026-06-01 16:22:04 -07:00
Tomás Barreiro d7cc9b6155 Move bun from the sdk/ to root (#11104)
* Move bun to root

* Fix scripts and pre-commit

* Update scripts

* Update workflows

* Fix cd

* fix pre-commit

* fix cli publish
2026-06-01 22:29:41 +02:00
Saoud Rizwan 05042d3ff7 docs(sdk): add env-blocker plugin example (#11192)
* docs(sdk): add env-blocker plugin example

Adds a beforeTool hook plugin that deterministically blocks the agent
from reading .env secret files via read_files, editor, or run_commands
(e.g. cat .env), while leaving .env.example/.sample/.template readable.
Demonstrates moving a security policy out of an AGENTS.md rule (a
suggestion the model can ignore) and into the execution path.

* docs(sdk): install env-blocker globally in usage examples

A secret-protection guard is most useful applied to every project, so
drop the --cwd . project-scoped install in favor of the global default.

* docs(sdk): trim env-blocker usage docs

* docs(sdk): limit env-blocker to read paths only

It is a read blocker, so only guard read_files and run_commands.
Drop the editor case (and with it the symmetric apply_patch concern),
keeping the example focused and simple.

* docs(sdk): rename env-blocker helpers for readability

collectPaths -> extractFilePaths, collectCommands -> extractShellCommands
so the beforeTool call sites read clearly at a glance.

* docs(sdk): rename commandTouchesEnv to commandReadsEnv

* docs(sdk): drop console.error from env-blocker hook
2026-06-01 12:12:16 -07:00
aikido-autofix[bot] dc2c662de6 [Aikido] Fix 53 security issues in @xmldom/xmldom, basic-ftp, axios and 14 more (#11145)
* fix(security): update dependencies

* fix: set unbounded axios fetch adapter limits for 1.16.0

---------

Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
Co-authored-by: TheRealSpencer <spencer@cline.bot>
2026-06-01 10:15:40 -07:00
59 changed files with 2220 additions and 676 deletions
+9 -9
View File
@@ -33,7 +33,7 @@ permissions:
defaults:
run:
working-directory: sdk
working-directory: .
jobs:
publish-main:
@@ -102,7 +102,7 @@ jobs:
fi
VERSION="${TAG#cli-v}"
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
PACKAGE_VERSION=$(node -p "require('./sdk/apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
@@ -172,7 +172,7 @@ jobs:
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
dir="sdk/apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
@@ -207,8 +207,8 @@ jobs:
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
# Grab content between the first "## " header and the next one in sdk/apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/apps/cli/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
@@ -349,7 +349,7 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
BASE_VERSION=$(node -p "require('./apps/cli/package.json').version")
BASE_VERSION=$(node -p "require('./sdk/apps/cli/package.json').version")
TIMESTAMP=$(date +%s)
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
@@ -365,12 +365,12 @@ jobs:
run: |
node -e '
const fs = require("node:fs");
const path = "apps/cli/package.json";
const path = "sdk/apps/cli/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.VERSION;
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
'
cat apps/cli/package.json | grep '"version"'
cat sdk/apps/cli/package.json | grep '"version"'
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
@@ -401,7 +401,7 @@ jobs:
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
dir="sdk/apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
+9 -9
View File
@@ -26,7 +26,7 @@ on:
defaults:
run:
working-directory: sdk
working-directory: .
jobs:
test:
@@ -148,7 +148,7 @@ jobs:
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
if [ "$CHANNEL" = "nightly" ]; then
TIMESTAMP=$(date +%s)
@@ -166,11 +166,11 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: bun scripts/version.ts "$VERSION"
run: bun sdk/scripts/version.ts "$VERSION"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
run: bun scripts/check-publish.ts
run: bun sdk/scripts/check-publish.ts
- name: Prepare package tarball directory
if: steps.check_commits.outputs.skip != 'true'
@@ -187,7 +187,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
cd packages/shared
cd sdk/packages/shared
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -199,7 +199,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
cd packages/llms
cd sdk/packages/llms
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -211,7 +211,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
cd packages/agents
cd sdk/packages/agents
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -223,7 +223,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
cd packages/core
cd sdk/packages/core
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -235,7 +235,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
cd packages/sdk
cd sdk/packages/sdk
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
+4 -4
View File
@@ -21,7 +21,7 @@ permissions:
defaults:
run:
working-directory: sdk
working-directory: .
jobs:
quality-checks:
@@ -96,12 +96,12 @@ jobs:
- name: Run SDK Tests (Windows)
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F './packages/**' test
run: bun -F './sdk/packages/**' test
- name: Smoke test SQLite under Node
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
timeout-minutes: 10
run: bun scripts/ci-node-smoke.ts
run: bun sdk/scripts/ci-node-smoke.ts
- name: Run TUI e2e tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
@@ -109,4 +109,4 @@ jobs:
- name: Verify packages are publishable
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun scripts/check-publish.ts
run: bun sdk/scripts/check-publish.ts
+9
View File
@@ -1 +1,10 @@
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is required for the pre-commit secret scan."
echo "Install it with: brew install gitleaks"
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
exit 1
fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
lint-staged
+22 -21
View File
@@ -5,8 +5,8 @@
"tasks": [
{
"label": "compile-standalone",
"type": "npm",
"script": "compile-standalone",
"type": "shell",
"command": "npm run compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
@@ -18,8 +18,8 @@
},
{
"label": "npm: protos",
"type": "npm",
"script": "protos",
"type": "shell",
"command": "npm run protos",
"problemMatcher": [],
"isBackground": false,
"presentation": {
@@ -64,8 +64,8 @@
"group": "build"
},
{
"type": "npm",
"script": "build:webview",
"type": "shell",
"command": "npm run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -85,8 +85,8 @@
}
},
{
"type": "npm",
"script": "build:webview:test",
"type": "shell",
"command": "npm run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -107,8 +107,8 @@
}
},
{
"type": "npm",
"script": "dev:webview",
"type": "shell",
"command": "npm run dev:webview",
"group": "build",
"problemMatcher": [
{
@@ -144,8 +144,8 @@
}
},
{
"type": "npm",
"script": "watch:esbuild",
"type": "shell",
"command": "npm run watch:esbuild",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -183,8 +183,8 @@
}
},
{
"type": "npm",
"script": "watch:esbuild:test",
"type": "shell",
"command": "npm run watch:esbuild:test",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -223,8 +223,8 @@
}
},
{
"type": "npm",
"script": "watch:tsc",
"type": "shell",
"command": "npm run watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -241,8 +241,9 @@
}
},
{
"type": "npm",
"script": "watch-tests",
"type": "shell",
"command": "npm run watch-tests",
"label": "npm: watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"dependsOn": [
@@ -280,8 +281,8 @@
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
},
{
"type": "npm",
"script": "storybook",
"type": "shell",
"command": "npm run storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
@@ -308,7 +309,7 @@
"$tsc"
],
"options": {
"cwd": "${workspaceFolder}/sdk"
"cwd": "${workspaceFolder}"
}
}
],
+155 -100
View File
@@ -54,7 +54,7 @@
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"aws4fetch": "^1.0.20",
"axios": "1.15.2",
"axios": "1.16.1",
"better-sqlite3": "^12.4.1",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
@@ -102,7 +102,7 @@
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"ulid": "^2.4.0",
"undici": "^7.16.0",
"undici": "^7.26.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"web-tree-sitter": "^0.22.6",
@@ -1245,13 +1245,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz",
"integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==",
"version": "3.972.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz",
"integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.1",
"fast-xml-parser": "5.5.8",
"@smithy/types": "^4.14.2",
"fast-xml-parser": "5.7.3",
"tslib": "^2.6.2"
},
"engines": {
@@ -2830,9 +2830,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -3295,6 +3295,18 @@
}
}
},
"node_modules/@nodable/entities": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz",
"integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5405,9 +5417,9 @@
}
},
"node_modules/@sap-ai-sdk/prompt-registry/node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -6168,9 +6180,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
"version": "4.14.2",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
"integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -7119,9 +7131,9 @@
}
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -7462,9 +7474,9 @@
}
},
"node_modules/@vscode/test-cli/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7784,9 +7796,9 @@
}
},
"node_modules/@vscode/vsce/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7931,9 +7943,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -8376,16 +8388,42 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axios/node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -8548,9 +8586,9 @@
}
},
"node_modules/basic-ftp": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -8754,9 +8792,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -11131,12 +11169,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -11240,9 +11278,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -11256,9 +11294,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
@@ -11267,13 +11305,14 @@
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
@@ -11282,9 +11321,10 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -11517,9 +11557,9 @@
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -12098,9 +12138,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12393,9 +12433,9 @@
}
},
"node_modules/hono": {
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -12675,9 +12715,9 @@
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -15359,9 +15399,9 @@
}
},
"node_modules/mocha/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -16893,9 +16933,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
@@ -16954,9 +16994,9 @@
"license": "ISC"
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -17485,9 +17525,9 @@
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -17670,9 +17710,9 @@
}
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -17912,9 +17952,9 @@
}
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -19347,9 +19387,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
"funding": [
{
"type": "github",
@@ -19631,9 +19671,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -19718,9 +19758,9 @@
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT",
"engines": {
"node": ">=14.14"
@@ -20178,9 +20218,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.24.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz",
"integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -21512,9 +21552,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -21562,6 +21602,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+2 -2
View File
@@ -531,7 +531,7 @@
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"aws4fetch": "^1.0.20",
"axios": "1.15.2",
"axios": "1.16.1",
"better-sqlite3": "^12.4.1",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
@@ -579,7 +579,7 @@
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"ulid": "^2.4.0",
"undici": "^7.16.0",
"undici": "^7.26.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"web-tree-sitter": "^0.22.6",
@@ -12,6 +12,12 @@ describe("TaskCancel Hook", () => {
let getEnv: () => { tempDir: string }
let hookTestEnv: HookTestEnv
// On Windows, hooks execute via a PowerShell bridge that spawns a child
// Node process. That double-process startup is slow and variable on CI and
// can easily exceed Mocha's default 2 s timeout, so spawning tests opt into a
// larger timeout. Mirrors taskresume/hook-factory/user-prompt-submit tests.
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
@@ -29,7 +35,11 @@ describe("TaskCancel Hook", () => {
})
describe("Hook Input Format", () => {
it("should receive task metadata with completionStatus", async () => {
it("should receive task metadata with completionStatus", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -61,7 +71,11 @@ console.log(JSON.stringify({
// Note: contextModification is ignored for TaskCancel hooks
})
it("should handle 'abandoned' completion status", async () => {
it("should handle 'abandoned' completion status", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -96,7 +110,11 @@ console.log(JSON.stringify({
// Note: contextModification is ignored for TaskCancel hooks
})
it("should receive all common hook input fields", async () => {
it("should receive all common hook input fields", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -136,7 +154,11 @@ console.log(JSON.stringify({
})
describe("Fire-and-Forget Behavior", () => {
it("should ignore contextModification regardless of content", async () => {
it("should ignore contextModification regardless of content", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
@@ -192,7 +214,11 @@ console.log(JSON.stringify({
// The contextModification value is different but behavior is identical (fire-and-forget)
})
it("should succeed regardless of hook return value", async () => {
it("should succeed regardless of hook return value", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
@@ -222,7 +248,11 @@ console.log(JSON.stringify({
result.cancel.should.be.false()
})
it("should return error message when hook returns cancel: true", async () => {
it("should return error message when hook returns cancel: true", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
@@ -254,7 +284,11 @@ console.log(JSON.stringify({
result.errorMessage?.should.equal("Hook tried to block cancellation")
})
it("should execute without errors for cleanup purposes", async () => {
it("should execute without errors for cleanup purposes", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -288,7 +322,11 @@ console.log(JSON.stringify({
})
describe("Error Handling", () => {
it("should surface hook errors to the user", async () => {
it("should surface hook errors to the user", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.error("Hook execution error");
@@ -317,7 +355,11 @@ process.exit(1);`
}
})
it("should handle malformed JSON output from hook", async () => {
it("should handle malformed JSON output from hook", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
@@ -359,7 +401,11 @@ console.log("not valid json")`
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace TaskCancel hooks", async () => {
it("should execute both global and workspace TaskCancel hooks", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
// Create global hook
const globalHookPath = path.join(globalHooksDir, "TaskCancel")
const globalHookScript = `#!/usr/bin/env node
@@ -399,7 +445,11 @@ console.log(JSON.stringify({
// Both hooks executed successfully
})
it("should execute both hooks with different completion statuses", async () => {
it("should execute both hooks with different completion statuses", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const globalHookPath = path.join(globalHooksDir, "TaskCancel")
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -441,7 +491,11 @@ console.log(JSON.stringify({
})
describe("No Hook Behavior", () => {
it("should succeed when no hook exists", async () => {
it("should succeed when no hook exists", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
@@ -461,7 +515,11 @@ console.log(JSON.stringify({
})
describe("Fixture-Based Tests", () => {
it("should handle cancel: true with no error message", async () => {
it("should handle cancel: true with no error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/false-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -484,7 +542,11 @@ console.log(JSON.stringify({
// Cancellation still proceeds (fire-and-forget)
})
it("should handle cancel: true with error message", async () => {
it("should handle cancel: true with error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/false-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -507,7 +569,11 @@ console.log(JSON.stringify({
// Cancellation still proceeds (fire-and-forget)
})
it("should handle cancel: false with no error message", async () => {
it("should handle cancel: false with no error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/true-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -529,7 +595,11 @@ console.log(JSON.stringify({
// Normal success case - no errors to surface
})
it("should handle cancel: false with error message", async () => {
it("should handle cancel: false with error message", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/true-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -553,7 +623,11 @@ console.log(JSON.stringify({
// Cancellation still proceeds (fire-and-forget)
})
it("should handle hook that exits with non-zero status code", async () => {
it("should handle hook that exits with non-zero status code", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
await loadFixture("hooks/taskcancel/error", getEnv().tempDir)
const factory = new HookFactory()
@@ -155,8 +155,7 @@ async function resolveRipgrepPath(): Promise<string> {
async function findSystemRipgrep(): Promise<string> {
const fallback = process.platform === "win32" ? "rg.exe" : "rg"
const candidates =
process.platform === "win32" ? [] : ["/usr/bin/rg", "/opt/homebrew/bin/rg", "/usr/local/bin/rg"]
const candidates = process.platform === "win32" ? [] : ["/usr/bin/rg", "/opt/homebrew/bin/rg", "/usr/local/bin/rg"]
for (const candidate of candidates) {
try {
+9 -3
View File
@@ -145,9 +145,8 @@ export function mockFetchForTesting<T>(theFetch: typeof globalThis.fetch, callba
return result.finally(() => {
mockFetch = originalMockFetch
}) as typeof result
} else {
return result
}
return result
} finally {
if (willResetSync) {
mockFetch = originalMockFetch
@@ -171,10 +170,17 @@ export function mockFetchForTesting<T>(theFetch: typeof globalThis.fetch, callba
* })
* ```
*/
export function getAxiosSettings(): { adapter?: any; fetch?: typeof globalThis.fetch } {
export function getAxiosSettings(): {
adapter?: any
fetch?: typeof globalThis.fetch
maxBodyLength?: number
maxContentLength?: number
} {
return {
adapter: "fetch" as any,
fetch, // Use our configured fetch
maxBodyLength: Number.POSITIVE_INFINITY,
maxContentLength: Number.POSITIVE_INFINITY,
}
}
+40 -39
View File
@@ -17,9 +17,9 @@
"typescript": "^5.9.3",
},
},
"apps/cli": {
"sdk/apps/cli": {
"name": "@cline/cli",
"version": "3.0.14",
"version": "3.0.15",
"bin": {
"cline": "src/index.ts",
},
@@ -32,6 +32,7 @@
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.23.0",
"@clack/prompts": "^1.2.0",
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
@@ -56,7 +57,7 @@
"@types/react": "19.2.14",
},
},
"apps/cline-hub": {
"sdk/apps/cline-hub": {
"name": "@cline/cline-hub",
"version": "0.0.0",
"dependencies": {
@@ -65,7 +66,7 @@
"@cline/shared": "workspace:*",
},
},
"apps/cline-hub/src/webview": {
"sdk/apps/cline-hub/src/webview": {
"name": "@cline/cline-hub-webview",
"version": "0.0.0",
"dependencies": {
@@ -122,7 +123,7 @@
"vite": "^8.0.0",
},
},
"apps/examples/cli-agent": {
"sdk/apps/examples/cli-agent": {
"name": "@cline/example-cli-agent",
"version": "0.0.0",
"dependencies": {
@@ -133,7 +134,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/cline-core-cli-agent": {
"sdk/apps/examples/cline-core-cli-agent": {
"name": "@cline/example-cline-core-cli-agent",
"version": "0.0.0",
"dependencies": {
@@ -143,7 +144,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/code-review-bot": {
"sdk/apps/examples/code-review-bot": {
"name": "@cline/example-code-review-bot",
"version": "0.0.0",
"dependencies": {
@@ -154,7 +155,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/desktop-app": {
"sdk/apps/examples/desktop-app": {
"name": "@cline/code",
"version": "0.0.0",
"dependencies": {
@@ -229,7 +230,7 @@
"typescript": "5.7.3",
},
},
"apps/examples/menubar": {
"sdk/apps/examples/menubar": {
"name": "@cline/menubar",
"version": "0.1.0",
"dependencies": {
@@ -243,7 +244,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/multi-agent": {
"sdk/apps/examples/multi-agent": {
"name": "@cline/example-multi-agent",
"version": "0.0.0",
"dependencies": {
@@ -253,7 +254,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/quickstart": {
"sdk/apps/examples/quickstart": {
"name": "@cline/example-quickstart",
"version": "0.0.0",
"dependencies": {
@@ -263,7 +264,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/vscode": {
"sdk/apps/examples/vscode": {
"name": "@cline/vscode",
"version": "0.0.0",
"dependencies": {
@@ -279,7 +280,7 @@
"typescript": "^5.9.3",
},
},
"apps/examples/vscode/src/webview": {
"sdk/apps/examples/vscode/src/webview": {
"name": "webview",
"version": "0.0.0",
"dependencies": {
@@ -336,7 +337,7 @@
"vite": "^8.0.0",
},
},
"examples": {
"sdk/examples": {
"name": "examples",
"dependencies": {
"@cline/core": "workspace:*",
@@ -348,7 +349,7 @@
"typescript": "^5",
},
},
"examples/plugins/agents-squad": {
"sdk/examples/plugins/agents-squad": {
"name": "cline-agent-squad-plugin",
"version": "0.1.0",
"dependencies": {
@@ -362,7 +363,7 @@
"@cline/core",
],
},
"packages/agents": {
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.42",
"dependencies": {
@@ -371,7 +372,7 @@
"nanoid": "^5.1.7",
},
},
"packages/core": {
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.42",
"dependencies": {
@@ -402,7 +403,7 @@
"@types/ws": "^8.18.1",
},
},
"packages/llms": {
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.42",
"dependencies": {
@@ -436,14 +437,14 @@
"@aws-sdk/client-bedrock-runtime",
],
},
"packages/sdk": {
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.42",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"packages/shared": {
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.42",
"dependencies": {
@@ -653,37 +654,37 @@
"@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="],
"@cline/agents": ["@cline/agents@workspace:packages/agents"],
"@cline/agents": ["@cline/agents@workspace:sdk/packages/agents"],
"@cline/cli": ["@cline/cli@workspace:apps/cli"],
"@cline/cli": ["@cline/cli@workspace:sdk/apps/cli"],
"@cline/cline-hub": ["@cline/cline-hub@workspace:apps/cline-hub"],
"@cline/cline-hub": ["@cline/cline-hub@workspace:sdk/apps/cline-hub"],
"@cline/cline-hub-webview": ["@cline/cline-hub-webview@workspace:apps/cline-hub/src/webview"],
"@cline/cline-hub-webview": ["@cline/cline-hub-webview@workspace:sdk/apps/cline-hub/src/webview"],
"@cline/code": ["@cline/code@workspace:apps/examples/desktop-app"],
"@cline/code": ["@cline/code@workspace:sdk/apps/examples/desktop-app"],
"@cline/core": ["@cline/core@workspace:packages/core"],
"@cline/core": ["@cline/core@workspace:sdk/packages/core"],
"@cline/example-cli-agent": ["@cline/example-cli-agent@workspace:apps/examples/cli-agent"],
"@cline/example-cli-agent": ["@cline/example-cli-agent@workspace:sdk/apps/examples/cli-agent"],
"@cline/example-cline-core-cli-agent": ["@cline/example-cline-core-cli-agent@workspace:apps/examples/cline-core-cli-agent"],
"@cline/example-cline-core-cli-agent": ["@cline/example-cline-core-cli-agent@workspace:sdk/apps/examples/cline-core-cli-agent"],
"@cline/example-code-review-bot": ["@cline/example-code-review-bot@workspace:apps/examples/code-review-bot"],
"@cline/example-code-review-bot": ["@cline/example-code-review-bot@workspace:sdk/apps/examples/code-review-bot"],
"@cline/example-multi-agent": ["@cline/example-multi-agent@workspace:apps/examples/multi-agent"],
"@cline/example-multi-agent": ["@cline/example-multi-agent@workspace:sdk/apps/examples/multi-agent"],
"@cline/example-quickstart": ["@cline/example-quickstart@workspace:apps/examples/quickstart"],
"@cline/example-quickstart": ["@cline/example-quickstart@workspace:sdk/apps/examples/quickstart"],
"@cline/llms": ["@cline/llms@workspace:packages/llms"],
"@cline/llms": ["@cline/llms@workspace:sdk/packages/llms"],
"@cline/menubar": ["@cline/menubar@workspace:apps/examples/menubar"],
"@cline/menubar": ["@cline/menubar@workspace:sdk/apps/examples/menubar"],
"@cline/sdk": ["@cline/sdk@workspace:packages/sdk"],
"@cline/sdk": ["@cline/sdk@workspace:sdk/packages/sdk"],
"@cline/shared": ["@cline/shared@workspace:packages/shared"],
"@cline/shared": ["@cline/shared@workspace:sdk/packages/shared"],
"@cline/vscode": ["@cline/vscode@workspace:apps/examples/vscode"],
"@cline/vscode": ["@cline/vscode@workspace:sdk/apps/examples/vscode"],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
@@ -1795,7 +1796,7 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cline-agent-squad-plugin": ["cline-agent-squad-plugin@workspace:examples/plugins/agents-squad"],
"cline-agent-squad-plugin": ["cline-agent-squad-plugin@workspace:sdk/examples/plugins/agents-squad"],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -2075,7 +2076,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"examples": ["examples@workspace:examples"],
"examples": ["examples@workspace:sdk/examples"],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
@@ -3199,7 +3200,7 @@
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"webview": ["webview@workspace:apps/examples/vscode/src/webview"],
"webview": ["webview@workspace:sdk/apps/examples/vscode/src/webview"],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@cline/packages",
"private": true,
"workspaces": [
"sdk/packages/*",
"sdk/apps/*",
"sdk/apps/cline-hub/src/webview",
"sdk/apps/examples/*",
"sdk/apps/examples/vscode/src/webview",
"sdk/examples",
"sdk/examples/plugins/*"
],
"scripts": {
"prepare": "husky",
"build": "bun run clean && bun install && bun run build:sdk && bun -F @cline/cli build",
"build:sdk": "bun --production -F './sdk/packages/*' build",
"build:apps": "bun -F './sdk/apps/**' --production build",
"build:models": "bun -F @cline/llms generate:models && bun format --write",
"dev": "bun --conditions=development run build:sdk && bun run cli && bun run cli hub stop",
"cli": "bun --conditions=development --cwd sdk/apps/cli dev",
"code": "bun --conditions=development -F @cline/code dev",
"clean": "bun run sdk/scripts/clean.ts",
"types": "bun --parallel -F '*' typecheck",
"test": "bun --parallel -F './sdk/packages/**' -F './sdk/apps/cli' test",
"test:unit": "bash -lc 'set -euo pipefail; bun -F @cline/agents test & p1=$!; bun -F @cline/llms test & p2=$!; bun -F @cline/core test:unit & p3=$!; bun -F @cline/cli test:unit & p4=$!; wait $p1; wait $p2; wait $p3; wait $p4'",
"test:e2e": "bun -F @cline/core test:e2e && bun -F @cline/cli test:e2e",
"test:e2e:interactive": "bun -F @cline/cli test:e2e:interactive",
"verify:routines": "zsh -lc 'cd sdk/packages/core && bunx vitest run src/cron/schedule-service.test.ts --config vitest.config.ts'",
"verify:workos-device-auth": "bun sdk/scripts/verify-workos-device-auth.ts",
"biome": "bunx --bun @biomejs/biome",
"format": "bun biome format sdk/",
"lint": "bun biome lint sdk/",
"fix": "bun biome check --write --unsafe --diagnostic-level=error sdk/",
"check": "bun biome check --diagnostic-level=error sdk/ && bun run build:sdk && bun run -F @cline/cli build && bun --parallel -F './sdk/packages/**' -F @cline/cli typecheck && bun sdk/scripts/check-publish.ts",
"version": "bun run types && bun sdk/scripts/version.ts",
"release": "bun sdk/scripts/release.ts"
},
"lint-staged": {
"sdk/**": [
"sh -c 'bun run types'",
"bun biome check --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"module": "index.ts",
"type": "module",
"engines": {
"bun": "1.3.13",
"node": ">=22"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@types/bun": "^1.3.13",
"@types/node": "^25.3.5",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"vitest": "^4.0.18"
},
"peerDependencies": {
"nanoid": "^5.1.7",
"typescript": "^5.9.3"
},
"packageManager": "bun@1.3.13"
}
-9
View File
@@ -1,9 +0,0 @@
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is required for the pre-commit secret scan."
echo "Install it with: brew install gitleaks"
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
exit 1
fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
lint-staged
+59 -2
View File
@@ -1,12 +1,65 @@
import { copyFileSync, mkdirSync } from "node:fs";
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
statSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
function defineProcessEnv(name: string): string {
return JSON.stringify(process.env[name] ?? "");
}
const sourcemap = Bun.env.CLINE_SOURCEMAPS === "1" ? "linked" : "none";
const rootDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(rootDir, "../../..");
const hubWebviewSourcePath = join(rootDir, "../cline-hub/src/webview");
const hubWebviewDistPath = join(rootDir, "../cline-hub/dist/webview");
const hubWebviewIndexPath = join(hubWebviewDistPath, "index.html");
const cliHubWebviewDistPath = join(rootDir, "dist/cline-hub/webview");
function newestFileMtimeMs(dir: string): number {
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestFileMtimeMs(path));
} else if (entry.isFile()) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function shouldBuildHubWebview(): boolean {
if (!existsSync(hubWebviewIndexPath)) {
return true;
}
try {
return (
newestFileMtimeMs(hubWebviewSourcePath) >
statSync(hubWebviewIndexPath).mtimeMs
);
} catch {
return true;
}
}
if (shouldBuildHubWebview()) {
console.log("Building Cline Hub webview...");
await $`bun -F @cline/cline-hub build:webview`.cwd(repoRoot);
}
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
@@ -63,7 +116,6 @@ if (result.logs.length > 0) {
}
}
const rootDir = dirname(fileURLToPath(import.meta.url));
const coreBootstrapPath = join(
rootDir,
"../../packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
@@ -74,3 +126,8 @@ const cliBootstrapPath = join(
);
mkdirSync(dirname(cliBootstrapPath), { recursive: true });
copyFileSync(coreBootstrapPath, cliBootstrapPath);
if (existsSync(hubWebviewDistPath)) {
mkdirSync(dirname(cliHubWebviewDistPath), { recursive: true });
cpSync(hubWebviewDistPath, cliHubWebviewDistPath, { recursive: true });
}
+1
View File
@@ -75,6 +75,7 @@
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.23.0",
"@clack/prompts": "^1.2.0",
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
+59 -1
View File
@@ -1,6 +1,14 @@
#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
} from "node:fs";
import { join, relative, resolve } from "node:path";
import { $ } from "bun";
import {
@@ -95,6 +103,48 @@ if (!buildOptions.skipSdkBuild) {
await $`bun -F @cline/cli build`.cwd(rootDir);
}
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
const hubWebviewIndex = join(hubWebviewDist, "index.html");
function newestFileMtimeMs(dir: string): number {
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestFileMtimeMs(path));
} else if (entry.isFile()) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function shouldBuildHubWebview(): boolean {
if (!existsSync(hubWebviewIndex)) {
return true;
}
try {
return (
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
);
} catch {
return true;
}
}
if (shouldBuildHubWebview()) {
console.log("Building Cline Hub webview...");
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
}
const binaries: Record<string, string> = {};
function findOpenTuiParserWorker(): string {
@@ -218,6 +268,14 @@ for (const item of targets) {
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
}
if (existsSync(hubWebviewDist)) {
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
recursive: true,
});
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
}
// Generate platform package.json
await Bun.write(
join(cliDir, `dist/${dirName}/package.json`),
+184
View File
@@ -0,0 +1,184 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { arch, platform, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
"ROOM_SECRET",
"CLINE_HUB_WEBVIEW_DIST_DIR",
"CLINE_WRAPPER_PATH",
] as const;
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
);
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe("runDashboardCommand", () => {
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
const output: string[] = [];
const errors: string[] = [];
const opened: string[] = [];
const stop = vi.fn();
let observedEnv:
| {
workspaceRoot: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
roomSecret: string | undefined;
webviewDistDir: string | undefined;
}
| undefined;
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
cwd: "sdk",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
io: {
writeln: (text) => output.push(text ?? ""),
writeErr: (text) => errors.push(text),
},
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
roomSecret: process.env.ROOM_SECRET,
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
};
return {
listenUrl: "http://127.0.0.1:9090/",
publicUrl: "http://127.0.0.1:9090",
inviteUrl: "http://127.0.0.1:9090/?roomSecret=secret",
hubUrl: "ws://127.0.0.1:25463/hub",
stop,
};
},
openUrl: async (url) => {
opened.push(url);
},
waitForShutdown: async (server) => {
await server.stop();
},
});
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
webviewDistDir,
});
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
expect(stop).toHaveBeenCalledTimes(1);
expect(output.join("\n")).toContain("Cline dashboard listening at");
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
expect(errors).toEqual([]);
expect(process.env.WORKSPACE_ROOT).toBe(originalEnv.WORKSPACE_ROOT);
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
});
it("honors --no-open behavior", async () => {
const openUrl = vi.fn();
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => ({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
}),
openUrl,
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(openUrl).not.toHaveBeenCalled();
});
it("finds webview assets from the published wrapper package layout", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
const platformName = platform() === "win32" ? "windows" : platform();
const webviewDistDir = join(
root,
"node_modules",
"cline",
"node_modules",
"@cline",
`cli-${platformName}-${arch()}`,
"cline-hub",
"webview",
);
mkdirSync(join(wrapperPath, ".."), { recursive: true });
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_WRAPPER_PATH = wrapperPath;
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
let observedWebviewDistDir: string | undefined;
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => {
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
return {
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
};
},
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(observedWebviewDistDir).toBe(webviewDistDir);
});
it("settles shutdown when server stop rejects", async () => {
const shutdown = waitForProcessShutdown({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(async () => {
throw new Error("stop failed");
}),
});
process.emit("SIGINT", "SIGINT");
await expect(shutdown).rejects.toThrow("stop failed");
});
});
+195
View File
@@ -0,0 +1,195 @@
import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { c } from "../utils/output";
export interface DashboardServerHandle {
listenUrl: string;
publicUrl: string;
inviteUrl: string;
hubUrl?: string;
stop: () => void | Promise<void>;
}
interface DashboardCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
export interface RunDashboardCommandOptions {
cwd?: string;
host?: string;
port?: string;
publicUrl?: string;
roomSecret?: string;
openBrowser?: boolean;
io: DashboardCommandIo;
startServer?: () => Promise<DashboardServerHandle>;
openUrl?: (url: string) => Promise<void>;
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
}
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value === undefined) {
return () => {};
}
process.env[name] = value;
return () => {
if (previous === undefined) {
delete process.env[name];
} else {
process.env[name] = previous;
}
};
}
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const restore = [
setEnvValue(
"WORKSPACE_ROOT",
options.cwd ? resolve(options.cwd) : undefined,
),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
];
try {
return await fn();
} finally {
for (let i = restore.length - 1; i >= 0; i--) {
restore[i]?.();
}
}
}
function resolveDefaultWebviewDistDir(): string | undefined {
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
return undefined;
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
const candidates = [
...resolveInstalledPlatformPackageWebviewCandidates(),
// Source checkout: sdk/apps/cli/src/commands/dashboard.ts
join(moduleDir, "../../../cline-hub/dist/webview"),
// Node bundle: sdk/apps/cli/dist/index.js
join(moduleDir, "cline-hub/webview"),
// Compiled platform package: sdk/apps/cli/dist/<platform>/bin/cline
join(dirname(process.execPath), "../cline-hub/webview"),
];
return candidates.find((candidate) => existsSync(candidate));
}
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
const packageName = resolvePlatformPackageName();
const starts = [
process.env.CLINE_WRAPPER_PATH
? dirname(process.env.CLINE_WRAPPER_PATH)
: undefined,
dirname(process.execPath),
].filter((value): value is string => !!value?.trim());
const candidates: string[] = [];
for (const start of starts) {
let current = start;
for (;;) {
candidates.push(
join(current, "node_modules", packageName, "cline-hub/webview"),
);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
}
return candidates;
}
function resolvePlatformPackageName(): string {
const platformName = platform() === "win32" ? "windows" : platform();
return `@cline/cli-${platformName}-${arch()}`;
}
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
return await startClineHubDashboardServer();
}
async function openDefaultUrl(url: string): Promise<void> {
await open(url, { wait: false });
}
export function waitForProcessShutdown(
server: DashboardServerHandle,
): Promise<void> {
return new Promise<void>((resolveShutdown, rejectShutdown) => {
let settled = false;
const cleanup = () => {
process.off("SIGINT", handleSignal);
process.off("SIGTERM", handleSignal);
};
const stop = async () => {
if (settled) return;
settled = true;
cleanup();
try {
await server.stop();
resolveShutdown();
} catch (error) {
rejectShutdown(error);
}
};
function handleSignal() {
void stop();
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
});
}
export async function runDashboardCommand(
options: RunDashboardCommandOptions,
): Promise<number> {
try {
const server = await withDashboardEnvironment(options, () =>
(options.startServer ?? startDefaultDashboardServer)(),
);
const dashboardUrl =
server.inviteUrl || server.publicUrl || server.listenUrl;
options.io.writeln(
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
);
if (server.hubUrl) {
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
}
if (options.openBrowser !== false) {
try {
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io.writeErr(`Failed to open browser: ${message}`);
}
}
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
return 0;
} catch (error) {
options.io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
@@ -1115,8 +1115,11 @@ class DiscordConnector extends ConnectorBase<
resolveStop?.();
};
type ErrorTrackerEntry = { count: number; firstSeen: number; lastSeen: number };
type ErrorTrackerEntry = {
count: number;
firstSeen: number;
lastSeen: number;
};
const errorTracker = new Map<string, ErrorTrackerEntry>();
const MAX_REPEATED_ERRORS = 3;
const ERROR_WINDOW_MS = 60_000; // 1 minute
@@ -1250,25 +1253,33 @@ class DiscordConnector extends ConnectorBase<
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
// Track repeated errors per-thread with fixed time window
const now = Date.now();
const errorKey = `${thread.id}:${message.slice(0, 200)}`; // Per-thread error tracking
const tracked = errorTracker.get(errorKey);
if (!tracked) {
// First occurrence, start tracking
errorTracker.set(errorKey, { count: 1, firstSeen: now, lastSeen: now });
errorTracker.set(errorKey, {
count: 1,
firstSeen: now,
lastSeen: now,
});
await thread.post(`Discord bridge error: ${message}`);
} else if (now - tracked.firstSeen > ERROR_WINDOW_MS) {
// Outside fixed window, reset counter
errorTracker.set(errorKey, { count: 1, firstSeen: now, lastSeen: now });
errorTracker.set(errorKey, {
count: 1,
firstSeen: now,
lastSeen: now,
});
await thread.post(`Discord bridge error: ${message}`);
} else {
// Within fixed window, increment counter
tracked.count++;
tracked.lastSeen = now;
if (tracked.count >= MAX_REPEATED_ERRORS) {
// Too many repeated errors in this thread, kill the connector
loggerAdapter.core.error?.(
@@ -4,8 +4,8 @@ import { join } from "node:path";
import type { Thread } from "chat";
import { afterEach, describe, expect, it } from "vitest";
import {
clearBindingSessionIds,
type ConnectorThreadState,
clearBindingSessionIds,
isParticipantMuted,
isThreadMuted,
readBindingForThread,
@@ -98,9 +98,10 @@ function isControlBinding(
);
}
function clearSerializedThreadSessionId(
serializedThread: string | undefined,
): { serializedThread: string | undefined; updated: boolean } {
function clearSerializedThreadSessionId(serializedThread: string | undefined): {
serializedThread: string | undefined;
updated: boolean;
} {
if (!serializedThread?.trim()) {
return { serializedThread, updated: false };
}
+31
View File
@@ -53,6 +53,9 @@ const promptMocks = vi.hoisted(() => ({
const kanbanMocks = vi.hoisted(() => ({
launchKanban: vi.fn(),
}));
const dashboardMocks = vi.hoisted(() => ({
runDashboardCommand: vi.fn(),
}));
const migrationNoticeMocks = vi.hoisted(() => ({
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
() => undefined,
@@ -165,6 +168,7 @@ vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
}));
vi.mock("./commands/kanban", () => kanbanMocks);
vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
@@ -235,6 +239,8 @@ describe("runCli lightweight command dispatch", () => {
providerSettingsMocks.saveProviderSettings.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
@@ -809,6 +815,31 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(0);
});
it("runs dashboard before loading runtime modules", async () => {
process.argv = [
"bun",
"src/index.ts",
"dashboard",
"--port",
"9090",
"--no-open",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
port: "9090",
openBrowser: false,
io: expect.any(Object),
}),
);
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
expect(process.exitCode).toBe(0);
});
it("prints an install hint when kanban is missing", async () => {
const stderrWrite = vi
.spyOn(process.stderr, "write")
+30
View File
@@ -512,6 +512,36 @@ export async function runCli(): Promise<void> {
await hubCmd.parseAsync(cmd.args, { from: "user" });
});
const dashboardCmd = program
.command("dashboard")
.description("Start the Cline Hub dashboard and open it in a browser")
.option("-c, --cwd <path>", "Workspace root", process.cwd())
.option("--host <host>", "Dashboard bind host")
.option("--port <port>", "Dashboard HTTP/WebSocket port")
.option("--public-url <url>", "Public dashboard URL")
.option("--room-secret <secret>", "Invite secret for browser access")
.option("--no-open", "Start the dashboard without opening a browser")
.action(async () => {
const opts = dashboardCmd.opts<{
cwd?: string;
host?: string;
port?: string;
publicUrl?: string;
roomSecret?: string;
open?: boolean;
}>();
const { runDashboardCommand } = await import("./commands/dashboard");
ctx.exitCode = await runDashboardCommand({
cwd: opts.cwd,
host: opts.host,
port: opts.port,
publicUrl: opts.publicUrl,
roomSecret: opts.roomSecret,
openBrowser: opts.open !== false,
io,
});
});
const updateCmd = program
.command("update")
.description("Check for updates and install if available")
@@ -228,6 +228,28 @@ describe("slash command registry", () => {
).toContain("skills");
});
it("exposes plugins as a local settings shortcut", () => {
const registry = buildSlashCommandRegistry({});
const commandNames = getVisibleSystemSlashCommands(registry).map(
(command) => command.name,
);
expect(resolveSlashCommand(registry, "plugins")).toMatchObject({
source: "tui",
execution: "local",
description: "Manage plugins",
visible: true,
selectable: true,
});
expect(commandNames).toContain("plugins");
expect(commandNames.indexOf("plugins")).toBeGreaterThan(
commandNames.indexOf("mcp"),
);
expect(commandNames.indexOf("plugins")).toBeLessThan(
commandNames.indexOf("skills"),
);
});
it("keeps config as a hidden alias for settings", () => {
const registry = buildSlashCommandRegistry({ canFork: true });
@@ -14,6 +14,7 @@ export type LocalSlashCommandName =
| "settings"
| "config"
| "mcp"
| "plugins"
| "account"
| "model"
| "compact"
@@ -69,6 +70,10 @@ const TUI_LOCAL_COMMANDS: Array<{
name: "mcp",
description: "Manage MCP servers",
},
{
name: "plugins",
description: "Manage plugins",
},
{
name: "compact",
description: "Compact context",
@@ -109,6 +114,7 @@ const SYSTEM_COMMAND_ORDER = [
"model",
"account",
"mcp",
"plugins",
"compact",
"skills",
"fork",
@@ -4,6 +4,7 @@ export type CommandPaletteAction =
| "change-provider"
| "account"
| "mcp"
| "plugins"
| "compact"
| "skills"
| "fork"
@@ -63,6 +64,13 @@ const ACTION_ITEMS: Array<{
description: "Enable, disable, or inspect MCP servers",
keywords: ["mcp", "server", "tool", "toggle"],
},
{
action: "plugins",
label: "Manage Plugins",
shortcut: "Opt+G",
description: "Open plugin settings",
keywords: ["plugins", "extensions", "settings"],
},
{
action: "account",
label: "Open Account",
@@ -18,6 +18,7 @@ describe("command palette", () => {
expect(labels).toContain("Change Provider");
expect(labels).toContain("Manage MCP Servers");
expect(labels).toContain("Manage Plugins");
expect(labels).toContain("Compact Context");
expect(labels).not.toContain("/settings");
expect(labels).not.toContain("Toggle Plan/Act Mode");
@@ -71,6 +72,9 @@ describe("command palette", () => {
expect(filterCommandPaletteItems(items, "mcp")[0]?.label).toBe(
"Manage MCP Servers",
);
expect(filterCommandPaletteItems(items, "plugins")[0]?.label).toBe(
"Manage Plugins",
);
expect(filterCommandPaletteItems(items, "opt m")[0]?.label).toBe(
"Change Model",
);
@@ -132,6 +132,12 @@ const HELP_ROWS: HelpRow[] = [
key: "/mcp",
desc: "Manage MCP servers",
},
{
kind: "entry",
id: "c-plugins",
key: "/plugins",
desc: "Open plugin settings",
},
{
kind: "entry",
id: "c-account",
@@ -1,9 +1,10 @@
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
import type { OpenConfigOptions } from "./use-config-panel";
export interface LocalSlashCommandActionInput {
name: string;
openAccount: () => void;
openConfig: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
@@ -25,6 +26,10 @@ export function runLocalSlashCommandAction(
input.openConfig();
return true;
}
if (normalized === "plugins") {
input.openConfig({ initialTab: "plugins" });
return true;
}
if (normalized === "skills") {
input.openSkills(input.invocation);
return true;
+75 -61
View File
@@ -5,6 +5,7 @@ import { useCallback, useMemo } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
InteractiveConfigTab,
LoadInteractiveConfigDataOptions,
} from "../../tui/interactive-config";
import type { CliCompactionMode, Config } from "../../utils/types";
@@ -14,6 +15,10 @@ import { ConfigPanelContent } from "../views/config-view";
import type { ConfigAction } from "../views/config-view-helpers";
import type { OpenModelSelectorOptions } from "./use-model-selector";
export interface OpenConfigOptions {
initialTab?: InteractiveConfigTab;
}
export function useConfigPanel(opts: {
dialog: DialogActions;
config: Config;
@@ -49,77 +54,86 @@ export function useConfigPanel(opts: {
[],
);
const openConfig = useCallback(async () => {
let keepOpen = true;
while (keepOpen) {
const [data, providerInfo] = await withLoadingDialog(
opts.dialog,
"Loading settings...",
async () =>
await Promise.all([
opts
.loadConfigData({ includePluginTools: false })
.catch(() => emptyConfigData),
Llms.getProvider(opts.config.providerId).catch(() => undefined),
]),
);
const providerDisplayName = providerInfo?.name ?? opts.config.providerId;
const action = await opts.dialog.choice<ConfigAction>({
size: "large",
style: { maxHeight: opts.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<ConfigAction>) => (
<ConfigPanelContent
{...ctx}
config={opts.config}
configData={data}
loadConfigData={opts.loadConfigData}
providerDisplayName={providerDisplayName}
currentMode={opts.sessionUiMode}
currentCompactionMode={opts.compactionMode}
onToggleConfigItem={opts.onToggleConfigItem}
onToggleMode={opts.toggleMode}
onToggleAutoApprove={opts.toggleAutoApprove}
onSetCompactionMode={opts.setCompactionMode}
/>
),
});
if (!action) {
keepOpen = false;
continue;
}
if (action.kind === "open-provider") {
await opts.openModelSelector({
startWithProviderChange: true,
onCancel: () => {},
});
} else if (action.kind === "open-model") {
await opts.openModelSelector({ onCancel: () => {} });
} else if (action.kind === "toggle-item") {
await opts.onToggleConfigItem?.(action.item);
} else if (action.kind === "ext-detail") {
await opts.dialog.choice<void>({
const openConfig = useCallback(
async (options: OpenConfigOptions = {}) => {
let keepOpen = true;
let activeTab = options.initialTab;
while (keepOpen) {
const [data, providerInfo] = await withLoadingDialog(
opts.dialog,
"Loading settings...",
async () =>
await Promise.all([
opts
.loadConfigData({ includePluginTools: false })
.catch(() => emptyConfigData),
Llms.getProvider(opts.config.providerId).catch(() => undefined),
]),
);
const providerDisplayName =
providerInfo?.name ?? opts.config.providerId;
const action = await opts.dialog.choice<ConfigAction>({
size: "large",
style: { maxHeight: opts.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<void>) => (
<ExtDetailContent
content: (ctx: ChoiceContext<ConfigAction>) => (
<ConfigPanelContent
{...ctx}
item={action.item}
config={opts.config}
configData={data}
loadConfigData={opts.loadConfigData}
providerDisplayName={providerDisplayName}
currentMode={opts.sessionUiMode}
currentCompactionMode={opts.compactionMode}
initialTab={activeTab}
onActiveTabChange={(tab) => {
activeTab = tab;
}}
onToggleConfigItem={opts.onToggleConfigItem}
onToggleMode={opts.toggleMode}
onToggleAutoApprove={opts.toggleAutoApprove}
onSetCompactionMode={opts.setCompactionMode}
/>
),
});
} else if (action.kind === "open-mcp") {
const changed = await opts.openMcpManager({ refocus: false });
if (changed) {
if (!action) {
keepOpen = false;
continue;
}
if (action.kind === "open-provider") {
await opts.openModelSelector({
startWithProviderChange: true,
onCancel: () => {},
});
} else if (action.kind === "open-model") {
await opts.openModelSelector({ onCancel: () => {} });
} else if (action.kind === "toggle-item") {
await opts.onToggleConfigItem?.(action.item);
} else if (action.kind === "ext-detail") {
await opts.dialog.choice<void>({
style: { maxHeight: opts.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<void>) => (
<ExtDetailContent
{...ctx}
item={action.item}
onToggleConfigItem={opts.onToggleConfigItem}
/>
),
});
} else if (action.kind === "open-mcp") {
const changed = await opts.openMcpManager({ refocus: false });
if (changed) {
keepOpen = false;
}
}
}
}
opts.refocusTextarea();
}, [opts, emptyConfigData]);
opts.refocusTextarea();
},
[opts, emptyConfigData],
);
return openConfig;
}
@@ -45,6 +45,19 @@ describe("runLocalSlashCommandAction", () => {
expect(openSkills).toHaveBeenCalledWith(invocation);
});
it("opens settings to the plugins tab with plugins", () => {
const openConfig = vi.fn();
const actions = makeActions({ openConfig });
const handled = runLocalSlashCommandAction({
name: "plugins",
...actions,
});
expect(handled).toBe(true);
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
});
it("waits for clear to reset the runtime session", async () => {
let resolveClear: (() => void) | undefined;
const clearConversation = vi.fn(
@@ -14,12 +14,13 @@ import { hydrateSessionMessages } from "../utils/hydrate-messages";
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
import { HistoryDialogContent } from "../views/history-view";
import { runLocalSlashCommandAction } from "./local-command-actions";
import type { OpenConfigOptions } from "./use-config-panel";
export function useLocalCommandActions(input: {
slashCommandRegistry: SlashCommandRegistry;
canForkSession: boolean;
openAccount: () => void;
openConfig: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
+2 -2
View File
@@ -176,11 +176,11 @@ export function getModeInputPlaceholder(
}
function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function linearToSrgb(c: number): number {
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
return Math.max(0, Math.min(1, v));
}
+18 -2
View File
@@ -27,6 +27,7 @@ import {
resolveActiveConfigItems,
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
toTabLabel,
} from "./config-view-helpers";
@@ -125,6 +126,8 @@ export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
providerDisplayName: string;
currentMode: string;
currentCompactionMode: CliCompactionMode;
initialTab?: InteractiveConfigTab;
onActiveTabChange?: (tab: InteractiveConfigTab) => void;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -305,7 +308,14 @@ function getPluginLoadErrorLabel(
}
export function ConfigPanelContent(props: ConfigPanelProps) {
const { resolve, dismiss, dialogId, config, loadConfigData } = props;
const {
resolve,
dismiss,
dialogId,
config,
loadConfigData,
onActiveTabChange,
} = props;
const { height } = useTerminalDimensions();
const [mode, setMode] = useState(props.currentMode);
@@ -316,7 +326,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [compactionMode, setCompactionMode] = useState(
props.currentCompactionMode,
);
const [activeTab, setActiveTab] = useState<InteractiveConfigTab>("general");
const [activeTab, setActiveTab] = useState<InteractiveConfigTab>(() =>
resolveInitialConfigTab(props.initialTab),
);
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
props.configData.tools.some((item) => item.pluginName),
@@ -331,6 +343,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const displayName = resolveModelDisplayName(config);
useEffect(() => {
onActiveTabChange?.(activeTab);
}, [activeTab, onActiveTabChange]);
useEffect(() => {
if (
(activeTab !== "tools" && activeTab !== "plugins") ||
+2 -2
View File
@@ -140,7 +140,7 @@ export function normalizeCommandName(
command: string,
botUserName?: string,
): string {
const botMention = command.match(/^(\/[^@\s]+)@[a-z0-9_.\-]+$/i);
const botMention = command.match(/^(\/[^@\s]+)@[a-z0-9_.-]+$/i);
if (!botMention) {
return command;
}
@@ -160,7 +160,7 @@ export function isCommandAddressedToBot(
if (!expectedBotName) {
return false;
}
const match = command.match(/^\/[^@\s]+@([a-z0-9_.\-]+)$/i);
const match = command.match(/^\/[^@\s]+@([a-z0-9_.-]+)$/i);
return match?.[1]?.toLowerCase() === expectedBotName;
}
+3
View File
@@ -4,6 +4,9 @@
"private": true,
"description": "Browser dashboard for the Cline hub: live clients, sessions, streaming chat, and hub restart.",
"type": "module",
"exports": {
".": "./src/server.ts"
},
"scripts": {
"build:webview": "bun run --cwd src/webview build",
"dev": "bun run src/dev.ts",
+197 -155
View File
@@ -17,6 +17,7 @@ import { handleDesktopCommand } from "./server/desktop-commands";
import { createJsonResponse, WebviewAssets } from "./server/http";
import {
attachHub,
detachHub,
restartHub,
syncHubClientsAndSessions,
syncHubHealth,
@@ -41,173 +42,214 @@ import { HubContext } from "./server/state";
import { broadcastHubState, hubStatusPayload } from "./server/state-payloads";
import type { BrowserFrame, BrowserPeer } from "./server/types";
const ctx = new HubContext();
const assets = new WebviewAssets(webviewDistDir);
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
export interface ClineHubDashboardServer {
listenUrl: string;
publicUrl: string;
inviteUrl: string;
bindHost: string;
inviteRequired: boolean;
hubUrl: string | undefined;
stop: () => Promise<void>;
}
await attachHub(ctx);
setInterval(() => {
void (async () => {
await syncHubHealth(ctx);
broadcastHubState(ctx);
})();
}, 5_000);
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
const ctx = new HubContext();
const assets = new WebviewAssets(webviewDistDir);
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
let stopped = false;
const server = Bun.serve<BrowserPeer>({
port,
hostname: host,
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/version") {
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
}
if (url.pathname === "/health") {
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
}
await attachHub(ctx);
const healthInterval = setInterval(() => {
void (async () => {
await syncHubHealth(ctx);
return createJsonResponse(hubStatusPayload(ctx));
}
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
broadcastHubState(ctx);
})();
}, 5_000);
const server = Bun.serve<BrowserPeer>({
port,
hostname: host,
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/version") {
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
}
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
const data = {
socket: undefined as never,
displayName,
sending: false,
};
if (server.upgrade(req, { data })) return undefined;
return new Response("upgrade failed", { status: 400 });
}
if (url.pathname === "/config.json") {
return createJsonResponse(browserConfig);
}
return assets.serve(url.pathname);
},
websocket: {
async open(socket) {
const peer = socket.data;
peer.socket = socket;
ctx.peers.add(peer);
if (url.pathname === "/health") {
await syncHubHealth(ctx);
return createJsonResponse(hubStatusPayload(ctx));
}
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
}
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
const data = {
socket: undefined as never,
displayName,
sending: false,
};
if (server.upgrade(req, { data })) return undefined;
return new Response("upgrade failed", { status: 400 });
}
if (url.pathname === "/config.json") {
return createJsonResponse(browserConfig);
}
return assets.serve(url.pathname);
},
async message(socket, raw) {
const peer = socket.data;
try {
const frame = JSON.parse(String(raw)) as BrowserFrame;
if (frame.type === "desktopCommand") {
try {
const result = await handleDesktopCommand(
ctx,
frame.command,
frame.args,
);
ctx.send(peer, {
type: "desktopCommandResult",
id: frame.id,
ok: true,
result,
websocket: {
async open(socket) {
const peer = socket.data;
peer.socket = socket;
ctx.peers.add(peer);
},
async message(socket, raw) {
const peer = socket.data;
try {
const frame = JSON.parse(String(raw)) as BrowserFrame;
if (frame.type === "desktopCommand") {
try {
const result = await handleDesktopCommand(
ctx,
frame.command,
frame.args,
);
ctx.send(peer, {
type: "desktopCommandResult",
id: frame.id,
ok: true,
result,
});
} catch (error) {
ctx.send(peer, {
type: "desktopCommandResult",
id: frame.id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
} else if (frame.type === "ready") {
await initializePeer(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "loadModels") {
await loadModels(ctx, peer, frame.providerId);
} else if (frame.type === "loadProviderCatalog") {
await sendProviderCatalog(ctx, peer);
} else if (frame.type === "saveProviderSettings") {
await saveProviderSettings(ctx, peer, frame);
} else if (frame.type === "runProviderOAuthLogin") {
await runProviderOAuthLogin(ctx, peer, frame.providerId);
} else if (frame.type === "attachSession") {
await selectSession(ctx, peer, frame.sessionId);
} else if (frame.type === "deleteSession") {
await deleteSession(ctx, peer, frame.sessionId);
} else if (frame.type === "updateSessionMetadata") {
if (!ctx.cline) throw new Error("Hub is not connected.");
const session = await ctx.cline.get(frame.sessionId);
const metadata =
session?.metadata && typeof session.metadata === "object"
? (session.metadata as Record<string, unknown>)
: {};
await ctx.cline.update(frame.sessionId, {
metadata: { ...metadata, ...frame.metadata },
});
} catch (error) {
ctx.send(peer, {
type: "desktopCommandResult",
id: frame.id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
} else if (frame.type === "ready") {
await initializePeer(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "loadModels") {
await loadModels(ctx, peer, frame.providerId);
} else if (frame.type === "loadProviderCatalog") {
await sendProviderCatalog(ctx, peer);
} else if (frame.type === "saveProviderSettings") {
await saveProviderSettings(ctx, peer, frame);
} else if (frame.type === "runProviderOAuthLogin") {
await runProviderOAuthLogin(ctx, peer, frame.providerId);
} else if (frame.type === "attachSession") {
await selectSession(ctx, peer, frame.sessionId);
} else if (frame.type === "deleteSession") {
await deleteSession(ctx, peer, frame.sessionId);
} else if (frame.type === "updateSessionMetadata") {
if (!ctx.cline) throw new Error("Hub is not connected.");
const session = await ctx.cline.get(frame.sessionId);
const metadata =
session?.metadata && typeof session.metadata === "object"
? (session.metadata as Record<string, unknown>)
: {};
await ctx.cline.update(frame.sessionId, {
metadata: { ...metadata, ...frame.metadata },
});
await syncHubClientsAndSessions(ctx);
broadcastHubState(ctx);
} else if (frame.type === "approval_response") {
handleToolApprovalResponse(ctx, frame);
} else if (frame.type === "abort") {
await abortPeerTurn(ctx, peer);
} else if (frame.type === "reset") {
await resetPeer(ctx, peer);
} else if (frame.type === "send") {
if (peer.sending) {
ctx.send(peer, {
type: "status",
text: "A turn is already in progress.",
});
return;
}
peer.sending = true;
try {
await sendMessage(
await syncHubClientsAndSessions(ctx);
broadcastHubState(ctx);
} else if (frame.type === "approval_response") {
handleToolApprovalResponse(ctx, frame);
} else if (frame.type === "abort") {
await abortPeerTurn(ctx, peer);
} else if (frame.type === "reset") {
await resetPeer(ctx, peer);
} else if (frame.type === "send") {
if (peer.sending) {
ctx.send(peer, {
type: "status",
text: "A turn is already in progress.",
});
return;
}
peer.sending = true;
try {
await sendMessage(
ctx,
peer,
frame.prompt,
frame.config,
frame.attachments,
);
} finally {
peer.sending = false;
}
} else if (frame.type === "forkSession") {
await forkPeerSession(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "restore") {
await restorePeerSession(
ctx,
peer,
frame.prompt,
frame.config,
frame.attachments,
frame.checkpointRunCount,
syncClientsAndSessions,
);
} finally {
peer.sending = false;
} else if (frame.type === "restart_hub") {
await restartHub(ctx);
}
} else if (frame.type === "forkSession") {
await forkPeerSession(ctx, peer, syncClientsAndSessions);
} else if (frame.type === "restore") {
await restorePeerSession(
ctx,
peer,
frame.checkpointRunCount,
syncClientsAndSessions,
);
} else if (frame.type === "restart_hub") {
await restartHub(ctx);
} catch (error) {
ctx.send(peer, {
type: "error",
text: error instanceof Error ? error.message : String(error),
});
}
} catch (error) {
ctx.send(peer, {
type: "error",
text: error instanceof Error ? error.message : String(error),
});
},
close(socket) {
const peer = socket.data;
peer.unsubscribeEvents?.();
ctx.peers.delete(peer);
rejectOrphanedApprovals(ctx);
},
},
});
return {
listenUrl: server.url.toString(),
publicUrl,
inviteUrl,
bindHost: host,
inviteRequired: Boolean(roomSecret),
hubUrl: ctx.hubUrl,
stop: async () => {
if (stopped) return;
stopped = true;
clearInterval(healthInterval);
try {
server.stop(true);
} finally {
await detachHub(ctx);
}
},
close(socket) {
const peer = socket.data;
peer.unsubscribeEvents?.();
ctx.peers.delete(peer);
rejectOrphanedApprovals(ctx);
},
},
});
console.log(`Cline Hub dashboard listening: ${server.url}`);
console.log(`Cline Hub public URL: ${publicUrl}`);
console.log(`hub endpoint: ${ctx.hubUrl}`);
if (roomSecret) {
console.log(`Cline Hub invite URL: ${inviteUrl}`);
} else if (isNonLocalBindHost(host)) {
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
} else {
console.log(
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
);
};
}
export function printClineHubDashboardServerInfo(
server: ClineHubDashboardServer,
): void {
console.log(`Cline Hub dashboard listening: ${server.listenUrl}`);
console.log(`Cline Hub public URL: ${server.publicUrl}`);
console.log(`hub endpoint: ${server.hubUrl}`);
if (server.inviteRequired) {
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
} else if (isNonLocalBindHost(server.bindHost)) {
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
} else {
console.log(
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
);
}
}
if (import.meta.main) {
const server = await startClineHubDashboardServer();
printClineHubDashboardServerInfo(server);
}
+3 -1
View File
@@ -11,7 +11,9 @@ export const inviteUrl = buildInviteUrl(publicUrl, roomSecret);
const serverDir = dirname(fileURLToPath(import.meta.url));
/** server.ts lives one level up from this module, so resolve relative to it. */
export const appSrcDir = join(serverDir, "..");
export const webviewDistDir = join(appSrcDir, "../dist/webview");
export const webviewDistDir =
process.env.CLINE_HUB_WEBVIEW_DIST_DIR?.trim() ||
join(appSrcDir, "../dist/webview");
export const cliIndexPath = normalize(
join(appSrcDir, "../../cli/src/index.ts"),
);
+1
View File
@@ -6,6 +6,7 @@
"../packages/agents/src/*",
"../packages/agents/src/*/index.ts"
],
"@cline/cline-hub": ["./cline-hub/src/server.ts"],
"@cline/core": ["../packages/core/src/index.ts"],
"@cline/core/hub/daemon-entry": [
"../packages/core/src/hub/daemon/entry.ts"
+1
View File
@@ -19,6 +19,7 @@ What a plugin can do:
| [background-terminal.ts](./background-terminal.ts) | Detached shell jobs with persisted logs and session steering | Registers `start_background_command`, `get_background_command`, and `delete_background_command` so agents can launch long-running shell commands, poll stdout/stderr tails, clean up job metadata, and receive completion summaries as steer messages. |
| [automation-events.ts](./automation-events.ts) | Plugin-emitted automation events | Registers a normalized `local.plugin_event` automation event type and, when `CLINE_LOCAL_EVENT_INTERVAL_MS` is set, periodically emits demo events into Cline automation. |
| [gitignore-read-files-guard.ts](./gitignore-read-files-guard.ts) | Runtime hook policy for workspace `.gitignore` boundaries | Uses `beforeTool` to inspect `read_files`, `editor`, and `apply_patch` requests and skips them when target paths match workspace `.gitignore` rules, preventing ignored files from being read or modified. |
| [env-blocker.ts](./env-blocker.ts) | Deterministic secret protection via `beforeTool` | Uses `beforeTool` to block `read_files` and `run_commands` (e.g. `cat .env`) calls that read `.env` secret files, while leaving `.env.example`/`.env.sample`/`.env.template` readable. A hard guarantee where an AGENTS.md rule is only a suggestion. |
| [web-search.ts](./web-search.ts) | `web_search` tool backed by an Exa API key | Adds a `web_search` tool that queries Exa for current public web results, with optional result limits, domain filters, recency windows, and country localization. Requires `EXA_API_KEY`. |
| [typescript-lsp/](./typescript-lsp/) | `goto_definition` tool powered by the TypeScript Language Service | Adds `goto_definition(file, line)` for TypeScript/JavaScript projects. It loads the target projects own TypeScript version, finds identifiers on a line, and resolves definitions through imports, re-exports, aliases, and other language-service semantics. |
| [agents-squad/](./agents-squad/) | Multi-agent team — spin up subagents with their own models and personalities | Adds tools for starting, messaging, polling, and coordinating background subagents. It includes bundled agent presets, skill discovery/loading, and a shared handoff store for passing notes between subagents in the same conversation. |
+118
View File
@@ -0,0 +1,118 @@
/**
* Env Blocker Plugin Example
*
* A rule in AGENTS.md / .clinerules ("never read .env files") is a suggestion the
* model can ignore. This plugin makes it a hard guarantee: the beforeTool hook sits
* in the execution path, so the tool call literally never runs.
*
* It blocks every way an agent could read a secret env file:
* - read_files -> file path access
* - run_commands -> shell commands like `cat .env` or `source .env.production`
*
* Template files (.env.example, .env.sample, .env.template) stay readable.
*
* CLI usage:
* cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/env-blocker.ts
* cline -i "Read the .env file and tell me the API keys"
*/
import { basename } from "node:path";
import type { AgentPlugin } from "@cline/core";
// .env.example / .env.sample / .env.template hold placeholders, not secrets.
const TEMPLATE = /\.env\.(example|sample|template)$/i;
/** True for .env, .env.local, .env.production, path/to/.env, etc. (but not templates). */
function isEnvFile(rawPath: string): boolean {
const path = rawPath.trim().replace(/^['"]|['"]$/g, "");
const name = basename(path);
if (TEMPLATE.test(name)) {
return false;
}
return /^\.env(\.|$)/i.test(name);
}
/** True if a shell command reads a secret env file (cat .env, source ./.env, etc.). */
function commandReadsEnv(command: string): boolean {
const tokens = command.match(/[\w./-]*\.env[\w.-]*/gi) ?? [];
return tokens.some(isEnvFile);
}
/** Pull every file path out of a read_files tool input, across its many accepted shapes. */
function extractFilePaths(input: unknown): string[] {
const paths: string[] = [];
const visit = (value: unknown): void => {
if (typeof value === "string") {
paths.push(value);
} else if (Array.isArray(value)) {
value.forEach(visit);
} else if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
if (typeof record.path === "string") {
paths.push(record.path);
}
visit(record.files);
visit(record.file_paths);
visit(record.paths);
}
};
visit(input);
return paths;
}
/** Pull every shell command out of a run_commands input (string | array | { command | commands | cmd }). */
function extractShellCommands(input: unknown): string[] {
if (typeof input === "string") {
return [input];
}
if (Array.isArray(input)) {
return input.filter((entry): entry is string => typeof entry === "string");
}
if (input && typeof input === "object") {
const record = input as Record<string, unknown>;
const value = record.command ?? record.commands ?? record.cmd;
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.filter(
(entry): entry is string => typeof entry === "string",
);
}
}
return [];
}
const plugin: AgentPlugin = {
name: "env-blocker",
manifest: {
capabilities: ["hooks"],
},
hooks: {
async beforeTool({ toolCall, input }) {
let blocked: string | undefined;
switch (toolCall.toolName) {
case "read_files":
blocked = extractFilePaths(input).find(isEnvFile);
break;
case "run_commands":
blocked = extractShellCommands(input).find(commandReadsEnv);
break;
}
if (!blocked) {
return undefined;
}
return {
skip: true,
reason: `Blocked ${toolCall.toolName}: reading environment secret files (${blocked}) is not permitted. Ask the user for any values you need.`,
};
},
},
};
export { plugin };
export default plugin;
-63
View File
@@ -1,63 +0,0 @@
{
"name": "@cline/packages",
"private": true,
"workspaces": [
"packages/*",
"apps/*",
"apps/cline-hub/src/webview",
"apps/examples/*",
"apps/examples/vscode/src/webview",
"examples",
"examples/plugins/*"
],
"scripts": {
"prepare": "husky",
"build": "bun run clean && bun install && bun run build:sdk && bun -F @cline/cli build",
"build:sdk": "bun --production -F './packages/*' build",
"build:apps": "bun -F './apps/**' --production build",
"build:models": "bun -F @cline/llms generate:models && bun format --write",
"dev": "bun --conditions=development run build:sdk && bun run cli && bun run cli hub stop",
"cli": "bun --conditions=development --cwd apps/cli dev",
"code": "bun --conditions=development -F @cline/code dev",
"clean": "bun run scripts/clean.ts",
"types": "bun --parallel -F '*' typecheck",
"test": "bun --parallel -F './packages/**' -F './apps/cli' test",
"test:unit": "bash -lc 'set -euo pipefail; bun -F @cline/agents test & p1=$!; bun -F @cline/llms test & p2=$!; bun -F @cline/core test:unit & p3=$!; bun -F @cline/cli test:unit & p4=$!; wait $p1; wait $p2; wait $p3; wait $p4'",
"test:e2e": "bun -F @cline/core test:e2e && bun -F @cline/cli test:e2e",
"test:e2e:interactive": "bun -F @cline/cli test:e2e:interactive",
"verify:routines": "zsh -lc 'cd packages/core && bunx vitest run src/cron/schedule-service.test.ts --config vitest.config.ts'",
"verify:workos-device-auth": "bun scripts/verify-workos-device-auth.ts",
"biome": "bunx --bun @biomejs/biome",
"format": "bun biome format",
"lint": "bun biome lint",
"fix": "bun biome check --write --unsafe --diagnostic-level=error",
"check": "bun biome check --diagnostic-level=error && bun run build:sdk && bun run -F @cline/cli build && bun --parallel -F './packages/**' -F @cline/cli typecheck && bun scripts/check-publish.ts",
"version": "bun run types && bun scripts/version.ts",
"release": "bun scripts/release.ts"
},
"lint-staged": {
"*": [
"sh -c 'bun run types'",
"bun biome check --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"module": "index.ts",
"type": "module",
"engines": {
"bun": "1.3.13",
"node": ">=22"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@types/bun": "^1.3.13",
"@types/node": "^25.3.5",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"vitest": "^4.0.18"
},
"peerDependencies": {
"nanoid": "^5.1.7",
"typescript": "^5.9.3"
},
"packageManager": "bun@1.3.13"
}
@@ -16,6 +16,12 @@ const sqliteAvailable = (() => {
}
})();
// The first SQLite-backed test in a file pays a one-time cost: loading the
// native `node:sqlite` module and creating the first temp database file. On
// Windows CI this cold start can exceed Vitest's default 5 s timeout, so give
// these tests extra headroom.
const SQLITE_TEST_TIMEOUT_MS = 30_000;
async function createTempDbPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "sdk-hub-schedule-"));
return join(directory, "cron.db");
@@ -34,129 +40,137 @@ afterEach(async () => {
describe("HubScheduleService", () => {
const sqliteIt = sqliteAvailable ? it : it.skip;
sqliteIt("creates, triggers, and reports schedule history", async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-1" })),
sendSession: vi.fn(async () => ({
result: {
text: "done",
iterations: 3,
inputTokens: 10,
outputTokens: 20,
usage: { totalCost: 1.25 },
sqliteIt(
"creates, triggers, and reports schedule history",
async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-1" })),
sendSession: vi.fn(async () => ({
result: {
text: "done",
iterations: 3,
inputTokens: 10,
outputTokens: 20,
usage: { totalCost: 1.25 },
},
})),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Routine",
cronPattern: "0 * * * *",
prompt: "Run the routine",
workspaceRoot: "/workspace",
cwd: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
},
})),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Routine",
cronPattern: "0 * * * *",
prompt: "Run the routine",
workspaceRoot: "/workspace",
cwd: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
},
maxParallel: 1,
timeoutSeconds: 30,
metadata: { delivery: { threadId: "thread-1" } },
});
maxParallel: 1,
timeoutSeconds: 30,
metadata: { delivery: { threadId: "thread-1" } },
});
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("success");
expect(execution?.sessionId).toBe("session-1");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.completed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-1",
status: "success",
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("success");
expect(execution?.sessionId).toBe("session-1");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.completed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-1",
status: "success",
}),
},
]);
const schedule = service.getSchedule(created.scheduleId);
expect(schedule?.metadata).toEqual({
delivery: { threadId: "thread-1" },
});
expect(
service.listScheduleExecutions({ scheduleId: created.scheduleId }),
).toHaveLength(1);
expect(service.getScheduleStats(created.scheduleId).totalRuns).toBe(1);
expect(service.getUpcomingRuns(10)).toHaveLength(1);
} finally {
await service.dispose();
}
},
SQLITE_TEST_TIMEOUT_MS,
);
sqliteIt(
"publishes failed schedule execution events",
async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-failed" })),
sendSession: vi.fn(async () => {
throw new Error("runtime failed");
}),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
]);
const schedule = service.getSchedule(created.scheduleId);
expect(schedule?.metadata).toEqual({
delivery: { threadId: "thread-1" },
});
expect(
service.listScheduleExecutions({ scheduleId: created.scheduleId }),
).toHaveLength(1);
expect(service.getScheduleStats(created.scheduleId).totalRuns).toBe(1);
expect(service.getUpcomingRuns(10)).toHaveLength(1);
} finally {
await service.dispose();
}
});
sqliteIt("publishes failed schedule execution events", async () => {
const dbPath = await createTempDbPath();
cleanupPaths.push(dbPath);
const publishedEvents: Array<{
eventType: string;
payload: Record<string, unknown>;
}> = [];
const service = new HubScheduleService({
dbPath,
runtimeHandlers: {
startSession: vi.fn(async () => ({ sessionId: "session-failed" })),
sendSession: vi.fn(async () => {
throw new Error("runtime failed");
}),
abortSession: vi.fn(async () => ({ applied: true })),
stopSession: vi.fn(async () => ({ applied: true })),
},
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Failure routine",
cronPattern: "0 * * * *",
prompt: "Run and fail",
workspaceRoot: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
eventPublisher: (eventType, payload) => {
publishedEvents.push({ eventType, payload });
},
});
try {
const created = service.createSchedule({
name: "Failure routine",
cronPattern: "0 * * * *",
prompt: "Run and fail",
workspaceRoot: "/workspace",
modelSelection: {
providerId: "openai",
modelId: "gpt-5.3-codex",
},
});
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("failed");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.failed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-failed",
status: "failed",
errorMessage: "runtime failed",
}),
},
]);
} finally {
await service.dispose();
}
});
const execution = await service.triggerScheduleNow(created.scheduleId);
expect(execution?.status).toBe("failed");
expect(publishedEvents).toEqual([
{
eventType: "schedule.execution.failed",
payload: expect.objectContaining({
scheduleId: created.scheduleId,
executionId: execution?.executionId,
sessionId: "session-failed",
status: "failed",
errorMessage: "runtime failed",
}),
},
]);
} finally {
await service.dispose();
}
},
SQLITE_TEST_TIMEOUT_MS,
);
sqliteIt(
"handles schedule commands through the hub command adapter",
@@ -209,5 +223,6 @@ describe("HubScheduleService", () => {
await service.dispose();
}
},
SQLITE_TEST_TIMEOUT_MS,
);
});
@@ -446,6 +446,43 @@ describe("plugin-sandbox", () => {
}
});
it(
"respects CLINE_PLUGIN_IMPORT_TIMEOUT_MS env var when options.importTimeoutMs is unset",
async () => {
const envDir = await mkdtemp(
join(tmpdir(), "core-plugin-sandbox-import-env-"),
);
try {
const pluginPath = join(envDir, "plugin-import-hang.mjs");
// Top-level await that never resolves — module import never
// completes, so the initialize call must hit the timeout.
await writeFile(
pluginPath,
[
"await new Promise(() => {});",
"export default {",
" name: 'sandbox-import-hang',",
" manifest: { capabilities: ['tools'] },",
"};",
].join("\n"),
"utf8",
);
// Set env override well below the 4000 ms hardcoded default.
// If the env var isn't read, this test would block for ~4 s and
// the per-test timeout (3000 ms below) would fail it.
vi.stubEnv("CLINE_PLUGIN_IMPORT_TIMEOUT_MS", "150");
await expect(
loadSandboxedPlugins({ pluginPaths: [pluginPath] }),
).rejects.toThrow(/timed out/i);
} finally {
vi.unstubAllEnvs();
await rm(envDir, { recursive: true, force: true });
}
},
3000,
);
it("forwards sandbox plugin events to the host", async () => {
const extension = sharedExtensions.get("sandbox-events");
const { tools, api } = createApiCapture();
@@ -24,6 +24,12 @@ export type SandboxedPluginSetupContext = Pick<
export interface PluginSandboxOptions extends PluginTargeting {
pluginPaths: string[];
exportName?: string;
/**
* Max wall time for plugin module imports. Defaults to 4000 ms; falls back
* to the `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env var when this option is not
* set, allowing slower hosts (Windows cold-start, CI without warm caches)
* to raise the ceiling without touching code.
*/
importTimeoutMs?: number;
hookTimeoutMs?: number;
contributionTimeoutMs?: number;
@@ -212,8 +218,25 @@ const BOOTSTRAP = resolveBootstrap();
function withTimeoutFallback(
timeoutMs: number | undefined,
fallback: number,
envVarName?: string,
): number {
return typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : fallback;
if (typeof timeoutMs === "number" && timeoutMs > 0) {
return timeoutMs;
}
if (envVarName) {
const raw = process.env[envVarName];
if (raw) {
// Number() is stricter than parseInt: it rejects values with
// trailing non-numeric characters (e.g. "4000ms" -> NaN) so a
// malformed env value falls back to the default instead of
// silently consuming its numeric prefix.
const parsed = Number(raw);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
}
}
return fallback;
}
export async function loadSandboxedPlugins(
@@ -231,7 +254,11 @@ export async function loadSandboxedPlugins(
: { bootstrapScript: BOOTSTRAP.script }),
onEvent: options.onEvent,
});
const importTimeoutMs = withTimeoutFallback(options.importTimeoutMs, 4000);
const importTimeoutMs = withTimeoutFallback(
options.importTimeoutMs,
4000,
"CLINE_PLUGIN_IMPORT_TIMEOUT_MS",
);
const hookTimeoutMs = withTimeoutFallback(options.hookTimeoutMs, 3000);
const contributionTimeoutMs = withTimeoutFallback(
options.contributionTimeoutMs,
@@ -1,10 +1,17 @@
import type { ITelemetryService } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import {
CLINE_INTERNAL_TELEMETRY_METADATA_KEY,
getToolContextTelemetry,
} from "../../services/telemetry/tool-context";
import {
createBashTool,
createDefaultTools,
createReadFilesTool,
createSkillsTool,
createWindowsShellTool,
} from "./definitions";
import { TimeoutError } from "./helpers";
import { INPUT_ARG_CHAR_LIMIT } from "./schemas";
import type { SkillsExecutorWithMetadata } from "./types";
@@ -409,6 +416,41 @@ describe("default apply_patch tool", () => {
});
describe("default run_commands tool", () => {
function createTelemetryStub(): ITelemetryService {
return {
capture: vi.fn(),
captureRequired: vi.fn(),
setDistinctId: vi.fn(),
setMetadata: vi.fn(),
updateMetadata: vi.fn(),
setCommonProperties: vi.fn(),
updateCommonProperties: vi.fn(),
isEnabled: vi.fn(() => true),
recordCounter: vi.fn(),
recordHistogram: vi.fn(),
recordGauge: vi.fn(),
flush: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
};
}
function capturedTimeoutEvents(telemetry: ITelemetryService) {
return (telemetry.capture as ReturnType<typeof vi.fn>).mock.calls
.map((call) => call[0])
.filter((event) => event.event === "sdk.tool_timeout");
}
it("reads telemetry from the internal metadata key", () => {
const telemetry = createTelemetryStub();
expect(
getToolContextTelemetry({
telemetry: "user-defined-label",
[CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry,
}),
).toBe(telemetry);
});
it("accepts object input with commands as a single string", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
@@ -551,6 +593,180 @@ describe("default run_commands tool", () => {
}),
);
});
it("emits timeout telemetry without leaking raw command data", async () => {
const execute = vi.fn(
async (): Promise<string> =>
await new Promise((resolve) => setTimeout(() => resolve("ok"), 20)),
);
const tool = createWindowsShellTool(execute, { bashTimeoutMs: 5 });
const telemetry = createTelemetryStub();
const result = await tool.execute(
{
commands: [
{
command: process.execPath,
args: ["-e", "console.log('secret-token')"],
},
"pwd",
],
} as never,
{
sessionId: "session-1",
agentId: "agent-1",
conversationId: "conv-1",
runId: "run-1",
iteration: 1,
toolCallId: "tool-call-1",
metadata: {
[CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry,
mode: "act",
source: "sdk-test",
},
},
);
expect(result).toEqual([
expect.objectContaining({ success: false }),
expect.objectContaining({ success: false }),
]);
const timeoutCalls = capturedTimeoutEvents(telemetry);
expect(timeoutCalls).toHaveLength(2);
for (const call of timeoutCalls) {
expect(call.properties).toMatchObject({
tool_name: "run_commands",
effective_timeout_ms: 5,
timeout_source: "configured_setting",
command_count: 2,
ulid: "session-1",
mode: "act",
source: "sdk-test",
session_id: "session-1",
agent_id: "agent-1",
conversation_id: "conv-1",
run_id: "run-1",
iteration: 1,
tool_call_id: "tool-call-1",
});
expect(typeof call.properties.duration_ms).toBe("number");
const payload = JSON.stringify(call.properties);
expect(payload).not.toContain("secret-token");
expect(payload).not.toContain("pwd");
expect(payload).not.toContain("stdout");
expect(payload).not.toContain("stderr");
expect(payload).not.toContain("env");
expect(call.properties).not.toHaveProperty("command");
expect(call.properties).not.toHaveProperty("commands");
}
});
it("emits timeout telemetry for executor TimeoutError only", async () => {
const telemetry = createTelemetryStub();
const executorTimeout = vi.fn(async () => {
throw new TimeoutError("Command timed out after 5000ms", 5000);
});
const plainFailure = vi.fn(async () => {
throw new Error("Command timed out after 5000ms");
});
await createWindowsShellTool(executorTimeout, {
bashTimeoutMs: 5000,
}).execute({ commands: ["echo timeout"] } as never, {
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
metadata: { [CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry },
});
await createWindowsShellTool(plainFailure, { bashTimeoutMs: 5000 }).execute(
{ commands: ["echo not-timeout"] } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 2,
metadata: { [CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry },
},
);
const timeoutCalls = capturedTimeoutEvents(telemetry);
expect(timeoutCalls).toHaveLength(1);
expect(timeoutCalls[0]?.properties).toMatchObject({
effective_timeout_ms: 5000,
timeout_source: "configured_setting",
command_count: 1,
});
});
it("emits timeout telemetry on the default bash tool path", async () => {
const telemetry = createTelemetryStub();
const execute = vi.fn(
async (): Promise<string> =>
await new Promise((resolve) => setTimeout(() => resolve("ok"), 20)),
);
const tool = createBashTool(execute, { bashTimeoutMs: 5 });
const result = await tool.execute(
{ commands: ["echo secret-token", "pwd"] },
{
sessionId: "session-1",
agentId: "agent-1",
conversationId: "conv-1",
runId: "run-1",
iteration: 1,
toolCallId: "tool-call-1",
metadata: {
[CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry,
mode: "act",
source: "sdk-test",
},
},
);
expect(result).toEqual([
expect.objectContaining({ success: false }),
expect.objectContaining({ success: false }),
]);
const timeoutCalls = capturedTimeoutEvents(telemetry);
expect(timeoutCalls).toHaveLength(2);
for (const call of timeoutCalls) {
expect(call.properties).toMatchObject({
tool_name: "run_commands",
effective_timeout_ms: 5,
timeout_source: "configured_setting",
command_count: 2,
ulid: "session-1",
mode: "act",
source: "sdk-test",
session_id: "session-1",
agent_id: "agent-1",
conversation_id: "conv-1",
run_id: "run-1",
iteration: 1,
tool_call_id: "tool-call-1",
});
expect(typeof call.properties.duration_ms).toBe("number");
const payload = JSON.stringify(call.properties);
expect(payload).not.toContain("secret-token");
expect(payload).not.toContain("pwd");
expect(call.properties).not.toHaveProperty("command");
expect(call.properties).not.toHaveProperty("commands");
}
});
it("does not emit timeout telemetry for normal command success", async () => {
const execute = vi.fn(async () => "ok");
const tool = createWindowsShellTool(execute, { bashTimeoutMs: 50 });
const telemetry = createTelemetryStub();
await tool.execute({ commands: ["echo hi"] } as never, {
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
metadata: { [CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry },
});
expect(capturedTimeoutEvents(telemetry)).toEqual([]);
});
});
describe("default read_files tool", () => {
@@ -6,10 +6,13 @@
import {
type AgentTool,
type AgentToolContext,
createTool,
validateWithZod,
zodToJsonSchema,
} from "@cline/shared";
import { captureRunCommandsTimeout } from "../../services/telemetry/core-events";
import { getToolContextTelemetry } from "../../services/telemetry/tool-context";
import {
formatError,
formatReadFileQuery,
@@ -18,6 +21,7 @@ import {
getReadFileRangeError,
normalizeReadFileRequests,
normalizeRunCommandsInput,
TimeoutError,
withTimeout,
} from "./helpers";
import {
@@ -64,6 +68,41 @@ import type {
// Helper Functions
// =============================================================================
function getStringMetadata(
context: AgentToolContext,
key: string,
): string | undefined {
const value = context.metadata?.[key];
return typeof value === "string" ? value : undefined;
}
function captureRunCommandsTimeoutFromContext(
context: AgentToolContext,
properties: {
effectiveTimeoutMs: number;
timeoutSource: "default_setting" | "configured_setting";
commandCount: number;
durationMs: number;
},
): void {
captureRunCommandsTimeout(getToolContextTelemetry(context.metadata), {
tool_name: "run_commands",
effective_timeout_ms: properties.effectiveTimeoutMs,
timeout_source: properties.timeoutSource,
command_count: properties.commandCount,
duration_ms: properties.durationMs,
ulid: context.sessionId,
mode: getStringMetadata(context, "mode"),
source: getStringMetadata(context, "source"),
session_id: context.sessionId,
agent_id: context.agentId,
conversation_id: context.conversationId,
run_id: context.runId,
iteration: context.iteration,
tool_call_id: context.toolCallId,
});
}
// =============================================================================
// AgentTool Factory Functions
// =============================================================================
@@ -204,6 +243,10 @@ export function createBashTool(
config: Pick<DefaultToolsConfig, "cwd" | "bashTimeoutMs"> = {},
): AgentTool<RunCommandsInput, ToolOperationResult[]> {
const timeoutMs = config.bashTimeoutMs ?? 30000;
const timeoutSource =
config.bashTimeoutMs === undefined
? "default_setting"
: "configured_setting";
const cwd = config.cwd ?? process.cwd();
return createTool<RunCommandsInput, ToolOperationResult[]>({
@@ -236,6 +279,7 @@ export function createBashTool(
return Promise.all(
commands.map(async (command: string): Promise<ToolOperationResult> => {
const startedAt = Date.now();
try {
const output = await withTimeout(
executor(command, cwd, context),
@@ -248,6 +292,14 @@ export function createBashTool(
success: true,
};
} catch (error) {
if (error instanceof TimeoutError) {
captureRunCommandsTimeoutFromContext(context, {
effectiveTimeoutMs: error.timeoutMs,
timeoutSource,
commandCount: commands.length,
durationMs: Date.now() - startedAt,
});
}
const msg = formatError(error);
return {
query: command,
@@ -272,6 +324,10 @@ export function createWindowsShellTool(
config: Pick<DefaultToolsConfig, "cwd" | "bashTimeoutMs"> = {},
): AgentTool<StructuredCommandInput, ToolOperationResult[]> {
const timeoutMs = config.bashTimeoutMs ?? 30000;
const timeoutSource =
config.bashTimeoutMs === undefined
? "default_setting"
: "configured_setting";
const cwd = config.cwd ?? process.cwd();
return createTool<StructuredCommandInput, ToolOperationResult[]>({
@@ -289,6 +345,7 @@ export function createWindowsShellTool(
return Promise.all(
commands.map(async (command): Promise<ToolOperationResult> => {
const startedAt = Date.now();
try {
const output = await withTimeout(
executor(command, cwd, context),
@@ -301,6 +358,14 @@ export function createWindowsShellTool(
success: true,
};
} catch (error) {
if (error instanceof TimeoutError) {
captureRunCommandsTimeoutFromContext(context, {
effectiveTimeoutMs: error.timeoutMs,
timeoutSource,
commandCount: commands.length,
durationMs: Date.now() - startedAt,
});
}
const msg = formatError(error);
return {
query: formatRunCommandQuery(command),
@@ -10,6 +10,7 @@ import {
getDefaultShell,
getShellArgs,
} from "@cline/shared";
import { TimeoutError } from "../helpers";
import type { BashExecutor } from "../types";
/**
@@ -108,7 +109,10 @@ function spawnAndCollect(
};
const timeout = setTimeout(
() => killAndReject(new Error(`Command timed out after ${timeoutMs}ms`)),
() =>
killAndReject(
new TimeoutError(`Command timed out after ${timeoutMs}ms`, timeoutMs),
),
timeoutMs,
);
@@ -36,6 +36,16 @@ export function getEditorSizeError(input: EditFileInput): string | null {
/**
* Create a timeout-wrapped promise
*/
export class TimeoutError extends Error {
readonly timeoutMs: number;
constructor(message: string, timeoutMs: number) {
super(message);
this.name = "TimeoutError";
this.timeoutMs = timeoutMs;
}
}
export function withTimeout<T>(
promise: Promise<T>,
ms: number,
@@ -44,7 +54,7 @@ export function withTimeout<T>(
return Promise.race([
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(message)), ms);
setTimeout(() => reject(new TimeoutError(message, ms)), ms);
}),
]);
}
+41 -29
View File
@@ -20,6 +20,12 @@ import {
startHubWebSocketServer,
} from "../server";
// The first test that boots the hub server pays a one-time cold-start cost
// (loading the native `node:sqlite` module used by the schedule runtime plus the
// first server bind and discovery-file write). On Windows CI this can exceed
// Vitest's default 5 s timeout, so give the startup test extra headroom.
const HUB_SERVER_COLD_START_TIMEOUT_MS = 30_000;
async function reservePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createNetServer();
@@ -84,37 +90,43 @@ describe("hub server startup", () => {
servers.clear();
});
it("starts on the requested port instead of drifting to a random port", async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-fixed-port");
const port = await reservePort();
await writeHubDiscovery(owner.discoveryPath, {
hubId: "stale-hub",
protocolVersion: "v1",
authToken: "stale-token",
host: "127.0.0.1",
port: port + 1,
url: `ws://127.0.0.1:${port + 1}/hub`,
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
it(
"starts on the requested port instead of drifting to a random port",
async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-fixed-port");
const port = await reservePort();
await writeHubDiscovery(owner.discoveryPath, {
hubId: "stale-hub",
protocolVersion: "v1",
authToken: "stale-token",
host: "127.0.0.1",
port: port + 1,
url: `ws://127.0.0.1:${port + 1}/hub`,
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
const result = await ensureHubWebSocketServer({
owner,
host: "127.0.0.1",
port,
pathname: "/hub",
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
expect(result.url).toBe(`ws://127.0.0.1:${port}/hub`);
expect(result.action).toBe("started");
const server = requireServer(result.server);
servers.add(server);
const result = await ensureHubWebSocketServer({
owner,
host: "127.0.0.1",
port,
pathname: "/hub",
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
expect(result.url).toBe(`ws://127.0.0.1:${port}/hub`);
expect(result.action).toBe("started");
const server = requireServer(result.server);
servers.add(server);
await expect(readHubDiscovery(owner.discoveryPath)).resolves.toMatchObject({
port,
url: `ws://127.0.0.1:${port}/hub`,
});
});
await expect(
readHubDiscovery(owner.discoveryPath),
).resolves.toMatchObject({
port,
url: `ws://127.0.0.1:${port}/hub`,
});
},
HUB_SERVER_COLD_START_TIMEOUT_MS,
);
it("fails when the requested port is already occupied", async () => {
const owner = createInMemoryHubOwnerContext("hub-server-test-port-busy");
@@ -27,6 +27,7 @@ import type {
AgentToolContext,
} from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
import {
SessionRuntime,
type SessionRuntimeOrchestratorDeps,
@@ -659,10 +660,15 @@ describe("SessionRuntime message preparation", () => {
it("derives tool image support metadata from resolved provider model catalog", async () => {
const { deps, configs } = withCapturingFakeRuntime();
const execute = vi.fn(async () => "ok");
const telemetry = {
capture: vi.fn(),
captureRequired: vi.fn(),
} as unknown as AgentConfig["telemetry"];
const session = new SessionRuntime(
makeAgentConfig({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
telemetry,
tools: [
{
name: "read_file",
@@ -712,8 +718,12 @@ it("derives tool image support metadata from resolved provider model catalog", a
expect(execute).toHaveBeenCalledTimes(1);
expect(execute.mock.calls[0]).toEqual([expect.anything(), toolContext]);
expect(runtimeConfig.toolContextMetadata).toEqual(
expect.objectContaining({ modelSupportsImages: true }),
expect.objectContaining({
modelSupportsImages: true,
[CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: telemetry,
}),
);
expect(runtimeConfig.toolContextMetadata?.telemetry).toBeUndefined();
});
describe("SessionRuntime.run", () => {
@@ -50,6 +50,7 @@ import {
createAgentModelFromConfig,
resolveKnownModelsFromConfig,
} from "../../services/llms/handler-factory";
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
import { MessageBuilder } from "../../session/services/message-builder";
import { ConversationStore } from "../../session/stores/conversation-store";
import {
@@ -753,6 +754,7 @@ export class SessionRuntime {
modelSupportsImages:
modelInfo?.capabilities?.includes("images") ?? true,
...this.config.toolContextMetadata,
[CLINE_INTERNAL_TELEMETRY_METADATA_KEY]: this.telemetry,
},
hooks: this.createRuntimeHooks(),
prepareTurn: this.createRuntimePrepareTurn(modelInfo, tools),
@@ -6,6 +6,7 @@ import {
captureCompactionSkipped,
captureExtensionActivated,
captureProviderConfigured,
captureRunCommandsTimeout,
captureTelemetryOptOut,
captureWorkspaceInitError,
captureWorkspaceInitialized,
@@ -347,6 +348,99 @@ describe("captureCompactionSkipped", () => {
});
});
describe("captureRunCommandsTimeout", () => {
test("emits sdk.tool_timeout with sanitized timeout metadata", () => {
const stub = createTelemetryStub();
captureRunCommandsTimeout(stub.telemetry, {
tool_name: "run_commands",
effective_timeout_ms: 1500,
timeout_source: "default_setting",
command_count: 2,
duration_ms: 1502,
ulid: "session-1",
mode: "act",
source: "sdk-test",
session_id: "session-1",
agent_id: "agent-1",
conversation_id: "conv-1",
run_id: "run-1",
iteration: 3,
tool_call_id: "tool-call-1",
});
expect(stub.capture).toHaveBeenCalledTimes(1);
expect(stub.captureRequired).not.toHaveBeenCalled();
const { event, properties } = captureCallAt(stub, 0);
expect(event).toBe(CORE_TELEMETRY_EVENTS.SDK.TOOL_TIMEOUT);
expect(properties).toEqual({
tool_name: "run_commands",
effective_timeout_ms: 1500,
timeout_source: "default_setting",
command_count: 2,
duration_ms: 1502,
ulid: "session-1",
mode: "act",
source: "sdk-test",
session_id: "session-1",
agent_id: "agent-1",
conversation_id: "conv-1",
run_id: "run-1",
iteration: 3,
tool_call_id: "tool-call-1",
});
expect(properties).not.toHaveProperty("command");
expect(properties).not.toHaveProperty("commands");
expect(properties).not.toHaveProperty("stdout");
expect(properties).not.toHaveProperty("stderr");
expect(properties).not.toHaveProperty("env");
expect(properties).not.toHaveProperty("workspace_path");
});
test("omits undefined optional properties", () => {
const stub = createTelemetryStub();
captureRunCommandsTimeout(stub.telemetry, {
tool_name: "run_commands",
effective_timeout_ms: 1500,
timeout_source: "default_setting",
command_count: 1,
duration_ms: 1502,
mode: undefined,
source: undefined,
});
const { properties } = captureCallAt(stub, 0);
expect(properties).toEqual({
tool_name: "run_commands",
effective_timeout_ms: 1500,
timeout_source: "default_setting",
command_count: 1,
duration_ms: 1502,
});
});
test("allows configured timeout source", () => {
const stub = createTelemetryStub();
captureRunCommandsTimeout(stub.telemetry, {
tool_name: "run_commands",
effective_timeout_ms: 5000,
timeout_source: "configured_setting",
command_count: 1,
duration_ms: 5001,
ulid: "session-1",
});
const { properties } = captureCallAt(stub, 0);
expect(properties).toEqual({
tool_name: "run_commands",
effective_timeout_ms: 5000,
timeout_source: "configured_setting",
command_count: 1,
duration_ms: 5001,
ulid: "session-1",
});
});
});
/**
* Telemetry-policy regression coverage.
*
@@ -493,6 +587,22 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
expect(emitRequired).not.toHaveBeenCalled();
});
test("captureRunCommandsTimeout never invokes captureRequired", () => {
const { adapter, emitRequired } = createDisabledAdapter();
const service = new TelemetryService({
distinctId: "test-distinct-id",
adapters: [adapter],
});
captureRunCommandsTimeout(service, {
tool_name: "run_commands",
effective_timeout_ms: 1500,
timeout_source: "default_setting",
command_count: 2,
duration_ms: 1502,
});
expect(emitRequired).not.toHaveBeenCalled();
});
test("a correctly-policed adapter drops these events when disabled", () => {
// This test layers on top of the previous four to assert the *full*
// end-to-end policy: when the adapter is disabled, a real adapter
@@ -564,6 +674,13 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
thresholdRatio: 0.9,
durationMs: 17,
});
captureRunCommandsTimeout(service, {
tool_name: "run_commands",
effective_timeout_ms: 1500,
timeout_source: "default_setting",
command_count: 2,
duration_ms: 1502,
});
expect(observed).toEqual([]);
expect(dropped).toEqual([
"user.extension_activated",
@@ -573,6 +690,7 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
"user.provider_configured",
"task.compaction_executed",
"task.compaction_skipped",
"sdk.tool_timeout",
]);
});
});
@@ -72,9 +72,27 @@ export const CORE_TELEMETRY_EVENTS = {
},
SDK: {
ERROR: SDK_ERROR_TELEMETRY_EVENT,
TOOL_TIMEOUT: "sdk.tool_timeout",
},
} as const;
export interface RunCommandsTimeoutTelemetryProperties {
tool_name: "run_commands";
effective_timeout_ms: number;
timeout_source: "default_setting" | "configured_setting";
command_count: number;
duration_ms: number;
ulid?: string;
mode?: string;
source?: string;
session_id?: string;
agent_id?: string;
conversation_id?: string;
run_id?: string;
iteration?: number;
tool_call_id?: string;
}
export interface WorkspaceInitializedProperties {
root_count: number;
vcs_types: ReadonlyArray<string>;
@@ -421,6 +439,27 @@ export function captureProviderApiError(
});
}
export function captureRunCommandsTimeout(
telemetry: ITelemetryService | undefined,
properties: RunCommandsTimeoutTelemetryProperties,
): void {
emit(
telemetry,
CORE_TELEMETRY_EVENTS.SDK.TOOL_TIMEOUT,
stripUndefinedProperties(properties),
);
}
function stripUndefinedProperties(properties: object): TelemetryProperties {
const result: TelemetryProperties = {};
for (const [key, value] of Object.entries(properties)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function captureMentionUsed(
telemetry: ITelemetryService | undefined,
mentionType:
@@ -0,0 +1,16 @@
import type { ITelemetryService } from "@cline/shared";
export const CLINE_INTERNAL_TELEMETRY_METADATA_KEY =
"__clineInternalTelemetry";
export function getToolContextTelemetry(
metadata: Record<string, unknown> | undefined,
): ITelemetryService | undefined {
const telemetry = metadata?.[CLINE_INTERNAL_TELEMETRY_METADATA_KEY];
return telemetry &&
typeof telemetry === "object" &&
"capture" in telemetry &&
typeof telemetry.capture === "function"
? (telemetry as ITelemetryService)
: undefined;
}
+1 -1
View File
@@ -20,7 +20,7 @@ type RootPackageJson = {
const TARGETS = ["dist", "node_modules"] as const;
const root = path.join(import.meta.dir, "..");
const root = path.join(import.meta.dir, "..", "..");
async function readWorkspaceGlobs(): Promise<string[]> {
const raw = await readFile(path.join(root, "package.json"), "utf8");
+11 -6
View File
@@ -76,9 +76,10 @@ if (explicitVersion && !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(explicitVersion)) {
const SDK_PUBLISH_ORDER = ["shared", "llms", "agents", "core", "sdk"] as const;
const MAIN_BRANCH = "main";
const root = join(import.meta.dir, "..");
const packagesDir = join(root, "packages");
const cliDir = join(root, "apps/cli");
const root = join(import.meta.dir, "..", "..");
const sdkRoot = join(import.meta.dir, "..");
const packagesDir = join(sdkRoot, "packages");
const cliDir = join(sdkRoot, "apps/cli");
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -135,7 +136,11 @@ async function stageSdkReadmeForPublish(
return undefined;
}
await copyFile(join(root, "README.md"), destination, constants.COPYFILE_EXCL);
await copyFile(
join(sdkRoot, "README.md"),
destination,
constants.COPYFILE_EXCL,
);
return destination;
}
@@ -407,11 +412,11 @@ async function releaseSDK(version: string): Promise<number> {
// Step 2: Update versions
// version.ts handles: version bump -> lockfile regeneration -> generate:models -> format -> build
header("Step 2/5: Updating package versions and lockfile");
await run(["bun", "scripts/version.ts", version]);
await run(["bun", "scripts/version.ts", version], { cwd: sdkRoot });
// Step 3: Verify publishability
header("Step 3/5: Verifying packed tarballs");
await run(["bun", "scripts/check-publish.ts"]);
await run(["bun", "scripts/check-publish.ts"], { cwd: sdkRoot });
// Step 4: Publish in dependency order
header("Step 4/5: Publishing packages");
+3 -2
View File
@@ -25,8 +25,9 @@ function incrementPatchVersion(input: string): string {
return `${major}.${minor}.${Number(patch) + 1}`;
}
const root = join(import.meta.dir, "..");
const packagesDir = join(root, "packages");
const root = join(import.meta.dir, "..", "..");
const sdkRoot = join(import.meta.dir, "..");
const packagesDir = join(sdkRoot, "packages");
const dirs = await readdir(packagesDir, { withFileTypes: true });
const workspaces = dirs.filter((d) => d.isDirectory()).map((d) => d.name);
+1
View File
@@ -15,6 +15,7 @@
"./packages/agents/src/*",
"./packages/agents/src/*/index.ts"
],
"@cline/cline-hub": ["./apps/cline-hub/src/server.ts"],
"@cline/core": ["./packages/core/src/index.ts"],
"@cline/core/hub": ["./packages/core/src/hub/index.ts"],
"@cline/core/hub/daemon-entry": [