mirror of
https://github.com/cline/cline.git
synced 2026-09-08 22:13:11 +08:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba15dfca6 | ||
|
|
6ab6a1eabc | ||
|
|
2ad4146de1 | ||
|
|
cfc2250717 | ||
|
|
730bac7f59 | ||
|
|
797ea1f607 | ||
|
|
9d59de4a4c | ||
|
|
ae67ca7a13 | ||
|
|
7e2583f40c | ||
|
|
ecca88bb98 | ||
|
|
4bb93ee5b9 | ||
|
|
4f2d7398ed | ||
|
|
bc184f346d | ||
|
|
96aea0d34b | ||
|
|
676b446d47 | ||
|
|
a8835425bf | ||
|
|
e152741e1d | ||
|
|
6fec41ea70 | ||
|
|
5de1a45d1d | ||
|
|
717a2e643a | ||
|
|
e50184d316 | ||
|
|
08c6f7ecbe | ||
|
|
15e6a685d6 | ||
|
|
bc0eed950b | ||
|
|
48316027a7 | ||
|
|
4d15d16109 | ||
|
|
d2339f57f1 | ||
|
|
a209825116 | ||
|
|
acc1a25e51 | ||
|
|
5f17a6963f | ||
|
|
c7ddb96ddb | ||
|
|
107bce75b2 | ||
|
|
81792d20c6 | ||
|
|
e8e2af705d | ||
|
|
8f00fcf3ed | ||
|
|
a64d17734d | ||
|
|
8e621817c5 | ||
|
|
423fde4828 | ||
|
|
76af785fa6 | ||
|
|
dd7042fc7b | ||
|
|
38e5b7c26b | ||
|
|
8ae99cd69f | ||
|
|
845970ba7d | ||
|
|
444a9be6ec | ||
|
|
ade7775337 | ||
|
|
e147682945 | ||
|
|
ae78fb422c |
@@ -9,12 +9,13 @@ Use this skill when the user asks to release the CLI, publish `cline`, bump the
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
|
||||
> Working directory: this skill lives in the SDK sub-monorepo. Run `cd sdk` (from the repo root) before any of the shell commands below. Paths in commands and instructions (e.g. `apps/cli/package.json`, `bun release cli`) are written relative to `sdk/`.
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
|
||||
|
||||
## Release contract
|
||||
|
||||
- SDK prerequisite: the CLI depends on the SDK via `workspace:*` (`@cline/core`, `@cline/shared`, and friends). If the SDK changed since its last release, release the SDK first and wait for it to finish publishing before releasing the CLI. See "Step 0: Release the SDK first if it changed" below.
|
||||
- Version source: `apps/cli/package.json`.
|
||||
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
|
||||
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
|
||||
@@ -30,8 +31,93 @@ The skill should guide the user through one release preparation flow, then offer
|
||||
- Always ask before pushing commits or tags.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
|
||||
## Step 0: Release the SDK first if it changed
|
||||
|
||||
Do this before anything else in the Workflow below.
|
||||
|
||||
The CLI builds and ships against the SDK source in the monorepo (`workspace:*` for `@cline/core`, `@cline/shared`, and the rest), so a CLI release always contains the latest SDK code whether or not the SDK was released. The build and tests use that source too, not anything from npm. Releasing the SDK alongside the CLI is still worth doing for two reasons:
|
||||
|
||||
- Hub freshness. The hub daemon lives in `@cline/core` and stamps a `buildId` that defaults to the `@cline/core` package version (`resolveHubBuildId` in `sdk/packages/core/src/hub/discovery/index.ts`). A running hub is only retired and respawned when that `buildId` changes (`isCompatibleHubRecord` / `retireIncompatibleHub` in `sdk/packages/core/src/hub/daemon/index.ts`). So if the SDK code changed but the version did not, a user who upgrades the CLI keeps talking to their already-running hub, which is still executing the old SDK code. Bumping the SDK version makes the new CLI's `buildId` differ, so the stale hub is detected as incompatible and respawned with the fresh code.
|
||||
- Release hygiene. We want regular SDK releases; cutting one whenever we cut a CLI release keeps the published SDK in step with what the CLI ships.
|
||||
|
||||
So when the SDK has changed, release it first (which bumps the `@cline/core` version), then cut the CLI release on top of that bump. Leave the CLI's SDK dependency as `workspace:*` — the fix is to release the SDK, not to pin the CLI.
|
||||
|
||||
1. Check for unreleased SDK changes.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git tag --list 'sdk/sdk/v*' 'sdk-v*' --sort=-v:refname | head -1
|
||||
git log <last-sdk-tag>..origin/main --oneline --no-merges -- sdk/packages
|
||||
```
|
||||
|
||||
`sdk/<pkg>/v*` tags are created by the `sdk-publish.yml` workflow; `sdk-v*` tags are created by the local `bun release sdk` helper. Use whichever is newest as the baseline.
|
||||
|
||||
If `git log` prints no commits, the SDK is already up to date. Skip the rest of Step 0 and continue with the Workflow below.
|
||||
|
||||
If it prints commits, sanity-check the diff (ignore entries that are only the previous version-bump commit's lockfile or generated files), then release the SDK.
|
||||
|
||||
2. Decide the SDK version bump.
|
||||
|
||||
All SDK packages share one version, read from `sdk/packages/llms/package.json`. Ask whether this is patch, minor, major, or an explicit version. Patch is the default. Do not guess if the user has not made it clear.
|
||||
|
||||
3. Draft the SDK release notes and update the changelog.
|
||||
|
||||
Draft user-facing notes from the SDK commits found in step 1, translating commit messages into user-facing language (same approach as the CLI release notes below). Prepend a new `## <version>` section with those notes to the top of `sdk/CHANGELOG.md`, using the header format `## <version>` with no date — the same flat, newest-on-top format as `apps/cli/CHANGELOG.md`. This is the SDK changelog (all SDK packages share one version) and it is maintained by hand; the `sdk-publish.yml` workflow does not read it.
|
||||
|
||||
4. Bump versions and regenerate.
|
||||
|
||||
```sh
|
||||
bun run version <version>
|
||||
```
|
||||
|
||||
This bumps every SDK `package.json` to the new version, regenerates the lockfile and the generated model catalog, formats, and builds. Review the result.
|
||||
|
||||
5. Commit and push the bump to `main`.
|
||||
|
||||
The `sdk-publish.yml` workflow publishes the version that is committed on `main` and tags that commit, so the bump must land on `main` before the workflow runs.
|
||||
|
||||
```sh
|
||||
git add -A
|
||||
git commit -m "chore(sdk): release v<version>"
|
||||
```
|
||||
|
||||
Ask before pushing:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
6. Trigger the SDK publish workflow on the `latest` channel.
|
||||
|
||||
```sh
|
||||
gh workflow run sdk-publish.yml -f channel=latest -f confirm_publish=publish
|
||||
gh run list --workflow=sdk-publish.yml --limit=1 --json databaseId,url,status,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow runs the SDK tests, publishes `@cline/shared`, `@cline/llms`, `@cline/agents`, `@cline/core`, and `@cline/sdk` to npm with the `latest` dist-tag in dependency order, and pushes `sdk/<pkg>/v<version>` git tags.
|
||||
|
||||
7. Wait for the SDK workflow to succeed before starting the CLI release.
|
||||
|
||||
```sh
|
||||
gh run watch <run-id> --exit-status
|
||||
```
|
||||
|
||||
Do not start the CLI release until this run has finished successfully. The CLI does not install the SDK from npm, but cutting the CLI release on top of a clean, completed SDK release keeps the two in step: the CLI release commit then sits on top of the `@cline/core` version bump, so the shipped CLI carries the new version that forces a running hub to respawn with the new code, and you are not building a CLI release on top of an SDK release that failed midway.
|
||||
|
||||
After the SDK release succeeds, pull `main` so the CLI release is prepared on top of the SDK version bump:
|
||||
|
||||
```sh
|
||||
git checkout main && git pull --ff-only
|
||||
```
|
||||
|
||||
Then continue with the Workflow below.
|
||||
|
||||
For a local SDK publish from an authenticated machine instead of the workflow, `bun release sdk <version>` exists, but prefer the `sdk-publish.yml` workflow for normal releases so the CLI release can gate on a single GitHub Actions run.
|
||||
|
||||
## Workflow
|
||||
|
||||
Complete Step 0 first. Only proceed once the SDK is released (or you confirmed no SDK release was needed).
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
@@ -46,10 +132,10 @@ Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/cli-publish.yml
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
|
||||
```
|
||||
|
||||
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
|
||||
The `sdk/packages` commits matter here even though the SDK was released separately in Step 0: the CLI bundles the SDK, so SDK changes ship in this CLI release too. Read those commits and fold anything user-relevant to the CLI into the release notes (provider/model updates, behavior changes, fixes the CLI inherits). Skip SDK changes that are purely internal or have no CLI-visible effect.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
+9
-13
@@ -140,12 +140,10 @@ Adding a new key to global state requires updates in multiple places. Missing an
|
||||
|
||||
Required steps:
|
||||
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
|
||||
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
|
||||
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
|
||||
- Add to the return object: `myKey: myKey ?? defaultValue,`
|
||||
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
|
||||
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
|
||||
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
|
||||
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
|
||||
|
||||
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
|
||||
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
|
||||
@@ -159,22 +157,20 @@ Webview toggle gotcha: settings changes must also round-trip back in state paylo
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
Exception: State needed immediately at extension startup (before cache is ready)
|
||||
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
|
||||
|
||||
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
|
||||
|
||||
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
|
||||
Example pattern:
|
||||
```typescript
|
||||
// Writing (normal pattern)
|
||||
controller.stateManager.setGlobalState("myKey", value)
|
||||
|
||||
// Reading at startup in common.ts (bypass cache)
|
||||
const value = context.globalState.get<string>("myKey")
|
||||
// Reading after initialization
|
||||
const value = controller.stateManager.getGlobalStateKey("myKey")
|
||||
```
|
||||
|
||||
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
|
||||
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
|
||||
@@ -44,7 +44,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -31,6 +31,9 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
@@ -54,11 +57,13 @@ jobs:
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -114,11 +114,13 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -128,13 +128,15 @@ jobs:
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
@@ -91,13 +91,15 @@ jobs:
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
@@ -130,16 +132,19 @@ jobs:
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
@@ -229,13 +234,15 @@ jobs:
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
@@ -244,7 +251,8 @@ jobs:
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
run: cd testing-platform && npm ci
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
{
|
||||
"id": "sdk-tool-handler-telemetry",
|
||||
"rule": "Any new tool handler added to packages/agents/src or packages/core/src that performs a user-visible action (writes files, executes commands, modifies state, calls external APIs) must include a call to captureToolUsage() from packages/core/src/services/telemetry/core-events.ts, or emit a task.tool_used event via telemetry.capture(). Pure read-only helpers and getters are exempt. When in doubt, prefer instrumentation.",
|
||||
"scope": ["packages/agents/src/**", "packages/core/src/**"],
|
||||
"scope": [
|
||||
"sdk/packages/agents/src/**",
|
||||
"sdk/packages/core/src/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-session-lifecycle-telemetry",
|
||||
"rule": "New session start, end, or state-transition code paths in packages/core/src must call the appropriate typed helper from packages/core/src/services/telemetry/core-events.ts (captureTaskCreated, captureTaskCompleted, captureConversationTurnEvent, captureTokenUsage, etc.). Do not inline raw telemetry.capture() calls for session lifecycle events — always use the typed helper, which guarantees a consistent payload shape.",
|
||||
"scope": [
|
||||
"packages/core/src/cline-core/**",
|
||||
"packages/core/src/runtime/**"
|
||||
"sdk/packages/core/src/cline-core/**",
|
||||
"sdk/packages/core/src/runtime/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
@@ -22,8 +25,8 @@
|
||||
"id": "sdk-no-raw-event-strings",
|
||||
"rule": "All telemetry event name strings must be sourced from CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts. If a PR introduces a string literal in a telemetry.capture(), telemetry.captureRequired(), or recordCounter()/recordHistogram()/recordGauge() call that does not reference CORE_TELEMETRY_EVENTS, flag it. New events must be added to CORE_TELEMETRY_EVENTS first, with a typed capture helper created alongside them.",
|
||||
"scope": [
|
||||
"packages/core/src/**",
|
||||
"packages/agents/src/**",
|
||||
"sdk/packages/core/src/**",
|
||||
"sdk/packages/agents/src/**",
|
||||
"apps/cli/src/**",
|
||||
"apps/vscode/src/**"
|
||||
],
|
||||
@@ -32,13 +35,17 @@
|
||||
{
|
||||
"id": "sdk-auth-telemetry-completeness",
|
||||
"rule": "Any new OAuth or authentication provider added under packages/core/src/auth must emit all four lifecycle events using the typed helpers from core-events.ts: captureAuthStarted (at flow entry), captureAuthSucceeded + identifyAccount (on token success), captureAuthFailed (on error), and captureAuthLoggedOut (on token invalidation or explicit logout). Flag PRs that introduce a new auth flow file without all four. Cross-reference packages/core/src/auth/cline.ts and packages/core/src/auth/codex.ts as canonical examples.",
|
||||
"scope": ["packages/core/src/auth/**"],
|
||||
"scope": [
|
||||
"sdk/packages/core/src/auth/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-telemetry-doc-update",
|
||||
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
|
||||
"scope": ["packages/core/src/services/telemetry/core-events.ts"],
|
||||
"scope": [
|
||||
"sdk/packages/core/src/services/telemetry/core-events.ts"
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "packages/core/src/services/telemetry/core-events.ts",
|
||||
"path": "sdk/packages/core/src/services/telemetry/core-events.ts",
|
||||
"description": "Single source of truth for all telemetry event names (CORE_TELEMETRY_EVENTS) and their typed capture helper functions. Every PR touching telemetry must be evaluated against this catalog. New events must be defined here first."
|
||||
},
|
||||
{
|
||||
"path": "packages/shared/src/services/telemetry.ts",
|
||||
"path": "sdk/packages/shared/src/services/telemetry.ts",
|
||||
"description": "ITelemetryService interface definition. Defines the contract all telemetry implementations must satisfy (capture, captureRequired, recordCounter, recordHistogram, recordGauge, flush, dispose)."
|
||||
},
|
||||
{
|
||||
"path": "packages/core/src/services/telemetry/TelemetryService.ts",
|
||||
"path": "sdk/packages/core/src/services/telemetry/TelemetryService.ts",
|
||||
"description": "Reference implementation of ITelemetryService used by all hosts. Multi-adapter fan-out service that forwards events to OpenTelemetry."
|
||||
},
|
||||
{
|
||||
"path": "packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
|
||||
},
|
||||
{
|
||||
@@ -21,11 +21,11 @@
|
||||
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
|
||||
},
|
||||
{
|
||||
"path": "ARCHITECTURE.md",
|
||||
"path": "sdk/ARCHITECTURE.md",
|
||||
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"path": "sdk/AGENTS.md",
|
||||
"description": "Package boundary rules. Telemetry runtime services live in @cline/core; @cline/agents must not own stateful telemetry. Use to evaluate whether a telemetry change is being made in the correct package."
|
||||
}
|
||||
]
|
||||
@@ -10,9 +10,9 @@ The SDK uses OpenTelemetry (OTEL) as its sole telemetry transport. Events flow t
|
||||
```
|
||||
core-events.ts (event catalog + typed helpers)
|
||||
↓
|
||||
ITelemetryService (packages/shared) ← interface contract
|
||||
ITelemetryService (sdk/packages/shared) ← interface contract
|
||||
↓
|
||||
TelemetryService (packages/core) ← multi-adapter fan-out
|
||||
TelemetryService (sdk/packages/core) ← multi-adapter fan-out
|
||||
↓
|
||||
OpenTelemetryAdapter → OpenTelemetryProvider ← OTLP transport
|
||||
↓
|
||||
@@ -24,7 +24,7 @@ parallel-but-independent stacks; this `.greptile/` config covers only the SDK.
|
||||
|
||||
## The Single Source of Truth
|
||||
|
||||
`packages/core/src/services/telemetry/core-events.ts` is the single source of truth for all
|
||||
`sdk/packages/core/src/services/telemetry/core-events.ts` is the single source of truth for all
|
||||
event names. It exports:
|
||||
|
||||
- `CORE_TELEMETRY_EVENTS` — a frozen const object grouped by family
|
||||
@@ -60,8 +60,8 @@ Emission ownership:
|
||||
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
|
||||
- `workspace.path_resolved`: emitted from default tool executors **only when**
|
||||
`WorkspaceManager` exposes more than one root.
|
||||
- `task.*`: emitted by core session lifecycle code in `packages/core/src/cline-core/` and
|
||||
`packages/core/src/runtime/`. Hosts must not duplicate this emission.
|
||||
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
|
||||
`sdk/packages/core/src/runtime/`. Hosts must not duplicate this emission.
|
||||
|
||||
## `task.completed` Semantics
|
||||
|
||||
@@ -105,7 +105,7 @@ forwarding, hub-backed sessions silently drop their lifecycle telemetry.
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
Every authentication provider in `packages/core/src/auth/` must emit all four auth lifecycle
|
||||
Every authentication provider in `sdk/packages/core/src/auth/` must emit all four auth lifecycle
|
||||
events using the typed helpers:
|
||||
|
||||
| Phase | Helper | Where it fires |
|
||||
@@ -115,7 +115,7 @@ events using the typed helpers:
|
||||
| Token error | `captureAuthFailed(provider, errorMessage)` | In the catch block |
|
||||
| Token invalidation | `captureAuthLoggedOut(provider, reason)` | On invalid_grant or explicit logout |
|
||||
|
||||
Cross-reference `packages/core/src/auth/cline.ts` and `packages/core/src/auth/codex.ts` as
|
||||
Cross-reference `sdk/packages/core/src/auth/cline.ts` and `sdk/packages/core/src/auth/codex.ts` as
|
||||
canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [3.88.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a debug section in settings for Cline testers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
|
||||
|
||||
## [3.88.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
|
||||
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
|
||||
|
||||
## [3.87.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -212,8 +212,12 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
- Added a Cline credits refill link
|
||||
- Fixed scrolling for inline ask-question responses
|
||||
- Fixed connector thread session routing and stale hub session handling
|
||||
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
|
||||
- Fixed empty message content replay for Bedrock
|
||||
- Cleaned up the OpenAI Codex model list
|
||||
|
||||
## 3.0.20
|
||||
|
||||
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
|
||||
|
||||
## 3.0.19
|
||||
|
||||
- Fixed CLI auto-update to use `npm update` so updates apply reliably, while preserving the installed release channel (e.g. nightly).
|
||||
|
||||
## 3.0.18
|
||||
|
||||
- Fix Slack channel mentions so replies post in the original message's thread.
|
||||
- Fix the abort indicator to clear immediately when a task is cancelled.
|
||||
- Sync the Fireworks AI model registry and refresh the bundled model catalog with current platform offerings.
|
||||
- Bump the bundled SDK to v0.0.43, which forces a running Cline Hub to restart so it picks up the latest SDK code.
|
||||
|
||||
## 3.0.17
|
||||
|
||||
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
|
||||
- Fix Ctrl+C and Hub shutdown races that surfaced as "hook dispatch failed" and WebSocket connection errors from late hook events racing against Hub shutdown.
|
||||
- Fix the Hub daemon being shut down prematurely when a runtime request was aborted, so the daemon now stays alive.
|
||||
- Improve the Telegram connector with a new `--allowed-user-id` flag to restrict which Telegram users are authorized to interact with the agent.
|
||||
|
||||
## 3.0.16
|
||||
|
||||
- Install official Cline plugins by slug off the new github.com/cline/plugins collection.
|
||||
- Uninstall plugins using `cline plugin uninstall <plugin>` or in the TUI.
|
||||
- Plugins can now bundle skills, and plugin skills are grouped together in settings.
|
||||
- Add Slack socket mode support.
|
||||
- Allow a custom base URL for Anthropic vendor-type providers.
|
||||
- Fix OAuth token migration for users signed in through the old extension.
|
||||
- Use a union schema for read-files tool input validation.
|
||||
- Add a `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env override to control the plugin import timeout.
|
||||
|
||||
## 3.0.15
|
||||
|
||||
- Add Cline Hub, a web app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub, with local, LAN, and tunnel usage gated by a room secret.
|
||||
|
||||
@@ -416,7 +416,7 @@ Then attach VS Code or Chrome DevTools to `ws://127.0.0.1:6499`.
|
||||
|
||||
## Publishing
|
||||
|
||||
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`apps/cli/.cline/skills/publish-cli/SKILL.md`).
|
||||
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`.cline/skills/publish-cli/SKILL.md` at the repo root).
|
||||
|
||||
From the `apps/cli` workspace:
|
||||
|
||||
|
||||
@@ -174,6 +174,9 @@ cline connect telegram -k 123456:ABCDEF...
|
||||
# Slack (webhook mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --signing-secret $SLACK_SIGNING_SECRET --base-url https://your-domain.com
|
||||
|
||||
# Slack (socket mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
|
||||
# Google Chat (webhook mode)
|
||||
cline connect gchat --base-url https://your-domain.com
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.15",
|
||||
"version": "3.0.21",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
@@ -17,8 +18,10 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
runPluginInstallCommand,
|
||||
runPluginUninstallCommand,
|
||||
} from "./plugin";
|
||||
|
||||
type FetchCall = (
|
||||
@@ -47,6 +50,29 @@ describe("plugin install command", () => {
|
||||
setClineDir(process.env.CLINE_DIR);
|
||||
});
|
||||
|
||||
function runGitCommand(cwd: string, args: string[]): void {
|
||||
execFileSync("git", args, { cwd, stdio: "ignore" });
|
||||
}
|
||||
|
||||
async function createOfficialPluginsRepo(
|
||||
plugins: Record<string, Record<string, string>>,
|
||||
): Promise<string> {
|
||||
const repo = mkdtempSync(join(root, "official-plugins-"));
|
||||
for (const [slug, files] of Object.entries(plugins)) {
|
||||
const pluginRoot = join(repo, "plugins", slug);
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
for (const [filename, content] of Object.entries(files)) {
|
||||
await writeFile(join(pluginRoot, filename), content, "utf8");
|
||||
}
|
||||
}
|
||||
runGitCommand(repo, ["init"]);
|
||||
runGitCommand(repo, ["config", "user.email", "test@example.com"]);
|
||||
runGitCommand(repo, ["config", "user.name", "Cline Test"]);
|
||||
runGitCommand(repo, ["add", "."]);
|
||||
runGitCommand(repo, ["commit", "-m", "seed plugins"]);
|
||||
return repo;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -85,6 +111,25 @@ describe("plugin install command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses bare official keywords as official plugin slugs", () => {
|
||||
expect(isOfficialPluginSlug("clickhouse")).toBe(true);
|
||||
expect(isOfficialPluginSlug("web-search")).toBe(true);
|
||||
expect(isOfficialPluginSlug("WebSearch")).toBe(false);
|
||||
expect(parsePluginSource("clickhouse")).toEqual({
|
||||
type: "official",
|
||||
slug: "clickhouse",
|
||||
});
|
||||
expect(parsePluginSource("web-search")).toEqual({
|
||||
type: "official",
|
||||
slug: "web-search",
|
||||
});
|
||||
expect(parsePluginSource("web-search", "npm")).toEqual({
|
||||
type: "npm",
|
||||
spec: "web-search",
|
||||
name: "web-search",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hostname-style sources without --git guidance", () => {
|
||||
expect(() => parsePluginSource("github.com/acme/plugin")).toThrow(
|
||||
/Use --git/,
|
||||
@@ -169,6 +214,133 @@ describe("plugin install command", () => {
|
||||
).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("installs an official plugin slug from the configured collection repo", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"web-search": {
|
||||
"index.ts":
|
||||
"export default { name: 'official-web-search', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
"other-plugin": {
|
||||
"index.ts":
|
||||
"export default { name: 'other-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "web-search",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
});
|
||||
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "official"),
|
||||
);
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"official-web-search",
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(existsSync(join(result.installPath, "repo"))).toBe(false);
|
||||
expect(
|
||||
existsSync(join(result.installPath, "package", "other-plugin")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
|
||||
).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("installs an official package plugin and runs package dependency install", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"package-plugin": {
|
||||
"package.json": JSON.stringify(
|
||||
{
|
||||
name: "package-plugin",
|
||||
type: "module",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
dependencies: {
|
||||
yaml: "^2.8.1",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"index.ts":
|
||||
"export default { name: 'package-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
const npmLogPath = join(root, "official-npm-install.log");
|
||||
const npmCommandPath = join(root, "official-fake-npm.sh");
|
||||
writeFileSync(
|
||||
npmCommandPath,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$PWD $*" >> "${npmLogPath}"\nexit 0\n`,
|
||||
{ encoding: "utf8", mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "package-plugin",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
|
||||
const npmLog = readFileSync(npmLogPath, "utf8");
|
||||
expect(npmLog).toContain("package install --omit=dev --omit=peer");
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"package-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a clear error when an official plugin slug is missing", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"known-plugin": {
|
||||
"index.ts":
|
||||
"export default { name: 'known-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
installPlugin({
|
||||
source: "missing-plugin",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Official Cline plugin "missing-plugin" was not found at plugins\/missing-plugin/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit relative paths as local plugin installs", async () => {
|
||||
const localPluginRoot = join(workspace, "web-search");
|
||||
await mkdir(localPluginRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(localPluginRoot, "index.ts"),
|
||||
"export default { name: 'local-web-search', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "./web-search",
|
||||
cwd: workspace,
|
||||
});
|
||||
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "local"),
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"local-web-search",
|
||||
);
|
||||
});
|
||||
|
||||
it("times out stalled remote plugin downloads", async () => {
|
||||
vi.useFakeTimers();
|
||||
const source =
|
||||
@@ -292,7 +464,8 @@ describe("plugin install command", () => {
|
||||
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
) as { name?: string; cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
expect(wrapperManifest.name).toBe("plugin-package");
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
|
||||
"package/index.ts",
|
||||
@@ -430,6 +603,54 @@ describe("plugin install command", () => {
|
||||
).toContain("installed-v1");
|
||||
});
|
||||
|
||||
it("uninstalls a package plugin by package name", async () => {
|
||||
const source = join(root, "uninstall-package");
|
||||
const npmCommandPath = join(root, "fake-npm.sh");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(
|
||||
join(source, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cli-uninstall-plugin",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'cli-uninstall-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const installed = await installPlugin({
|
||||
source,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
const output: string[] = [];
|
||||
const code = await runPluginUninstallCommand({
|
||||
name: "cli-uninstall-plugin",
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(installed.installPath)).toBe(false);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Uninstalled plugin cli-uninstall-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints JSON output for command callers", async () => {
|
||||
const source = join(root, "json.ts");
|
||||
writeFileSync(
|
||||
@@ -460,6 +681,40 @@ describe("plugin install command", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prints JSON output for official plugin installs", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"json-plugin": {
|
||||
"index.ts":
|
||||
"export default { name: 'json-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source: "json-plugin",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "official"),
|
||||
);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses shared search paths for cwd installs", async () => {
|
||||
const source = join(root, "workspace.ts");
|
||||
writeFileSync(
|
||||
|
||||
@@ -12,7 +12,16 @@ import {
|
||||
} from "node:fs";
|
||||
import { cp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
|
||||
import {
|
||||
isPluginModulePath,
|
||||
resolveClineDir,
|
||||
@@ -25,6 +34,7 @@ export interface PluginInstallOptions {
|
||||
cwd?: string;
|
||||
force?: boolean;
|
||||
npmCommand?: string;
|
||||
officialPluginsRepo?: string;
|
||||
io?: PluginInstallIo;
|
||||
}
|
||||
|
||||
@@ -60,6 +70,10 @@ type ParsedPluginSource =
|
||||
| {
|
||||
type: "local";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: "official";
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type PluginInstallSourceType = "npm" | "git" | "local" | "remote";
|
||||
@@ -77,6 +91,7 @@ interface PluginPackageManifest {
|
||||
|
||||
const INSTALLS_DIRECTORY_NAME = "_installed";
|
||||
const PACKAGE_DIRECTORY_NAME = "package";
|
||||
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
|
||||
const REMOTE_PLUGIN_FETCH_TIMEOUT_MS = 30_000;
|
||||
const REMOTE_PLUGIN_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const HOST_PROVIDED_SDK_PREFIX = "@cline/";
|
||||
@@ -121,6 +136,14 @@ function sanitizeSegment(value: string): string {
|
||||
return sanitized || "plugin";
|
||||
}
|
||||
|
||||
export function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function resolveOfficialPluginsRepo(override: string | undefined): string {
|
||||
return override?.trim() || OFFICIAL_PLUGINS_REPO;
|
||||
}
|
||||
|
||||
function parseNpmSpec(spec: string): { name: string } {
|
||||
const trimmed = spec.trim();
|
||||
const match = trimmed.match(/^(@?[^@/]+(?:\/[^@/]+)?)(?:@.+)?$/);
|
||||
@@ -371,6 +394,9 @@ export function parsePluginSource(
|
||||
if (git) {
|
||||
return git;
|
||||
}
|
||||
if (isOfficialPluginSlug(trimmed)) {
|
||||
return { type: "official", slug: trimmed };
|
||||
}
|
||||
if (looksLikeHostnamePath(trimmed)) {
|
||||
throw new Error(
|
||||
`Unrecognized plugin source "${source}". Use --git for hostname-style repositories or pass an explicit local path such as ./github.com/owner/repo.`,
|
||||
@@ -415,6 +441,14 @@ function getInstallPath(
|
||||
`${sanitizeSegment(parsed.filename)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
if (parsed.type === "official") {
|
||||
return join(
|
||||
pluginRoot,
|
||||
INSTALLS_DIRECTORY_NAME,
|
||||
"official",
|
||||
`${sanitizeSegment(parsed.slug)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
return join(
|
||||
pluginRoot,
|
||||
INSTALLS_DIRECTORY_NAME,
|
||||
@@ -423,7 +457,11 @@ function getInstallPath(
|
||||
);
|
||||
}
|
||||
|
||||
function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
|
||||
function getInstallSourceKey(
|
||||
parsed: ParsedPluginSource,
|
||||
cwd: string,
|
||||
officialPluginsRepo: string,
|
||||
): string {
|
||||
if (parsed.type === "npm") {
|
||||
return `npm:${parsed.spec}`;
|
||||
}
|
||||
@@ -433,9 +471,31 @@ function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
|
||||
if (parsed.type === "remote") {
|
||||
return `remote:${parsed.url}`;
|
||||
}
|
||||
if (parsed.type === "official") {
|
||||
return `official:${officialPluginsRepo}#plugins/${parsed.slug}`;
|
||||
}
|
||||
return `local:${resolve(cwd, resolveHomePath(parsed.path))}`;
|
||||
}
|
||||
|
||||
function getWrapperPackageName(
|
||||
parsed: ParsedPluginSource,
|
||||
cwd: string,
|
||||
): string {
|
||||
if (parsed.type === "npm") {
|
||||
return parsed.name;
|
||||
}
|
||||
if (parsed.type === "git") {
|
||||
return sanitizeSegment(basename(parsed.path));
|
||||
}
|
||||
if (parsed.type === "remote") {
|
||||
return sanitizeSegment(basename(parsed.filename, extname(parsed.filename)));
|
||||
}
|
||||
if (parsed.type === "official") {
|
||||
return parsed.slug;
|
||||
}
|
||||
return sanitizeSegment(basename(resolve(cwd, resolveHomePath(parsed.path))));
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -632,6 +692,7 @@ function toWrapperEntryPaths(
|
||||
async function writeWrapperManifest(
|
||||
wrapperRoot: string,
|
||||
packageRoot: string,
|
||||
packageName: string,
|
||||
): Promise<string[]> {
|
||||
const entryPaths = toWrapperEntryPaths(wrapperRoot, packageRoot);
|
||||
await writeFile(
|
||||
@@ -639,7 +700,7 @@ async function writeWrapperManifest(
|
||||
JSON.stringify(
|
||||
{
|
||||
...WRAPPER_PACKAGE_JSON,
|
||||
name: `cline-installed-plugin-${hashSource(wrapperRoot)}`,
|
||||
name: packageName,
|
||||
cline: {
|
||||
plugins: [{ paths: entryPaths }],
|
||||
},
|
||||
@@ -732,6 +793,43 @@ async function installGitPackage(
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
async function installOfficialPlugin(
|
||||
parsed: Extract<ParsedPluginSource, { type: "official" }>,
|
||||
stagingRoot: string,
|
||||
npmCommand: string,
|
||||
officialPluginsRepo: string,
|
||||
): Promise<string> {
|
||||
const repoRoot = join(stagingRoot, "repo");
|
||||
await runCommand("git", [
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--depth",
|
||||
"1",
|
||||
"--",
|
||||
officialPluginsRepo,
|
||||
repoRoot,
|
||||
]);
|
||||
|
||||
const sourceRoot = join(repoRoot, "plugins", parsed.slug);
|
||||
if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
||||
throw new Error(
|
||||
`Official Cline plugin "${parsed.slug}" was not found at plugins/${parsed.slug} in ${officialPluginsRepo}`,
|
||||
);
|
||||
}
|
||||
|
||||
const packageRoot = join(stagingRoot, PACKAGE_DIRECTORY_NAME);
|
||||
await cp(sourceRoot, packageRoot, {
|
||||
recursive: true,
|
||||
filter: (sourcePath) => {
|
||||
const name = basename(sourcePath);
|
||||
return name !== ".git" && name !== "node_modules";
|
||||
},
|
||||
});
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
await installPackageDependencies(packageRoot, npmCommand);
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
function remotePluginSizeLimitError(url: string): Error {
|
||||
return new Error(
|
||||
`Remote plugin file from ${url} exceeds the ${REMOTE_PLUGIN_MAX_BYTES} byte limit`,
|
||||
@@ -913,8 +1011,12 @@ export async function installPlugin(
|
||||
const explicitCwd = options.cwd?.trim();
|
||||
const cwd = explicitCwd ? resolve(explicitCwd) : process.cwd();
|
||||
const pluginRoot = getPluginRoot(explicitCwd ? cwd : undefined);
|
||||
const sourceKey = getInstallSourceKey(parsed, cwd);
|
||||
const officialPluginsRepo = resolveOfficialPluginsRepo(
|
||||
options.officialPluginsRepo,
|
||||
);
|
||||
const sourceKey = getInstallSourceKey(parsed, cwd, officialPluginsRepo);
|
||||
const installPath = getInstallPath(pluginRoot, parsed, sourceKey);
|
||||
const wrapperPackageName = getWrapperPackageName(parsed, cwd);
|
||||
const stagingParent = join(pluginRoot, INSTALLS_DIRECTORY_NAME, ".tmp");
|
||||
const stagingRoot = join(
|
||||
stagingParent,
|
||||
@@ -933,6 +1035,13 @@ export async function installPlugin(
|
||||
packageRoot = await installNpmPackage(parsed, stagingRoot, npmCommand);
|
||||
} else if (parsed.type === "git") {
|
||||
packageRoot = await installGitPackage(parsed, stagingRoot, npmCommand);
|
||||
} else if (parsed.type === "official") {
|
||||
packageRoot = await installOfficialPlugin(
|
||||
parsed,
|
||||
stagingRoot,
|
||||
npmCommand,
|
||||
officialPluginsRepo,
|
||||
);
|
||||
} else if (parsed.type === "remote") {
|
||||
packageRoot = await installRemoteFile(parsed, stagingRoot);
|
||||
} else {
|
||||
@@ -950,7 +1059,11 @@ export async function installPlugin(
|
||||
? collectPluginEntries(stagingRoot).map(
|
||||
(entry) => `./${toPosixPath(relative(stagingRoot, entry))}`,
|
||||
)
|
||||
: await writeWrapperManifest(stagingRoot, packageRoot);
|
||||
: await writeWrapperManifest(
|
||||
stagingRoot,
|
||||
packageRoot,
|
||||
wrapperPackageName,
|
||||
);
|
||||
if (entryPaths.length === 0) {
|
||||
throw new Error(`No plugin entry files found for ${source}`);
|
||||
}
|
||||
@@ -985,3 +1098,22 @@ export async function runPluginInstallCommand(
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginUninstallCommand(
|
||||
options: PluginUninstallOptions & { json?: boolean; io?: PluginInstallIo },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await uninstallPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Uninstalled plugin ${result.name}`);
|
||||
options.io?.writeln(` Removed: ${result.installPath}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
withMinimumReleaseAgeBypass,
|
||||
@@ -10,6 +12,9 @@ import {
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createFile(path: string): string {
|
||||
@@ -32,6 +37,22 @@ describe("getInstallationInfo", () => {
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -45,7 +66,7 @@ describe("getInstallationInfo", () => {
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm install -g cline@latest",
|
||||
updateCommand: "npm update -g cline --tag latest",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +78,7 @@ describe("getInstallationInfo", () => {
|
||||
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm install -g cline@nightly",
|
||||
updateCommand: "npm update -g cline --tag nightly",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,14 +93,74 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips startup auto update when disabled globally", () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.IS_DEV;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("should not fetch"));
|
||||
|
||||
autoUpdateOnStartup();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets manual update checks run when startup auto update is disabled", async () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.0" }),
|
||||
} as Response);
|
||||
|
||||
await checkForUpdates({ includeKanban: false });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"npm install -g cline@latest",
|
||||
"npm update -g cline --tag latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm install -g cline@latest --min-release-age=0");
|
||||
).toBe("npm update -g cline --tag latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveSharedHubOwnerContext,
|
||||
@@ -126,7 +127,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
return {
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
updateCommand: `npm install -g ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
updateCommand: `npm update -g ${DEFAULT_PACKAGE_NAME} --tag ${tag}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -340,19 +341,27 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
export function autoUpdateOnStartup(): void {
|
||||
if (process.env.IS_DEV === "true") return;
|
||||
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
|
||||
if (!isAutoUpdateEnabledGlobally()) return;
|
||||
|
||||
const { packageName, updateCommand } = getInstallationInfo(version);
|
||||
const { packageName, packageManager, updateCommand } =
|
||||
getInstallationInfo(version);
|
||||
if (!updateCommand) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const latest = await getLatestVersion(packageName, version);
|
||||
if (!latest || compareVersions(version, latest) >= 0) return;
|
||||
const child = spawn(updateCommand, {
|
||||
const autoUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
const child = spawn(autoUpdateCommand.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("discordConnector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
|
||||
it("updates Discord participant metadata without changing the thread session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const thread = createThread({
|
||||
@@ -278,11 +278,13 @@ describe("discordConnector", () => {
|
||||
errorLabel: "Discord",
|
||||
});
|
||||
|
||||
const bob =
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
|
||||
expect(bob?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(bob?.state?.participantLabel).toBe("Bob");
|
||||
expect(bob?.state?.sessionId).toBeUndefined();
|
||||
const binding =
|
||||
readBindings<TestDiscordState>(bindingsPath)[
|
||||
"discord:guild:channel:thread"
|
||||
];
|
||||
expect(binding?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(binding?.state?.participantLabel).toBe("Bob");
|
||||
expect(binding?.state?.sessionId).toBe("session-alice");
|
||||
expect(
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
|
||||
?.sessionId,
|
||||
|
||||
@@ -50,10 +50,9 @@ import {
|
||||
type ConnectorMuteTarget,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
mergeThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
} from "../thread-bindings";
|
||||
@@ -564,45 +563,17 @@ async function postDiscordResolvedText(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveParticipantState(input: {
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
function resolveCurrentStateWithParticipant(input: {
|
||||
currentState: DiscordThreadState;
|
||||
participant: DiscordParticipant;
|
||||
}): DiscordThreadState {
|
||||
const existing = findBindingForParticipantKey(
|
||||
readBindings<DiscordThreadState>(input.bindingsPath),
|
||||
input.participant.key,
|
||||
)?.binding.state;
|
||||
return {
|
||||
...mergeThreadState<DiscordThreadState>(
|
||||
undefined,
|
||||
existing,
|
||||
input.baseStartRequest,
|
||||
),
|
||||
...input.currentState,
|
||||
participantKey: input.participant.key,
|
||||
participantLabel: input.participant.label,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCurrentStateWithParticipant(input: {
|
||||
currentState: DiscordThreadState;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
participant: DiscordParticipant;
|
||||
}): DiscordThreadState {
|
||||
if (input.currentState.participantKey === input.participant.key) {
|
||||
return {
|
||||
...input.currentState,
|
||||
participantLabel: input.participant.label,
|
||||
};
|
||||
}
|
||||
return resolveParticipantState({
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
participant: input.participant,
|
||||
});
|
||||
}
|
||||
|
||||
async function persistDiscordThreadContext(input: {
|
||||
thread: Thread<DiscordThreadState>;
|
||||
bindingsPath: string;
|
||||
@@ -624,8 +595,6 @@ async function persistDiscordThreadContext(input: {
|
||||
);
|
||||
const nextState = resolveCurrentStateWithParticipant({
|
||||
currentState,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
participant,
|
||||
});
|
||||
if (
|
||||
@@ -669,20 +638,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<DiscordThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -1133,9 +1102,7 @@ class DiscordConnector extends ConnectorBase<
|
||||
isSubscribedThreadMessage?: boolean;
|
||||
},
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./gchat";
|
||||
|
||||
describe("gchat binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("does not fall back to channel identity for a different space thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
@@ -21,17 +21,7 @@ describe("gchat binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "space-123",
|
||||
isDM: false,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers an exact thread id match over a channel fallback", () => {
|
||||
@@ -65,7 +55,7 @@ describe("gchat binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different spaces", () => {
|
||||
it("does not reuse a binding by participant key across different spaces", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"gchat:email:alice@example.com": {
|
||||
@@ -91,7 +81,6 @@ describe("gchat binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("gchat:email:alice@example.com");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -191,20 +191,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<GoogleChatThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -590,9 +590,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
thread: Thread<GoogleChatThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./linear";
|
||||
|
||||
describe("linear binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("does not fall back to channel identity for a different issue thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
@@ -21,17 +21,7 @@ describe("linear binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "linear:issue:ISS-123",
|
||||
isDM: false,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers an exact thread id match over a channel fallback", () => {
|
||||
@@ -65,7 +55,7 @@ describe("linear binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different issue threads", () => {
|
||||
it("does not reuse a binding by participant key across different issue threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"linear:user:user_123": {
|
||||
@@ -91,7 +81,6 @@ describe("linear binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("linear:user:user_123");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -229,20 +229,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<LinearThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -625,9 +625,7 @@ class LinearConnector extends ConnectorBase<
|
||||
thread: Thread<LinearThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getConnectorSystemRules(
|
||||
}
|
||||
|
||||
const CONNECTOR_FIRST_CONTACT_MESSAGE = [
|
||||
"Connected.",
|
||||
"Connected to Cline.",
|
||||
"Your chat history is kept separately for your account.",
|
||||
"Send /new to start a fresh session or /whereami for thread details.",
|
||||
].join("\n");
|
||||
|
||||
@@ -1,15 +1,74 @@
|
||||
import type { ConnectSlackOptions } from "@cline/shared";
|
||||
import { type Message, ThreadImpl } from "chat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./slack";
|
||||
import { __test__, slackConnector } from "./slack";
|
||||
|
||||
const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
|
||||
(
|
||||
slackConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectSlackOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
describe("slack binding lookup", () => {
|
||||
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
|
||||
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("infers Slack webhook mode from a base URL", () => {
|
||||
expect(__test__.inferSlackConnectionMode("https://example.test")).toBe(
|
||||
"webhook",
|
||||
);
|
||||
expect(__test__.inferSlackConnectionMode(" ")).toBe("socket");
|
||||
expect(__test__.inferSlackConnectionMode(undefined)).toBe("socket");
|
||||
});
|
||||
|
||||
it("uses webhook mode when Slack args include a base URL", () => {
|
||||
const options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--signing-secret",
|
||||
"secret",
|
||||
"--app-token",
|
||||
"xapp-ignored",
|
||||
"--base-url",
|
||||
"https://example.test",
|
||||
]);
|
||||
|
||||
expect(options.connectionMode).toBe("webhook");
|
||||
expect(options.baseUrl).toBe("https://example.test");
|
||||
expect(options.signingSecret).toBe("secret");
|
||||
expect(options.appToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses socket mode when Slack args omit a base URL", () => {
|
||||
const previousBaseUrl = process.env.BASE_URL;
|
||||
delete process.env.BASE_URL;
|
||||
let options: ConnectSlackOptions;
|
||||
try {
|
||||
options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--app-token",
|
||||
"xapp-token",
|
||||
]);
|
||||
} finally {
|
||||
if (previousBaseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = previousBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
expect(options.connectionMode).toBe("socket");
|
||||
expect(options.baseUrl).toBeUndefined();
|
||||
expect(options.appToken).toBe("xapp-token");
|
||||
});
|
||||
|
||||
it("falls back to DM channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
@@ -19,7 +78,7 @@ describe("slack binding lookup", () => {
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,7 +86,7 @@ describe("slack binding lookup", () => {
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
@@ -67,7 +126,7 @@ describe("slack binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different threads", () => {
|
||||
it("does not reuse a binding by participant key across different threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
[participantKey]: {
|
||||
@@ -94,8 +153,7 @@ describe("slack binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe(participantKey);
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds Slack participant keys with a team scope", () => {
|
||||
@@ -157,6 +215,105 @@ describe("slack binding lookup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes top-level channel mentions to the original Slack post thread", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> help",
|
||||
ts: "1710000000.123456",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const normalized = __test__.resolveSlackChannelMentionThread(
|
||||
original,
|
||||
message,
|
||||
);
|
||||
|
||||
expect(normalized.id).toBe("slack:C123:1710000000.123456");
|
||||
expect(normalized.channelId).toBe("slack:C123");
|
||||
expect(normalized.isDM).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Slack thread_ts instead of reply ts for in-thread mentions", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:1710000001.654321",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> follow up",
|
||||
thread_ts: "1710000000.123456",
|
||||
ts: "1710000001.654321",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const normalized = __test__.resolveSlackChannelMentionThread(
|
||||
original,
|
||||
message,
|
||||
);
|
||||
|
||||
expect(normalized.id).toBe("slack:C123:1710000000.123456");
|
||||
expect(normalized.channelId).toBe("slack:C123");
|
||||
expect(normalized.isDM).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Slack mention threads that already target the original post", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:1710000000.123456",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> help",
|
||||
ts: "1710000000.123456",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite Slack DM mention threads", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:D123",
|
||||
id: "slack:D123:",
|
||||
isDM: true,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "D123",
|
||||
text: "help",
|
||||
ts: "1710000000.123456",
|
||||
type: "message",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Slack posts through the installation bot token for a team", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await __test__.withSlackTeamBotToken({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type Adapter,
|
||||
Chat,
|
||||
ConsoleLogger,
|
||||
type Message,
|
||||
type Thread,
|
||||
ThreadImpl,
|
||||
} from "chat";
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
type ConnectorThreadBinding,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -79,6 +80,14 @@ type SlackThreadState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
type SlackConnectionMode = ConnectSlackOptions["connectionMode"];
|
||||
|
||||
function inferSlackConnectionMode(
|
||||
baseUrl: string | undefined,
|
||||
): SlackConnectionMode {
|
||||
return baseUrl?.trim() ? "webhook" : "socket";
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength = 160): string {
|
||||
return truncateConnectorText(value, maxLength);
|
||||
}
|
||||
@@ -184,6 +193,56 @@ function extractSlackTeamId(raw: unknown): string | undefined {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
function extractSlackMessageRecord(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(raw);
|
||||
return asRecord(record?.event) ?? asRecord(record?.message) ?? record;
|
||||
}
|
||||
|
||||
function extractSlackChannelFromId(id: string): string | undefined {
|
||||
const parts = id.split(":");
|
||||
return parts[0] === "slack" ? readString(parts[1]) : undefined;
|
||||
}
|
||||
|
||||
function resolveSlackChannelMentionThread(
|
||||
thread: Thread<SlackThreadState>,
|
||||
message: Message,
|
||||
): Thread<SlackThreadState> {
|
||||
if (thread.isDM) {
|
||||
return thread;
|
||||
}
|
||||
const event = extractSlackMessageRecord(message.raw);
|
||||
const threadTs = readString(event?.thread_ts) ?? readString(event?.ts);
|
||||
if (!threadTs) {
|
||||
return thread;
|
||||
}
|
||||
const channel =
|
||||
readString(event?.channel) ??
|
||||
extractSlackChannelFromId(thread.id) ??
|
||||
extractSlackChannelFromId(thread.channelId);
|
||||
if (!channel) {
|
||||
return thread;
|
||||
}
|
||||
const threadId = `slack:${channel}:${threadTs}`;
|
||||
const channelId = `slack:${channel}`;
|
||||
if (thread.id === threadId && thread.channelId === channelId) {
|
||||
return thread;
|
||||
}
|
||||
return new ThreadImpl<SlackThreadState>({
|
||||
adapterName: "slack",
|
||||
channelId,
|
||||
channelVisibility: thread.channelVisibility,
|
||||
currentMessage: message,
|
||||
fallbackStreamingPlaceholderText: null,
|
||||
id: threadId,
|
||||
initialMessage: message,
|
||||
isDM: false,
|
||||
isSubscribedContext: false,
|
||||
streamingUpdateIntervalMs: 500,
|
||||
});
|
||||
}
|
||||
|
||||
async function withSlackBindingBotToken<T>(input: {
|
||||
slack: Pick<SlackAdapter, "getInstallation" | "withBotToken">;
|
||||
binding: ConnectorThreadBinding<SlackThreadState>;
|
||||
@@ -317,20 +376,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<SlackThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
const deliveryThreadId = match?.key || threadId || bindingKey;
|
||||
if (!binding?.serializedThread) {
|
||||
@@ -380,7 +439,10 @@ class SlackConnector extends ConnectorBase<
|
||||
SlackConnectorState
|
||||
> {
|
||||
constructor() {
|
||||
super("slack", "Slack webhook bridge backed by RPC runtime sessions");
|
||||
super(
|
||||
"slack",
|
||||
"Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
);
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
@@ -393,6 +455,7 @@ class SlackConnector extends ConnectorBase<
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
@@ -433,6 +496,7 @@ class SlackConnector extends ConnectorBase<
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
@@ -445,6 +509,7 @@ class SlackConnector extends ConnectorBase<
|
||||
userName?: string;
|
||||
botToken?: string;
|
||||
signingSecret?: string;
|
||||
appToken?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
encryptionKey?: string;
|
||||
@@ -467,17 +532,50 @@ class SlackConnector extends ConnectorBase<
|
||||
this.parseOptionalInteger(opts.port, "port") ??
|
||||
Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||
const port = Number.isFinite(parsedPort) ? parsedPort : 8787;
|
||||
const baseUrl = opts.baseUrl?.trim() || process.env.BASE_URL?.trim();
|
||||
const connectionMode = inferSlackConnectionMode(baseUrl);
|
||||
const isSocketMode = connectionMode === "socket";
|
||||
if (isSocketMode && (opts.clientId?.trim() || opts.clientSecret?.trim())) {
|
||||
throw new Error(
|
||||
"Slack socket mode does not support --client-id or --client-secret",
|
||||
);
|
||||
}
|
||||
const botToken =
|
||||
opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim();
|
||||
const appToken = isSocketMode
|
||||
? opts.appToken?.trim() || process.env.SLACK_APP_TOKEN?.trim()
|
||||
: undefined;
|
||||
if (isSocketMode && !appToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --app-token or SLACK_APP_TOKEN",
|
||||
);
|
||||
}
|
||||
if (isSocketMode && !botToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --bot-token or SLACK_BOT_TOKEN",
|
||||
);
|
||||
}
|
||||
return {
|
||||
userName:
|
||||
opts.userName?.trim() ||
|
||||
process.env.SLACK_BOT_USERNAME?.trim() ||
|
||||
"cline-slack",
|
||||
botToken: opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim(),
|
||||
connectionMode,
|
||||
botToken,
|
||||
signingSecret:
|
||||
opts.signingSecret?.trim() || process.env.SLACK_SIGNING_SECRET?.trim(),
|
||||
clientId: opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim(),
|
||||
connectionMode === "webhook"
|
||||
? opts.signingSecret?.trim() ||
|
||||
process.env.SLACK_SIGNING_SECRET?.trim()
|
||||
: opts.signingSecret?.trim(),
|
||||
appToken,
|
||||
clientId:
|
||||
connectionMode === "webhook"
|
||||
? opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim()
|
||||
: undefined,
|
||||
clientSecret:
|
||||
opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim(),
|
||||
connectionMode === "webhook"
|
||||
? opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim()
|
||||
: undefined,
|
||||
encryptionKey:
|
||||
opts.encryptionKey?.trim() || process.env.SLACK_ENCRYPTION_KEY?.trim(),
|
||||
installationKeyPrefix:
|
||||
@@ -500,10 +598,7 @@ class SlackConnector extends ConnectorBase<
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
port,
|
||||
host: opts.host?.trim() || process.env.HOST?.trim() || "0.0.0.0",
|
||||
baseUrl:
|
||||
opts.baseUrl?.trim() ||
|
||||
process.env.BASE_URL?.trim() ||
|
||||
`http://127.0.0.1:${port}`,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -599,9 +694,11 @@ class SlackConnector extends ConnectorBase<
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName}`,
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
@@ -618,6 +715,7 @@ class SlackConnector extends ConnectorBase<
|
||||
const consoleLogger = new ConsoleLogger("info", "slack-connect");
|
||||
const slackConfig: Record<string, unknown> = {
|
||||
logger: consoleLogger,
|
||||
mode: options.connectionMode,
|
||||
userName: options.userName,
|
||||
};
|
||||
if (options.botToken?.trim()) {
|
||||
@@ -626,6 +724,9 @@ class SlackConnector extends ConnectorBase<
|
||||
if (options.signingSecret?.trim()) {
|
||||
slackConfig.signingSecret = options.signingSecret.trim();
|
||||
}
|
||||
if (options.appToken?.trim()) {
|
||||
slackConfig.appToken = options.appToken.trim();
|
||||
}
|
||||
if (options.clientId?.trim()) {
|
||||
slackConfig.clientId = options.clientId.trim();
|
||||
}
|
||||
@@ -694,10 +795,12 @@ class SlackConnector extends ConnectorBase<
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
rpcAddress,
|
||||
port: options.port,
|
||||
baseUrl: options.baseUrl,
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -723,7 +826,7 @@ class SlackConnector extends ConnectorBase<
|
||||
bindingsPath,
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = currentState.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await withSlackTeamBotToken({
|
||||
@@ -842,9 +945,10 @@ class SlackConnector extends ConnectorBase<
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
await thread.subscribe();
|
||||
const mentionThread = resolveSlackChannelMentionThread(thread, message);
|
||||
await mentionThread.subscribe();
|
||||
await persistSlackThreadContext({
|
||||
thread,
|
||||
thread: mentionThread,
|
||||
bindingsPath,
|
||||
baseStartRequest: startRequest,
|
||||
rawMessage: message.raw,
|
||||
@@ -852,7 +956,7 @@ class SlackConnector extends ConnectorBase<
|
||||
});
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread,
|
||||
thread: mentionThread,
|
||||
text: message.text,
|
||||
client,
|
||||
clientId,
|
||||
@@ -862,7 +966,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(thread, message.text);
|
||||
await handleTurn(mentionThread, message.text);
|
||||
});
|
||||
|
||||
bot.onSubscribedMessage(async (thread, message) => {
|
||||
@@ -948,48 +1052,64 @@ class SlackConnector extends ConnectorBase<
|
||||
},
|
||||
});
|
||||
|
||||
const webhookUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
const oauthCallbackUrl = `${options.baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
const server = await startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) => bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
let webhookUrl: string | undefined;
|
||||
let oauthCallbackUrl: string | undefined;
|
||||
const server =
|
||||
options.connectionMode === "webhook"
|
||||
? await (async () => {
|
||||
const baseUrl = options.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
"Slack webhook mode requires --base-url or BASE_URL",
|
||||
);
|
||||
}
|
||||
webhookUrl = `${baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
oauthCallbackUrl = `${baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
return startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) =>
|
||||
bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
});
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
"Connection mode: webhook",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() &&
|
||||
options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() && options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const stopEventStream = client.streamEvents(
|
||||
{ clientId: `${clientId}-server-events` },
|
||||
@@ -1052,17 +1172,22 @@ class SlackConnector extends ConnectorBase<
|
||||
process.once("SIGINT", () => requestStop("sigint"));
|
||||
process.once("SIGTERM", () => requestStop("sigterm"));
|
||||
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
if (options.connectionMode === "webhook") {
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
} else {
|
||||
io.writeln("[slack] socket mode connected");
|
||||
}
|
||||
|
||||
await stopPromise;
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
stopTaskUpdateStream();
|
||||
stopEventStream();
|
||||
await server.close();
|
||||
await server?.close();
|
||||
await bot.shutdown();
|
||||
userInstructionService.stop();
|
||||
client.close();
|
||||
this.removeStateFile(statePath);
|
||||
@@ -1073,9 +1198,11 @@ class SlackConnector extends ConnectorBase<
|
||||
export const slackConnector: ConnectCommandDefinition = new SlackConnector();
|
||||
|
||||
export const __test__ = {
|
||||
inferSlackConnectionMode,
|
||||
buildSlackParticipantKey,
|
||||
resolveSlackParticipant,
|
||||
normalizeSlackMessageEventChannelType,
|
||||
resolveSlackChannelMentionThread,
|
||||
withSlackTeamBotToken,
|
||||
isSlackInvalidThreadTsError,
|
||||
findBindingForThread: (
|
||||
|
||||
@@ -76,7 +76,15 @@ cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
|
||||
When the connector starts with `--no-tools`, chat commands such as `/tools on` and `/yolo on` cannot re-enable tools for that connector run.
|
||||
|
||||
For participant restrictions, run the interactive connector wizard with `cline connect` or pass a `--hook-command` that returns `{"action":"deny"}` for unauthorized `session.authorize` events. If no hook is configured, messages are allowed.
|
||||
For participant restrictions, run the interactive connector wizard with `cline connect`. The Telegram wizard asks whether to restrict access, points you to `@userinfobot`, and configures your numeric Telegram user ID.
|
||||
|
||||
You can also pass the user ID directly:
|
||||
|
||||
```bash
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --allowed-user-id 12345
|
||||
```
|
||||
|
||||
You can also pass a manual `--hook-command` that returns `{"action":"deny"}` for unauthorized `session.authorize` events. If neither access option is configured, messages are allowed.
|
||||
|
||||
## Message Delivery
|
||||
|
||||
|
||||
@@ -62,6 +62,72 @@ describe("telegramConnector", () => {
|
||||
expect(options.enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an authorization hook from --allowed-user-id", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]);
|
||||
|
||||
expect(options.hookCommand).toBe(
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:1201547643" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe --allowed-user-id values", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"123; rm -rf /",
|
||||
]),
|
||||
).toThrow("digits only");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with --hook-command", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
"--hook-command",
|
||||
"echo noop",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with the hook command env var", () => {
|
||||
const originalHookCommand = process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = "echo noop";
|
||||
try {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
} finally {
|
||||
if (originalHookCommand === undefined) {
|
||||
delete process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
} else {
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = originalHookCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require the bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
@@ -297,7 +363,7 @@ describe("telegram binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different chats", () => {
|
||||
it("does not reuse a binding by participant key across different chats", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"telegram:user:alice": {
|
||||
@@ -323,7 +389,6 @@ describe("telegram binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("telegram:user:alice");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -89,6 +89,20 @@ function readTelegramBotId(botToken: string): string | undefined {
|
||||
return /^\d+$/.test(botId) ? botId : undefined;
|
||||
}
|
||||
|
||||
function normalizeAllowedTelegramUserId(value: string): string {
|
||||
const userId = value.trim();
|
||||
if (!/^\d+$/.test(userId)) {
|
||||
throw new Error(
|
||||
"connect telegram --allowed-user-id must contain digits only",
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function buildTelegramAllowedUserHookCommand(userId: string): string {
|
||||
return `jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`;
|
||||
}
|
||||
|
||||
function describeTelegramGetMeFailure(
|
||||
response: Response,
|
||||
body: string,
|
||||
@@ -279,20 +293,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<TelegramThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
const deliveryThreadId = match?.key || threadId;
|
||||
if (!binding?.serializedThread) {
|
||||
@@ -418,6 +432,10 @@ class TelegramConnector extends ConnectorBase<
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
@@ -434,6 +452,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
@@ -454,6 +473,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
allowedUserId?: string;
|
||||
}>();
|
||||
const botUsername =
|
||||
normalizeTelegramBotUsername(opts.botUsername ?? "") ||
|
||||
@@ -465,6 +485,15 @@ class TelegramConnector extends ConnectorBase<
|
||||
if (!botToken) {
|
||||
throw new Error("connect telegram requires -k/--bot-token <token>");
|
||||
}
|
||||
const hookCommand =
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim();
|
||||
const allowedUserId = opts.allowedUserId?.trim();
|
||||
if (hookCommand && allowedUserId) {
|
||||
throw new Error(
|
||||
"connect telegram accepts either --allowed-user-id or --hook-command, not both",
|
||||
);
|
||||
}
|
||||
return {
|
||||
botToken,
|
||||
...(botUsername ? { botUsername } : {}),
|
||||
@@ -480,9 +509,11 @@ class TelegramConnector extends ConnectorBase<
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
hookCommand: allowedUserId
|
||||
? buildTelegramAllowedUserHookCommand(
|
||||
normalizeAllowedTelegramUserId(allowedUserId),
|
||||
)
|
||||
: hookCommand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -757,9 +788,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("whatsapp binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different threads", () => {
|
||||
it("does not reuse a binding by participant key across different threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"whatsapp:user:15551234567": {
|
||||
@@ -91,7 +91,6 @@ describe("whatsapp binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("whatsapp:user:15551234567");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -226,20 +226,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<WhatsAppThreadState>(input.bindingsPath);
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -597,9 +597,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
thread: Thread<WhatsAppThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const queueKey = thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook bridge backed by RPC runtime sessions",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
|
||||
@@ -92,6 +92,11 @@ function createRuntimeClient(
|
||||
) {
|
||||
const startRuntimeSession = vi.fn(async () => ({ sessionId: "session-1" }));
|
||||
const updateSession = vi.fn(async () => undefined);
|
||||
const getSession = vi.fn(
|
||||
async (sessionId: string): Promise<{ sessionId: string } | undefined> => ({
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
const abortRuntimeSession = vi.fn(async () => undefined);
|
||||
const deleteSession = vi.fn(async () => undefined);
|
||||
const sendRuntimeSession = vi.fn(async () => ({
|
||||
@@ -106,6 +111,7 @@ function createRuntimeClient(
|
||||
client: {
|
||||
startRuntimeSession,
|
||||
updateSession,
|
||||
getSession,
|
||||
abortRuntimeSession,
|
||||
stopRuntimeSession: abortRuntimeSession,
|
||||
deleteSession,
|
||||
@@ -115,6 +121,7 @@ function createRuntimeClient(
|
||||
},
|
||||
startRuntimeSession,
|
||||
updateSession,
|
||||
getSession,
|
||||
sendRuntimeSession,
|
||||
readMessages,
|
||||
};
|
||||
@@ -593,7 +600,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
metadata: expect.objectContaining({
|
||||
delivery: expect.objectContaining({
|
||||
adapter: "telegram",
|
||||
bindingKey: "telegram:user:alice",
|
||||
bindingKey: "thread-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -627,7 +635,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
bindingKey: "telegram:user:alice",
|
||||
bindingKey: "thread-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
threadId: "thread-1",
|
||||
},
|
||||
},
|
||||
@@ -640,7 +649,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
bindingKey: "telegram:user:bob",
|
||||
bindingKey: "thread-2",
|
||||
participantKey: "telegram:user:bob",
|
||||
threadId: "thread-2",
|
||||
},
|
||||
},
|
||||
@@ -699,7 +709,8 @@ describe("handleConnectorUserTurn", () => {
|
||||
delivery: expect.objectContaining({
|
||||
adapter: "telegram",
|
||||
threadId: "thread-1",
|
||||
bindingKey: "telegram:user:alice",
|
||||
bindingKey: "thread-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
userName: "ClineAdapterBot",
|
||||
}),
|
||||
}),
|
||||
@@ -1442,7 +1453,7 @@ describe("handleConnectorUserTurn", () => {
|
||||
});
|
||||
const runtime = createRuntimeClient("unused");
|
||||
const activeTurns = new Map([
|
||||
["other-turn-key", { sessionId: "session-1" }],
|
||||
["other-turn-key", { sessionId: "session-1", threadId: "thread-1" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
@@ -1478,4 +1489,105 @@ describe("handleConnectorUserTurn", () => {
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
|
||||
});
|
||||
|
||||
it("starts a normal turn when the active session is in a different thread", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts } = createThread({
|
||||
enableTools: true,
|
||||
autoApproveTools: true,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
const runtime = createRuntimeClient("normal reply");
|
||||
const activeTurns = new Map([
|
||||
["other-thread", { sessionId: "session-1", threadId: "other-thread" }],
|
||||
]);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "start work in this thread",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "telegram",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Telegram",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
activeTurns,
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalled();
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
expect.not.objectContaining({
|
||||
delivery: "steer",
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(posts.at(-1)).toEqual({ raw: "normal reply" });
|
||||
});
|
||||
|
||||
it("starts a fresh session when persisted thread session is missing from the hub", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
|
||||
tempDirs.push(dir);
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const { thread, posts, getState } = createThread({
|
||||
sessionId: "stale-session",
|
||||
enableTools: true,
|
||||
autoApproveTools: true,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
welcomeSentAt: new Date().toISOString(),
|
||||
});
|
||||
const runtime = createRuntimeClient("fresh reply");
|
||||
runtime.getSession.mockResolvedValueOnce(undefined);
|
||||
|
||||
await handleConnectorUserTurn({
|
||||
thread: thread as never,
|
||||
text: "continue after hub restart",
|
||||
client: runtime.client as never,
|
||||
pendingApprovals: new Map(),
|
||||
baseStartRequest: baseStartRequest() as never,
|
||||
explicitSystemPrompt: undefined,
|
||||
clientId: "client-1",
|
||||
logger: {
|
||||
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as never,
|
||||
transport: "telegram",
|
||||
botUserName: "ClineAdapterBot",
|
||||
requestStop: vi.fn(),
|
||||
bindingsPath,
|
||||
systemRules: "rules",
|
||||
errorLabel: "Telegram",
|
||||
getSessionMetadata: () => ({}),
|
||||
reusedLogMessage: "reused",
|
||||
startedLogMessage: "started",
|
||||
turnKey: "thread-1",
|
||||
});
|
||||
|
||||
expect(runtime.getSession).toHaveBeenCalledWith("stale-session");
|
||||
expect(runtime.startRuntimeSession).toHaveBeenCalled();
|
||||
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
expect.not.objectContaining({
|
||||
delivery: "steer",
|
||||
}),
|
||||
{ timeoutMs: null },
|
||||
);
|
||||
expect(getState().sessionId).toBe("session-1");
|
||||
expect(posts.at(-1)).toEqual({ raw: "fresh reply" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -749,9 +749,7 @@ export async function handleConnectorUserTurn<
|
||||
`channelId=${input.thread.channelId}`,
|
||||
`deliveryAdapter=${input.transport}`,
|
||||
`deliveryThread=${input.thread.id}`,
|
||||
...(effectiveCurrent.participantKey
|
||||
? [`deliveryBindingKey=${effectiveCurrent.participantKey}`]
|
||||
: []),
|
||||
`deliveryBindingKey=${input.thread.id}`,
|
||||
`deliveryChannel=${input.thread.channelId}`,
|
||||
...(input.botUserName
|
||||
? [`deliveryUserName=${input.botUserName}`]
|
||||
@@ -789,11 +787,9 @@ export async function handleConnectorUserTurn<
|
||||
delivery: {
|
||||
adapter: input.transport,
|
||||
threadId: input.thread.id,
|
||||
bindingKey: input.thread.id,
|
||||
...(current.participantKey
|
||||
? {
|
||||
bindingKey: current.participantKey,
|
||||
participantKey: current.participantKey,
|
||||
}
|
||||
? { participantKey: current.participantKey }
|
||||
: {}),
|
||||
...(current.participantLabel
|
||||
? { participantLabel: current.participantLabel }
|
||||
@@ -832,11 +828,6 @@ export async function handleConnectorUserTurn<
|
||||
].join("\n");
|
||||
},
|
||||
list: async () => {
|
||||
const current = await loadThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
input.baseStartRequest,
|
||||
);
|
||||
const schedules = await input.client.listSchedules({ limit: 200 });
|
||||
const matching = schedules.filter((schedule) => {
|
||||
const delivery = schedule.metadata?.delivery;
|
||||
@@ -846,17 +837,9 @@ export async function handleConnectorUserTurn<
|
||||
!Array.isArray(delivery)
|
||||
? (delivery as Record<string, unknown>)
|
||||
: undefined;
|
||||
const deliveryBindingKey =
|
||||
typeof deliveryRecord?.bindingKey === "string"
|
||||
? deliveryRecord.bindingKey
|
||||
: typeof deliveryRecord?.participantKey === "string"
|
||||
? deliveryRecord.participantKey
|
||||
: undefined;
|
||||
return (
|
||||
deliveryRecord?.adapter === input.transport &&
|
||||
(current.participantKey
|
||||
? deliveryBindingKey === current.participantKey
|
||||
: deliveryRecord.threadId === input.thread.id)
|
||||
deliveryRecord.threadId === input.thread.id
|
||||
);
|
||||
});
|
||||
if (matching.length === 0) {
|
||||
@@ -913,7 +896,9 @@ export async function handleConnectorUserTurn<
|
||||
input.activeTurns?.get(turnKey) ??
|
||||
(input.activeTurns && currentState.sessionId?.trim()
|
||||
? Array.from(input.activeTurns.values()).find(
|
||||
(turn) => turn.sessionId === currentState.sessionId?.trim(),
|
||||
(turn) =>
|
||||
turn.sessionId === currentState.sessionId?.trim() &&
|
||||
turn.threadId === input.thread.id,
|
||||
)
|
||||
: undefined);
|
||||
if (activeTurn?.sessionId?.trim()) {
|
||||
|
||||
@@ -159,36 +159,57 @@ export async function getOrCreateSessionId<
|
||||
);
|
||||
const existing = threadState.sessionId?.trim();
|
||||
if (existing) {
|
||||
const existingSession = await input.client.getSession(existing);
|
||||
if (existingSession) {
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: existing,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
input.logger.core.log(input.reusedLogMessage, {
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
});
|
||||
await dispatchConnectorHook(
|
||||
input.hookCommand,
|
||||
{
|
||||
adapter: input.transport,
|
||||
botUserName: input.hookBotUserName,
|
||||
event: "session.reused",
|
||||
payload: {
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: existing,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
input.logger,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
await persistMergedThreadState(
|
||||
input.thread,
|
||||
input.bindingsPath,
|
||||
{
|
||||
...threadState,
|
||||
sessionId: existing,
|
||||
sessionId: undefined,
|
||||
},
|
||||
input.errorLabel,
|
||||
);
|
||||
input.logger.core.log(input.reusedLogMessage, {
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
});
|
||||
await dispatchConnectorHook(
|
||||
input.hookCommand,
|
||||
input.logger.core.log(
|
||||
"Connector thread session missing; starting a new session",
|
||||
{
|
||||
adapter: input.transport,
|
||||
botUserName: input.hookBotUserName,
|
||||
event: "session.reused",
|
||||
payload: {
|
||||
threadId: input.thread.id,
|
||||
channelId: input.thread.channelId,
|
||||
sessionId: existing,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
severity: "warn",
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
},
|
||||
input.logger,
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const started = await input.client.startRuntimeSession(input.startRequest);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ActiveConnectorRecord = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
function listConnectorStatePaths(
|
||||
@@ -68,6 +69,8 @@ const connectorFieldExtractors: Record<
|
||||
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
|
||||
port: (p) => (typeof p.port === "number" ? p.port : undefined),
|
||||
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
|
||||
connectionMode: (p) =>
|
||||
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
|
||||
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
|
||||
botUsername: (p) =>
|
||||
typeof p.botUsername === "string" ? p.botUsername : undefined,
|
||||
@@ -91,7 +94,10 @@ const connectorConfigs: Record<
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
slack: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
|
||||
slack: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "connectionMode", "port", "baseUrl"],
|
||||
},
|
||||
whatsapp: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
isParticipantMuted,
|
||||
isThreadMuted,
|
||||
readBindingForThread,
|
||||
@@ -52,16 +53,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("thread binding refresh", () => {
|
||||
it("refreshes the serialized thread immediately when channel fallback rebinds a thread id", () => {
|
||||
it("refreshes the serialized thread immediately when DM channel fallback rebinds a thread id", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", teamId: "T123" },
|
||||
@@ -74,7 +75,7 @@ describe("thread binding refresh", () => {
|
||||
createThread({
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
isDM: true,
|
||||
}),
|
||||
"Slack",
|
||||
);
|
||||
@@ -85,7 +86,7 @@ describe("thread binding refresh", () => {
|
||||
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
|
||||
});
|
||||
|
||||
it("refreshes the serialized thread when a participant-key binding matches a new thread id", () => {
|
||||
it("does not rebind a different thread by participant key", () => {
|
||||
const path = createBindingsPath();
|
||||
const participantKey = "slack:team:T123:user:U123";
|
||||
writeBindings<TestState>(path, {
|
||||
@@ -119,10 +120,69 @@ describe("thread binding refresh", () => {
|
||||
participantKey,
|
||||
);
|
||||
|
||||
expect(binding?.serializedThread).toContain("new_thread_id");
|
||||
expect(binding).toBeUndefined();
|
||||
expect(
|
||||
readBindings<TestState>(path)[participantKey]?.serializedThread,
|
||||
).toContain("new_thread_id");
|
||||
).toContain("legacy_thread_id");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by exact binding key before participant metadata", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:C123:111.222": {
|
||||
kind: "conversation",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-thread",
|
||||
state: {
|
||||
sessionId: "sess-thread",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
bindingKey: "slack:C123:111.222",
|
||||
threadId: "slack:C123:111.222",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:C123:111.222");
|
||||
expect(match?.binding.sessionId).toBe("sess-thread");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by participant key when no exact thread binding exists", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:team:T123:user:U123": {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-participant",
|
||||
state: {
|
||||
sessionId: "sess-participant",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:team:T123:user:U123");
|
||||
expect(match?.binding.sessionId).toBe("sess-participant");
|
||||
});
|
||||
|
||||
it("stores mute state at thread scope instead of participant scope", () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ConnectorThreadState = {
|
||||
};
|
||||
|
||||
export type ConnectorThreadBinding<TState extends ConnectorThreadState> = {
|
||||
kind?: "participant" | "thread" | "thread-participant-mute";
|
||||
kind?: "conversation" | "participant" | "thread" | "thread-participant-mute";
|
||||
channelId: string;
|
||||
isDM: boolean;
|
||||
participantKey?: string;
|
||||
@@ -134,12 +134,9 @@ function clearSerializedThreadSessionId(serializedThread: string | undefined): {
|
||||
|
||||
export function resolveThreadBindingKey(
|
||||
thread: ConnectorBindingThreadIdentity,
|
||||
state?: ConnectorThreadState | null,
|
||||
_state?: ConnectorThreadState | null,
|
||||
): string {
|
||||
return (
|
||||
normalizeParticipantKey(state?.participantKey ?? thread.participantKey) ??
|
||||
thread.id
|
||||
);
|
||||
return thread.id;
|
||||
}
|
||||
|
||||
export function readBindings<TState extends ConnectorThreadState>(
|
||||
@@ -160,40 +157,13 @@ export function findBindingForThread<TState extends ConnectorThreadState>(
|
||||
bindings: ConnectorBindingStore<TState>,
|
||||
thread: ConnectorBindingThreadIdentity,
|
||||
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
|
||||
const participantKey = normalizeParticipantKey(thread.participantKey);
|
||||
if (participantKey) {
|
||||
const exactThread = bindings[thread.id];
|
||||
const exactThreadParticipantKey = normalizeParticipantKey(
|
||||
exactThread?.participantKey ?? exactThread?.state?.participantKey,
|
||||
);
|
||||
if (
|
||||
exactThread &&
|
||||
!isControlBinding(exactThread) &&
|
||||
exactThreadParticipantKey === participantKey
|
||||
) {
|
||||
return { key: thread.id, binding: exactThread };
|
||||
}
|
||||
const exactParticipant = bindings[participantKey];
|
||||
if (exactParticipant && !isControlBinding(exactParticipant)) {
|
||||
return { key: participantKey, binding: exactParticipant };
|
||||
}
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (isControlBinding(binding)) {
|
||||
continue;
|
||||
}
|
||||
const bindingParticipantKey = normalizeParticipantKey(
|
||||
binding.participantKey ?? binding.state?.participantKey,
|
||||
);
|
||||
if (bindingParticipantKey === participantKey) {
|
||||
return { key, binding };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const exact = bindings[thread.id];
|
||||
if (exact && !isControlBinding(exact)) {
|
||||
return { key: thread.id, binding: exact };
|
||||
}
|
||||
if (!thread.isDM) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (isControlBinding(binding)) {
|
||||
continue;
|
||||
@@ -282,29 +252,8 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
|
||||
thread as ConnectorBindingThreadIdentity,
|
||||
state,
|
||||
);
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (isControlBinding(binding)) {
|
||||
continue;
|
||||
}
|
||||
const bindingParticipantKey = normalizeParticipantKey(
|
||||
binding.participantKey ?? binding.state?.participantKey,
|
||||
);
|
||||
const matchesParticipant =
|
||||
participantKey && bindingParticipantKey === participantKey;
|
||||
const matchesLegacyKey = participantKey && key === thread.id;
|
||||
const matchesLegacyThread =
|
||||
!participantKey &&
|
||||
binding.channelId === thread.channelId &&
|
||||
binding.isDM === thread.isDM;
|
||||
if (
|
||||
key !== bindingKey &&
|
||||
(matchesParticipant || matchesLegacyKey || matchesLegacyThread)
|
||||
) {
|
||||
delete bindings[key];
|
||||
}
|
||||
}
|
||||
bindings[bindingKey] = {
|
||||
kind: "participant",
|
||||
kind: "conversation",
|
||||
channelId: thread.channelId,
|
||||
isDM: thread.isDM,
|
||||
participantKey,
|
||||
@@ -531,6 +480,37 @@ export function findBindingForParticipantKey<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findBindingForDeliveryTarget<
|
||||
TState extends ConnectorThreadState,
|
||||
>(
|
||||
bindings: ConnectorBindingStore<TState>,
|
||||
input: {
|
||||
bindingKey?: string;
|
||||
threadId?: string;
|
||||
participantKey?: string;
|
||||
},
|
||||
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
|
||||
const bindingKey = normalizeParticipantKey(input.bindingKey);
|
||||
if (bindingKey) {
|
||||
const exact = bindings[bindingKey];
|
||||
if (exact && !isControlBinding(exact)) {
|
||||
return { key: bindingKey, binding: exact };
|
||||
}
|
||||
const participantMatch = findBindingForParticipantKey(bindings, bindingKey);
|
||||
if (participantMatch) {
|
||||
return participantMatch;
|
||||
}
|
||||
}
|
||||
const threadId = input.threadId?.trim();
|
||||
if (threadId) {
|
||||
const exact = bindings[threadId];
|
||||
if (exact && !isControlBinding(exact)) {
|
||||
return { key: threadId, binding: exact };
|
||||
}
|
||||
}
|
||||
return findBindingForParticipantKey(bindings, input.participantKey);
|
||||
}
|
||||
|
||||
export async function persistMergedThreadState<
|
||||
TState extends ConnectorThreadState,
|
||||
>(
|
||||
|
||||
+28
-2
@@ -245,10 +245,12 @@ export async function runCli(): Promise<void> {
|
||||
const pluginInstallCmd = pluginCmd
|
||||
.command("install")
|
||||
.alias("i")
|
||||
.description("Install a Cline Plugin from npm, git, URL, or a local path")
|
||||
.description(
|
||||
"Install a Cline Plugin from an official keyword, npm, git, URL, or a local path",
|
||||
)
|
||||
.argument(
|
||||
"<source>",
|
||||
"npm package, git URL, plugin file URL, or local plugin path",
|
||||
"official keyword, npm package, git URL, plugin file URL, or local plugin path",
|
||||
)
|
||||
.option("--npm", "Treat source as an npm package")
|
||||
.option("--git", "Treat source as a git repository")
|
||||
@@ -282,6 +284,30 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const pluginUninstallCmd = pluginCmd
|
||||
.command("uninstall")
|
||||
.alias("remove")
|
||||
.alias("rm")
|
||||
.description("Uninstall a Cline Plugin by name or path")
|
||||
.argument("<name>", "plugin package name, installed slug, or plugin path")
|
||||
.option("--json", "Output as JSON")
|
||||
.option(
|
||||
"--cwd <path>",
|
||||
"Search <path>/.cline/plugins before global plugins",
|
||||
)
|
||||
.action(async (name: string) => {
|
||||
const opts = pluginUninstallCmd.opts<{
|
||||
json?: boolean;
|
||||
cwd?: string;
|
||||
}>();
|
||||
const { runPluginUninstallCommand } = await import("./commands/plugin");
|
||||
ctx.exitCode = await runPluginUninstallCommand({
|
||||
name,
|
||||
cwd: opts.cwd,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
const connectCmd = program
|
||||
.command("connect")
|
||||
.description("Connect to an external channel")
|
||||
|
||||
@@ -455,6 +455,112 @@ Find installable skills.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes a package-backed plugin and refreshes bundled slash commands", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const packageDir = join(tempRoot, ".cline", "plugins", "delete-plugin");
|
||||
const pluginPath = join(packageDir, "index.ts");
|
||||
const skillPath = join(packageDir, "skills", "erase", "SKILL.md");
|
||||
await mkdir(join(packageDir, "skills", "erase"), { recursive: true });
|
||||
await writeFile(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "delete-plugin",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await writeFile(pluginPath, "export default {};\n");
|
||||
await writeFile(
|
||||
skillPath,
|
||||
`---
|
||||
name: erase
|
||||
---
|
||||
Erase stale plugin commands.`,
|
||||
);
|
||||
await writeFile(
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH,
|
||||
JSON.stringify({ disabledPlugins: [pluginPath] }, null, 2),
|
||||
);
|
||||
const refreshCalls: string[] = [];
|
||||
let refreshed = false;
|
||||
const userInstructionService = {
|
||||
async refreshType(type: string) {
|
||||
refreshCalls.push(type);
|
||||
refreshed = true;
|
||||
},
|
||||
listRuntimeCommands() {
|
||||
return refreshed
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "erase",
|
||||
instructions: "Erase stale plugin commands.",
|
||||
description: "Erase",
|
||||
kind: "skill",
|
||||
},
|
||||
];
|
||||
},
|
||||
listRecords(type: string) {
|
||||
if (type !== "skill") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "erase",
|
||||
type: "skill",
|
||||
filePath: skillPath,
|
||||
item: {
|
||||
name: "erase",
|
||||
disabled: false,
|
||||
description: "Erase",
|
||||
instructions: "Erase stale plugin commands.",
|
||||
frontmatter: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
} as unknown as UserInstructionConfigService;
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
userInstructionService,
|
||||
});
|
||||
const data = await loader.loadConfigData({ includePluginTools: false });
|
||||
const plugin = data.plugins.find((item) => item.path === pluginPath);
|
||||
if (!plugin) {
|
||||
throw new Error("Expected package plugin to be listed");
|
||||
}
|
||||
|
||||
const nextData = await loader.onDeleteConfigItem(plugin, {
|
||||
includePluginTools: false,
|
||||
});
|
||||
const settings = JSON.parse(
|
||||
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
|
||||
) as { disabledPlugins?: string[] };
|
||||
|
||||
await expect(readFile(pluginPath, "utf8")).rejects.toThrow();
|
||||
await expect(readFile(skillPath, "utf8")).rejects.toThrow();
|
||||
expect(settings.disabledPlugins).toBeUndefined();
|
||||
expect(refreshCalls).toEqual(
|
||||
expect.arrayContaining(["workflow", "rule", "skill"]),
|
||||
);
|
||||
expect(nextData?.plugins.some((item) => item.path === pluginPath)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
nextData?.workflowSlashCommands.map((command) => command.name),
|
||||
).not.toContain("erase");
|
||||
});
|
||||
|
||||
it("uses the package name for package-backed plugin entries", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
type UserInstructionConfigService,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type InteractiveConfigData,
|
||||
@@ -36,6 +37,18 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
includePluginTools: options.includePluginTools,
|
||||
});
|
||||
|
||||
const refreshUserInstructionConfigs = async (): Promise<void> => {
|
||||
const service = input.userInstructionService;
|
||||
if (!service) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
service.refreshType("workflow"),
|
||||
service.refreshType("rule"),
|
||||
service.refreshType("skill"),
|
||||
]);
|
||||
};
|
||||
|
||||
const onToggleConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
@@ -106,8 +119,26 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onDeleteConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
if (item.kind !== "plugin") {
|
||||
return undefined;
|
||||
}
|
||||
await uninstallPlugin({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
});
|
||||
await refreshUserInstructionConfigs();
|
||||
return await loadConfigData(options);
|
||||
};
|
||||
|
||||
return {
|
||||
loadConfigData,
|
||||
onToggleConfigItem,
|
||||
onDeleteConfigItem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { AgentTool } from "@cline/shared";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
@@ -112,7 +113,7 @@ function makeManager() {
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async () => []),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
@@ -124,6 +125,31 @@ function makeManager() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeTurnResult() {
|
||||
return {
|
||||
text: "ok",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
@@ -231,4 +257,84 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers and retries when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "hi" }],
|
||||
},
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
});
|
||||
|
||||
expect(result?.finishReason).toBe("completed");
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ sessionId: "session-2" }),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
manager.readMessages
|
||||
.mockImplementationOnce(() => recoveryRead.promise)
|
||||
.mockResolvedValue([]);
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
})
|
||||
.catch((error) => error);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
let cleanupSettled = false;
|
||||
const cleanupPromise = runtime.cleanup().finally(() => {
|
||||
cleanupSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(manager.get).not.toHaveBeenCalled();
|
||||
expect(manager.dispose).not.toHaveBeenCalled();
|
||||
|
||||
recoveryRead.resolve([]);
|
||||
await cleanupPromise;
|
||||
const sendError = await sendPromise;
|
||||
|
||||
expect(sendError).toBeInstanceOf(SessionNotFoundError);
|
||||
expect(manager.dispose).toHaveBeenCalledWith("cli_interactive_shutdown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type CheckpointEntry,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
readSessionCheckpointHistory,
|
||||
@@ -74,6 +75,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
let missingSessionRecoveryPromise: Promise<void> | undefined;
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
@@ -248,6 +250,37 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return (await sessionManager.readMessages(activeSessionId)) ?? [];
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
|
||||
if (missingSessionRecoveryPromise) {
|
||||
return await missingSessionRecoveryPromise;
|
||||
}
|
||||
missingSessionRecoveryPromise = (async () => {
|
||||
const manager = sessionManager;
|
||||
const missingSessionId = activeSessionId;
|
||||
if (!manager || !missingSessionId || shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
const messages = await manager
|
||||
.readMessages(missingSessionId)
|
||||
.catch(() => []);
|
||||
input.config.logger?.log("Recovering missing interactive session", {
|
||||
sessionId: missingSessionId,
|
||||
messageCount: messages.length,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages);
|
||||
})().finally(() => {
|
||||
missingSessionRecoveryPromise = undefined;
|
||||
});
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -334,10 +367,29 @@ export function createInteractiveSessionRuntime(input: {
|
||||
? startupError
|
||||
: new Error("interactive session manager is unavailable");
|
||||
}
|
||||
return await sessionManager.send({
|
||||
sessionId: activeSessionId,
|
||||
...turnInput,
|
||||
});
|
||||
const manager = sessionManager;
|
||||
try {
|
||||
return await manager.send({
|
||||
sessionId: activeSessionId,
|
||||
...turnInput,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
abortRequested ||
|
||||
shutdownRequested ||
|
||||
!isSessionNotFoundError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await recoverMissingActiveSession(error);
|
||||
if (!activeSessionId || abortRequested || shutdownRequested) {
|
||||
throw error;
|
||||
}
|
||||
return await manager.send({
|
||||
sessionId: activeSessionId,
|
||||
...turnInput,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updatePendingPrompt = async (input: {
|
||||
@@ -550,20 +602,20 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let exitSummary: InteractiveExitSummary | undefined;
|
||||
try {
|
||||
await startupPromise?.catch(() => {});
|
||||
await missingSessionRecoveryPromise?.catch(() => {});
|
||||
} finally {
|
||||
unsubscribeAgent();
|
||||
unsubscribePendingPrompts();
|
||||
}
|
||||
try {
|
||||
exitSummary = await getExitSummary();
|
||||
// Mark hooks shut down before session disposal so late abort/stop
|
||||
// emissions cannot dispatch over a closing hub transport.
|
||||
await runtimeHooks?.shutdown();
|
||||
await stopCurrentSession();
|
||||
} finally {
|
||||
try {
|
||||
if (sessionManager) {
|
||||
await sessionManager.dispose("cli_interactive_shutdown");
|
||||
}
|
||||
} finally {
|
||||
await runtimeHooks?.shutdown();
|
||||
if (sessionManager) {
|
||||
await sessionManager.dispose("cli_interactive_shutdown");
|
||||
}
|
||||
}
|
||||
return exitSummary;
|
||||
|
||||
@@ -228,11 +228,11 @@ export async function runAgent(
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
unsubscribe();
|
||||
await runtimeHooks.shutdown().catch(() => {});
|
||||
if (activeSessionId) {
|
||||
await sessionManager.stop(activeSessionId).catch(() => {});
|
||||
}
|
||||
await sessionManager.dispose("cli_run_shutdown").catch(() => {});
|
||||
await runtimeHooks.shutdown().catch(() => {});
|
||||
setActiveRuntimeAbort(undefined);
|
||||
})();
|
||||
return cleanupDone;
|
||||
|
||||
@@ -322,6 +322,18 @@ export async function runInteractive(
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const onDeleteConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof configDataLoader.onDeleteConfigItem>>
|
||||
> => {
|
||||
const data = await configDataLoader.onDeleteConfigItem(item, options);
|
||||
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
|
||||
await refreshInteractiveSessionPolicies();
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const toQueuedPromptItem = (prompt: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
@@ -397,6 +409,7 @@ export async function runInteractive(
|
||||
}),
|
||||
loadConfigData: configDataLoader.loadConfigData,
|
||||
onToggleConfigItem,
|
||||
onDeleteConfigItem,
|
||||
subscribeToEvents: ({
|
||||
onAgentEvent: onAgent,
|
||||
onTeamEvent: onTeam,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { toProviderApiKey } from "../utils/provider-auth";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
|
||||
|
||||
@@ -30,6 +32,8 @@ export function formatClineCredits(value: number): string {
|
||||
return formatCreditBalance(normalizeCreditBalance(value));
|
||||
}
|
||||
|
||||
// FIXME: These message checks are temporary until structured error types are
|
||||
// passed through to the CLI instead of plain error strings.
|
||||
export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
@@ -38,6 +42,14 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAccountApiBaseUrl(input: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
} from "../cline-account";
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
@@ -256,6 +260,36 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="red"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Cline Credits depleted</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="You have run out of Cline credits. Add credits in the dashboard to continue."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Dashboard: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -351,6 +385,9 @@ export function ChatEntryView(props: {
|
||||
);
|
||||
|
||||
case "error":
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -137,3 +137,54 @@ export function ExtDetailContent(
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteConfigItemConfirmContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
item: InteractiveConfigItem;
|
||||
},
|
||||
) {
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "return" || key.name === "y") {
|
||||
props.resolve(true);
|
||||
} else if (key.name === "escape" || key.name === "n") {
|
||||
props.dismiss();
|
||||
}
|
||||
}, props.dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text>Delete plugin {props.item.name}?</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
This removes the installed plugin files from {props.item.path}.
|
||||
</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>Y/Enter to confirm, N/Esc to cancel</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigErrorContent(
|
||||
props: ChoiceContext<void> & {
|
||||
title: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "return" || key.name === "escape") {
|
||||
props.dismiss();
|
||||
}
|
||||
}, props.dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1}>
|
||||
<text fg="red">{props.title}</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
{props.message}
|
||||
</text>
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>Enter/Esc to close</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { palette } from "../palette";
|
||||
import type { RuntimeToolInteraction } from "../types";
|
||||
import { formatApprovalParams } from "./dialogs/tool-approval";
|
||||
@@ -22,23 +23,129 @@ function keyToText(name: string): string {
|
||||
return name === "space" ? " " : name;
|
||||
}
|
||||
|
||||
function getToolShellMaxHeight(terminalHeight: number): number {
|
||||
return Math.max(7, Math.min(14, Math.floor(terminalHeight * 0.38)));
|
||||
}
|
||||
|
||||
function getAskQuestionShellMaxHeight(terminalHeight: number): number {
|
||||
const preferredHeight = Math.max(11, Math.floor(terminalHeight * 0.58));
|
||||
const availableHeight = Math.max(7, terminalHeight - 3);
|
||||
return Math.min(18, preferredHeight, availableHeight);
|
||||
}
|
||||
|
||||
function getAskQuestionBodyHeight(shellMaxHeight: number): number {
|
||||
return Math.max(1, shellMaxHeight - 4);
|
||||
}
|
||||
|
||||
function addWrappedWidth(input: {
|
||||
rows: number;
|
||||
lineWidth: number;
|
||||
width: number;
|
||||
maxWidth: number;
|
||||
}): { rows: number; lineWidth: number } {
|
||||
if (input.width <= 0) {
|
||||
return { rows: input.rows, lineWidth: input.lineWidth };
|
||||
}
|
||||
|
||||
let rows = input.rows;
|
||||
let remainingWidth = input.width;
|
||||
let lineWidth = input.lineWidth;
|
||||
|
||||
if (lineWidth > 0) {
|
||||
const availableWidth = input.maxWidth - lineWidth;
|
||||
if (remainingWidth <= availableWidth) {
|
||||
return { rows, lineWidth: lineWidth + remainingWidth };
|
||||
}
|
||||
|
||||
remainingWidth -= Math.max(0, availableWidth);
|
||||
rows += 1;
|
||||
lineWidth = 0;
|
||||
}
|
||||
|
||||
rows += Math.max(0, Math.ceil(remainingWidth / input.maxWidth) - 1);
|
||||
lineWidth = remainingWidth % input.maxWidth || input.maxWidth;
|
||||
|
||||
return { rows, lineWidth };
|
||||
}
|
||||
|
||||
function countWrappedRows(text: string, width: number): number {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const paragraphs = text.split("\n");
|
||||
let rows = 0;
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
rows += 1;
|
||||
let lineWidth = 0;
|
||||
const tokens = paragraph.match(/\s+|\S+/g) ?? [];
|
||||
|
||||
for (const token of tokens) {
|
||||
const tokenWidth = Bun.stringWidth(token);
|
||||
const isWhitespace = /^\s+$/.test(token);
|
||||
|
||||
if (
|
||||
!isWhitespace &&
|
||||
lineWidth > 0 &&
|
||||
lineWidth + tokenWidth > safeWidth
|
||||
) {
|
||||
rows += 1;
|
||||
lineWidth = 0;
|
||||
}
|
||||
|
||||
const next = addWrappedWidth({
|
||||
rows,
|
||||
lineWidth,
|
||||
width: tokenWidth,
|
||||
maxWidth: safeWidth,
|
||||
});
|
||||
rows = next.rows;
|
||||
lineWidth = next.lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getAskQuestionContentHeight(input: {
|
||||
terminalWidth: number;
|
||||
question: string;
|
||||
options: string[];
|
||||
customText: string;
|
||||
}): number {
|
||||
const questionWidth = Math.max(1, input.terminalWidth - 3);
|
||||
const optionTextWidth = Math.max(1, input.terminalWidth - 7);
|
||||
const questionRows = countWrappedRows(input.question, questionWidth);
|
||||
const optionRows = input.options.reduce(
|
||||
(rows, option) => rows + countWrappedRows(option, optionTextWidth),
|
||||
0,
|
||||
);
|
||||
const customRows = countWrappedRows(input.customText, optionTextWidth);
|
||||
return questionRows + 1 + optionRows + customRows;
|
||||
}
|
||||
|
||||
function getAskQuestionChoiceId(interactionId: number, index: number): string {
|
||||
return `ask-question-${interactionId.toString()}-choice-${index.toString()}`;
|
||||
}
|
||||
|
||||
function Shell(
|
||||
props: Pick<
|
||||
InlineToolResponseProps,
|
||||
"accent" | "inputBackground" | "inputForeground"
|
||||
> & {
|
||||
title: string;
|
||||
maxHeight?: number;
|
||||
overflow?: "hidden";
|
||||
children: React.ReactNode;
|
||||
},
|
||||
) {
|
||||
const { height } = useTerminalDimensions();
|
||||
const maxHeight = Math.max(7, Math.min(14, Math.floor(height * 0.38)));
|
||||
const maxHeight = props.maxHeight ?? getToolShellMaxHeight(height);
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
maxHeight={maxHeight}
|
||||
overflow={props.overflow}
|
||||
backgroundColor={props.inputBackground}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
@@ -59,6 +166,7 @@ function ChoiceButton(props: {
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
paddingX={1}
|
||||
backgroundColor={props.selected ? palette.selection : undefined}
|
||||
@@ -155,9 +263,11 @@ function AskQuestionResponse(
|
||||
},
|
||||
) {
|
||||
const { interaction } = props;
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customEmptyAttempted, setCustomEmptyAttempted] = useState(false);
|
||||
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const selectedRef = useRef(0);
|
||||
const customValueRef = useRef("");
|
||||
const interactionId = interaction.id;
|
||||
@@ -165,6 +275,24 @@ function AskQuestionResponse(
|
||||
const customIndex = interaction.options.length;
|
||||
const isTyping = selected === customIndex;
|
||||
const totalChoices = interaction.options.length + 1;
|
||||
const shellMaxHeight = getAskQuestionShellMaxHeight(height);
|
||||
const maxBodyHeight = getAskQuestionBodyHeight(shellMaxHeight);
|
||||
const customText = isTyping
|
||||
? customValue
|
||||
? `${customValue}|`
|
||||
: customEmptyAttempted
|
||||
? "Type a response first..."
|
||||
: "Type a response..."
|
||||
: "Type a response...";
|
||||
const bodyHeight = Math.min(
|
||||
maxBodyHeight,
|
||||
getAskQuestionContentHeight({
|
||||
terminalWidth: width,
|
||||
question: interaction.question,
|
||||
options: interaction.options,
|
||||
customText,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectIndex = useCallback(
|
||||
(index: number) => {
|
||||
@@ -192,6 +320,26 @@ function AskQuestionResponse(
|
||||
[interactionId, onResolveAskQuestion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const choiceId = getAskQuestionChoiceId(interactionId, selected);
|
||||
let canceled = false;
|
||||
const scrollSelectedChoiceIntoView = () => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollChildIntoView(choiceId);
|
||||
};
|
||||
|
||||
scrollSelectedChoiceIntoView();
|
||||
queueMicrotask(scrollSelectedChoiceIntoView);
|
||||
const timeout = setTimeout(scrollSelectedChoiceIntoView, 0);
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [interactionId, selected]);
|
||||
|
||||
useKeyboard((key) => {
|
||||
const typing = selectedRef.current === customIndex;
|
||||
if (key.name === "escape") {
|
||||
@@ -261,64 +409,91 @@ function AskQuestionResponse(
|
||||
accent={props.accent}
|
||||
inputBackground={props.inputBackground}
|
||||
inputForeground={props.inputForeground}
|
||||
maxHeight={shellMaxHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
<text fg={props.inputForeground} selectable>
|
||||
{interaction.question}
|
||||
</text>
|
||||
<scrollbox
|
||||
ref={scrollRef}
|
||||
height={bodyHeight}
|
||||
width="100%"
|
||||
scrollY
|
||||
scrollX={false}
|
||||
viewportOptions={{ overflow: "hidden" }}
|
||||
contentOptions={{ flexDirection: "column" }}
|
||||
>
|
||||
<box flexDirection="column" gap={1} flexShrink={0} width="100%">
|
||||
<text fg={props.inputForeground} selectable flexShrink={0}>
|
||||
{interaction.question}
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
{interaction.options.map((option, index) => {
|
||||
const optionSelected = !isTyping && selected === index;
|
||||
return (
|
||||
<box flexDirection="column" flexShrink={0} width="100%">
|
||||
{interaction.options.map((option, index) => {
|
||||
const optionSelected = !isTyping && selected === index;
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
id={getAskQuestionChoiceId(interactionId, index)}
|
||||
key={`${index.toString()}:${option}`}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={
|
||||
optionSelected ? palette.selection : undefined
|
||||
}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{option}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input. */}
|
||||
<box
|
||||
key={`${index.toString()}:${option}`}
|
||||
id={getAskQuestionChoiceId(interactionId, customIndex)}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={optionSelected ? palette.selection : undefined}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
fg={isTyping ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
>
|
||||
{option}
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
{customText}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.inputPlaceholder} flexGrow={1} flexShrink={1}>
|
||||
Type a response...
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
<box
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text fg={isTyping ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1}>
|
||||
{customValue
|
||||
? `${customValue}|`
|
||||
: customEmptyAttempted
|
||||
? "Type a response first..."
|
||||
: "Type a response..."}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.inputPlaceholder}>Type a response...</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import type {
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import { ExtDetailContent } from "../components/dialogs/config-dialogs";
|
||||
import {
|
||||
ConfigErrorContent,
|
||||
DeleteConfigItemConfirmContent,
|
||||
ExtDetailContent,
|
||||
} from "../components/dialogs/config-dialogs";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { ConfigPanelContent } from "../views/config-view";
|
||||
import type { ConfigAction } from "../views/config-view-helpers";
|
||||
@@ -35,6 +39,10 @@ export function useConfigPanel(opts: {
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
|
||||
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
|
||||
refocusTextarea: () => void;
|
||||
@@ -90,6 +98,7 @@ export function useConfigPanel(opts: {
|
||||
activeTab = tab;
|
||||
}}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
onDeleteConfigItem={opts.onDeleteConfigItem}
|
||||
onToggleMode={opts.toggleMode}
|
||||
onToggleAutoApprove={opts.toggleAutoApprove}
|
||||
onSetCompactionMode={opts.setCompactionMode}
|
||||
@@ -111,6 +120,38 @@ export function useConfigPanel(opts: {
|
||||
await opts.openModelSelector({ onCancel: () => {} });
|
||||
} else if (action.kind === "toggle-item") {
|
||||
await opts.onToggleConfigItem?.(action.item);
|
||||
} else if (action.kind === "delete-item") {
|
||||
const confirmed = await opts.dialog.choice<boolean>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<DeleteConfigItemConfirmContent {...ctx} item={action.item} />
|
||||
),
|
||||
});
|
||||
if (confirmed && opts.onDeleteConfigItem) {
|
||||
try {
|
||||
await withLoadingDialog(
|
||||
opts.dialog,
|
||||
`Deleting ${action.item.name}...`,
|
||||
async () =>
|
||||
await opts.onDeleteConfigItem?.(action.item, {
|
||||
includePluginTools: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await opts.dialog.choice<void>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ConfigErrorContent
|
||||
{...ctx}
|
||||
title="Plugin delete failed"
|
||||
message={
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (action.kind === "ext-detail") {
|
||||
await opts.dialog.choice<void>({
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
|
||||
@@ -234,6 +234,8 @@ export function useRootKeyboard(input: {
|
||||
const abortStarted = input.onAbort();
|
||||
if (abortStarted) {
|
||||
session.setAbortRequested(true);
|
||||
session.setIsStreaming(false);
|
||||
session.closeInlineStream();
|
||||
}
|
||||
} else if (selectedQueuedPromptId) {
|
||||
queuedSelection.select(null);
|
||||
|
||||
@@ -207,6 +207,19 @@ function App(props: TuiProps) {
|
||||
return data;
|
||||
};
|
||||
}, [propsOnToggleConfigItem]);
|
||||
const propsOnDeleteConfigItem = props.onDeleteConfigItem;
|
||||
const onDeleteConfigItem = useMemo<TuiProps["onDeleteConfigItem"]>(() => {
|
||||
if (!propsOnDeleteConfigItem) {
|
||||
return undefined;
|
||||
}
|
||||
return async (item, options) => {
|
||||
const data = await propsOnDeleteConfigItem(item, options);
|
||||
if (data) {
|
||||
setWorkflowSlashCommands(data.workflowSlashCommands);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
}, [propsOnDeleteConfigItem]);
|
||||
|
||||
const openConfig = useConfigPanel({
|
||||
dialog,
|
||||
@@ -219,6 +232,7 @@ function App(props: TuiProps) {
|
||||
termHeight,
|
||||
loadConfigData: props.loadConfigData,
|
||||
onToggleConfigItem,
|
||||
onDeleteConfigItem,
|
||||
openModelSelector,
|
||||
openMcpManager,
|
||||
refocusTextarea: () => refocusTextareaRef.current(),
|
||||
|
||||
@@ -137,6 +137,10 @@ export interface TuiProps {
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
subscribeToEvents: (handlers: {
|
||||
onAgentEvent: (event: AgentEvent) => void;
|
||||
onTeamEvent: (event: TeamEvent) => void;
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ConfigAction =
|
||||
| { kind: "open-provider" }
|
||||
| { kind: "open-model" }
|
||||
| { kind: "toggle-item"; item: InteractiveConfigItem }
|
||||
| { kind: "delete-item"; item: InteractiveConfigItem }
|
||||
| {
|
||||
kind: "ext-detail";
|
||||
item: InteractiveConfigItem;
|
||||
@@ -130,6 +131,10 @@ export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return isToggleableInteractiveConfigItem(item);
|
||||
}
|
||||
|
||||
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return item.kind === "plugin";
|
||||
}
|
||||
|
||||
export function resolveConfigItemSelectAction(
|
||||
item: InteractiveConfigItem,
|
||||
): ConfigAction {
|
||||
@@ -156,6 +161,15 @@ export function resolveConfigItemToggleAction(
|
||||
return { kind: "toggle-item", item };
|
||||
}
|
||||
|
||||
export function resolveConfigItemDeleteAction(
|
||||
item: InteractiveConfigItem,
|
||||
): ConfigAction | undefined {
|
||||
if (!isDeletableConfigItem(item)) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "delete-item", item };
|
||||
}
|
||||
|
||||
export function isInlineConfigAction(
|
||||
action: ConfigAction | undefined,
|
||||
): boolean {
|
||||
@@ -183,14 +197,33 @@ export function canToggleConfigFooterRow(
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteConfigFooterRow(
|
||||
row:
|
||||
| { kind: "ext"; item: InteractiveConfigItem }
|
||||
| { kind: string }
|
||||
| undefined,
|
||||
): boolean {
|
||||
return (
|
||||
row?.kind === "ext" && "item" in row && isDeletableConfigItem(row.item)
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfigFooterText({
|
||||
canToggle = false,
|
||||
canDelete = false,
|
||||
}: {
|
||||
canToggle?: boolean;
|
||||
canDelete?: boolean;
|
||||
} = {}): string {
|
||||
return canToggle
|
||||
? "←/→ switch tabs, ↑/↓ navigate, Tab/Enter select, Space toggle, Esc close"
|
||||
: "←/→ switch tabs, ↑/↓ navigate, Tab/Enter select, Esc close";
|
||||
const actions = ["←/→ switch tabs", "↑/↓ navigate", "Tab/Enter select"];
|
||||
if (canToggle) {
|
||||
actions.push("Space toggle");
|
||||
}
|
||||
if (canDelete) {
|
||||
actions.push("D delete");
|
||||
}
|
||||
actions.push("Esc close");
|
||||
return actions.join(", ");
|
||||
}
|
||||
|
||||
export function getConfigItemDisplayName(name: string): string {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readGlobalSettings, setAutoUpdateEnabledGlobally } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
@@ -18,6 +19,7 @@ import { resolveModelDisplayName } from "../components/status-bar";
|
||||
import { getModeAccent, palette } from "../palette";
|
||||
import {
|
||||
type ConfigAction,
|
||||
canDeleteConfigFooterRow,
|
||||
canToggleConfigFooterRow,
|
||||
getAdjacentConfigTab,
|
||||
getConfigFooterText,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveActiveConfigItems,
|
||||
resolveConfigItemDeleteAction,
|
||||
resolveConfigItemSelectAction,
|
||||
resolveConfigItemToggleAction,
|
||||
resolveInitialConfigTab,
|
||||
@@ -133,6 +136,10 @@ export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onToggleMode: () => void;
|
||||
onToggleAutoApprove: () => void;
|
||||
onSetCompactionMode: (mode: CliCompactionMode) => void;
|
||||
@@ -362,6 +369,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const [autoApprove, setAutoApprove] = useState(
|
||||
config.toolPolicies["*"]?.autoApprove !== false,
|
||||
);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(
|
||||
() => readGlobalSettings().autoUpdateEnabled,
|
||||
);
|
||||
const [verbose, setVerbose] = useState(config.verbose);
|
||||
const [compactionMode, setCompactionMode] = useState(
|
||||
props.currentCompactionMode,
|
||||
@@ -439,6 +449,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
id: "auto-approve",
|
||||
label: "Auto-approve all",
|
||||
});
|
||||
r.push({ kind: "toggle", id: "auto-update", label: "Auto update" });
|
||||
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
|
||||
} else {
|
||||
const activeItems = resolveActiveConfigItems(configData, activeTab);
|
||||
@@ -518,6 +529,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const selectedRowIdx = navIndices[clampedNavPos] ?? 0;
|
||||
const selectedRow = rows[selectedRowIdx];
|
||||
const canToggleSelectedRow = canToggleConfigFooterRow(selectedRow);
|
||||
const canDeleteSelectedRow = Boolean(
|
||||
props.onDeleteConfigItem && canDeleteConfigFooterRow(selectedRow),
|
||||
);
|
||||
|
||||
const setNavPosition = (nextNavPos: number) => {
|
||||
setNavPos(nextNavPos);
|
||||
@@ -575,6 +589,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
setAutoApprove(!autoApprove);
|
||||
props.onToggleAutoApprove();
|
||||
break;
|
||||
case "auto-update":
|
||||
setAutoUpdateEnabled((previous) => {
|
||||
const next = !previous;
|
||||
setAutoUpdateEnabledGlobally(next);
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case "compaction": {
|
||||
const nextMode = getNextCliCompactionMode(compactionMode);
|
||||
setCompactionMode(nextMode);
|
||||
@@ -618,6 +639,20 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (!props.onDeleteConfigItem) {
|
||||
return;
|
||||
}
|
||||
const row = rows[selectedRowIdx];
|
||||
if (!row || row.kind !== "ext") {
|
||||
return;
|
||||
}
|
||||
const action = resolveConfigItemDeleteAction(row.item);
|
||||
if (action) {
|
||||
resolve(action);
|
||||
}
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
@@ -648,6 +683,16 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
handleToggleSelected();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === "d" &&
|
||||
!key.ctrl &&
|
||||
!key.meta &&
|
||||
!key.option &&
|
||||
!key.shift
|
||||
) {
|
||||
handleDeleteSelected();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "tab") {
|
||||
handleSelect();
|
||||
}
|
||||
@@ -756,6 +801,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
} else if (row.id === "auto-approve") {
|
||||
value = autoApprove ? "● on" : "○ off";
|
||||
valueColor = autoApprove ? palette.success : "gray";
|
||||
} else if (row.id === "auto-update") {
|
||||
value = autoUpdateEnabled ? "● on" : "○ off";
|
||||
valueColor = autoUpdateEnabled ? palette.success : "gray";
|
||||
} else if (row.id === "compaction") {
|
||||
value = formatCliCompactionMode(compactionMode);
|
||||
valueColor = COMPACTION_MODE_COLORS[compactionMode];
|
||||
@@ -844,7 +892,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
<em>
|
||||
{togglingItemId
|
||||
? "Applying settings"
|
||||
: getConfigFooterText({ canToggle: canToggleSelectedRow })}
|
||||
: getConfigFooterText({
|
||||
canToggle: canToggleSelectedRow,
|
||||
canDelete: canDeleteSelectedRow,
|
||||
})}
|
||||
</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -215,4 +215,21 @@ describe("createRuntimeHooks", () => {
|
||||
expect(outputMocks.write).toHaveBeenCalledWith("\n[hook:prompt_submit]\n");
|
||||
expect(eventMocks.closeInlineStreamIfNeeded).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not dispatch hooks after shutdown", async () => {
|
||||
const dispatchHookEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
yolo: false,
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
verbose: true,
|
||||
dispatchHookEvent,
|
||||
});
|
||||
|
||||
await runtimeHooks.shutdown();
|
||||
await emitRunStartAndPrompt(runtimeHooks.hooks!);
|
||||
|
||||
expect(dispatchHookEvent).not.toHaveBeenCalled();
|
||||
expect(outputMocks.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,13 +138,23 @@ async function dispatchHookPayload(
|
||||
payload: HookEventPayload,
|
||||
options: {
|
||||
dispatchHookEvent: (payload: HookEventPayload) => Promise<void>;
|
||||
isShuttingDown: () => boolean;
|
||||
verbose: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (options.isShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.dispatchHookEvent(payload);
|
||||
if (options.isShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
writeHookInvocation(payload, { verbose: options.verbose });
|
||||
} catch (error) {
|
||||
if (options.isShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
if (isDev) {
|
||||
writeErr(
|
||||
`hook dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -172,6 +182,8 @@ export function createRuntimeHooks(options: {
|
||||
const verbose = options.verbose === true;
|
||||
const cwd = options.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot = options.workspaceRoot?.trim() || cwd;
|
||||
let shuttingDown = false;
|
||||
const isShuttingDown = () => shuttingDown;
|
||||
return {
|
||||
hooks: {
|
||||
beforeRun: async (ctx) => {
|
||||
@@ -197,6 +209,7 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -223,6 +236,7 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -261,6 +275,7 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -279,6 +294,7 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -306,6 +322,7 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
@@ -328,11 +345,14 @@ export function createRuntimeHooks(options: {
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
isShuttingDown,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
shutdown: async () => {},
|
||||
shutdown: async () => {
|
||||
shuttingDown = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { runConnectAdapter } from "../../commands/connect";
|
||||
import { PLATFORMS, type PlatformDef, type SecurityDef } from "./platforms";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type PlatformDef,
|
||||
type SecurityDef,
|
||||
shouldIncludeField,
|
||||
} from "./platforms";
|
||||
|
||||
function isCancel(value: unknown): value is symbol {
|
||||
return p.isCancel(value);
|
||||
@@ -10,6 +15,7 @@ const SENSITIVE_FLAGS = new Set([
|
||||
"-k",
|
||||
"--access-token",
|
||||
"--api-key",
|
||||
"--app-token",
|
||||
"--app-secret",
|
||||
"--bot-token",
|
||||
"--credentials-json",
|
||||
@@ -33,28 +39,40 @@ function redactCommandArgs(args: string[]): string {
|
||||
|
||||
async function collectFields(platform: PlatformDef): Promise<string[] | null> {
|
||||
const args: string[] = [];
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, values)) {
|
||||
continue;
|
||||
}
|
||||
if (field.help) {
|
||||
for (const line of field.help) {
|
||||
p.log.info(line);
|
||||
}
|
||||
}
|
||||
|
||||
const value = await p.text({
|
||||
message: field.label,
|
||||
placeholder: field.placeholder,
|
||||
validate: field.required
|
||||
? (v) => {
|
||||
if (!v?.trim()) return `${field.label} is required`;
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const value = field.options
|
||||
? await p.select({
|
||||
message: field.label,
|
||||
options: field.options,
|
||||
initialValue: field.initialValue,
|
||||
})
|
||||
: await p.text({
|
||||
message: field.label,
|
||||
placeholder: field.placeholder,
|
||||
defaultValue: field.initialValue,
|
||||
validate: field.required
|
||||
? (v) => {
|
||||
if (!v?.trim()) return `${field.label} is required`;
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (isCancel(value)) return null;
|
||||
|
||||
const trimmed = (value as string).trim();
|
||||
values[field.flag] = trimmed;
|
||||
if (trimmed) {
|
||||
args.push(field.flag, trimmed);
|
||||
}
|
||||
@@ -103,9 +121,9 @@ async function collectSecurity(
|
||||
values[field.key] = (value as string).trim();
|
||||
}
|
||||
|
||||
const hookCmd = security.buildHookCommand(values);
|
||||
const args = security.buildArgs(values);
|
||||
p.log.success("Access restriction enabled");
|
||||
return ["--hook-command", hookCmd];
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function runConnectWizard(): Promise<number> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLATFORMS } from "./platforms";
|
||||
import { PLATFORMS, shouldIncludeField } from "./platforms";
|
||||
|
||||
describe("connect wizard platform security fields", () => {
|
||||
it("does not ask Telegram users to re-enter the bot username", () => {
|
||||
@@ -30,4 +30,52 @@ describe("connect wizard platform security fields", () => {
|
||||
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
|
||||
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
|
||||
});
|
||||
|
||||
it("uses the Telegram allowed user ID flag for wizard security", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
const args = telegram?.security?.buildArgs({
|
||||
userId: "123456",
|
||||
});
|
||||
|
||||
expect(args).toEqual(["--allowed-user-id", "123456"]);
|
||||
});
|
||||
|
||||
it("builds an exact-match Slack authorization hook", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const args = slack?.security?.buildArgs({
|
||||
teamId: "T01ABC123",
|
||||
userId: "U01ABC123",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:T01ABC123:user:U01ABC123" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks Slack users for mode-specific setup fields", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
const fields = slack?.fields ?? [];
|
||||
const webhookValues = { "--base-url": "https://example.test" };
|
||||
const socketValues = { "--base-url": "" };
|
||||
|
||||
expect(fields.map((field) => field.flag)).toEqual([
|
||||
"--bot-token",
|
||||
"--base-url",
|
||||
"--signing-secret",
|
||||
"--app-token",
|
||||
]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, webhookValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, socketValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--app-token"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook";
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
@@ -13,8 +13,17 @@ export interface FieldDef {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -27,7 +36,25 @@ export interface SecurityFieldDef {
|
||||
export interface SecurityDef {
|
||||
prompt: string;
|
||||
fields: SecurityFieldDef[];
|
||||
buildHookCommand: (values: Record<string, string>) => string;
|
||||
buildArgs: (values: Record<string, string>) => string[];
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
@@ -84,15 +111,14 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
validate: validateTelegramUserId,
|
||||
},
|
||||
],
|
||||
buildHookCommand: ({ userId }) =>
|
||||
`jq -r ".payload.actor.participantKey" | grep -q "telegram:id:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "webhook",
|
||||
hint: "Requires a Slack app and public URL.",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
@@ -105,21 +131,32 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Your publicly accessible URL for webhook callbacks",
|
||||
"Use ngrok or similar for local development",
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
@@ -148,8 +185,10 @@ export const PLATFORMS: PlatformDef[] = [
|
||||
validate: validateSlackUserId,
|
||||
},
|
||||
],
|
||||
buildHookCommand: ({ teamId, userId }) =>
|
||||
`jq -r ".payload.actor.participantKey" | grep -q "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
buildArgs: ({ teamId, userId }) => [
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { PLATFORMS } from "../../../cli/src/wizards/connect/platforms";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type {
|
||||
WebviewConnectorChannel,
|
||||
WebviewConnectorChannelsResponse,
|
||||
@@ -27,6 +30,9 @@ export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
@@ -104,9 +110,21 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
const value = asString(values[field.flag]);
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
@@ -124,10 +142,7 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(
|
||||
"--hook-command",
|
||||
platform.security.buildHookCommand(hookValues),
|
||||
);
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
@@ -155,6 +156,13 @@ export async function handleDesktopCommand(
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
@@ -134,6 +134,13 @@ export type WebviewConnectorField = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebviewConnectorSecurityField = {
|
||||
@@ -147,7 +154,7 @@ export type WebviewConnectorSecurityField = {
|
||||
export type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook";
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: WebviewConnectorField[];
|
||||
security?: {
|
||||
@@ -168,6 +175,7 @@ export type WebviewActiveConnector = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
export type WebviewConnectorChannelsResponse = {
|
||||
|
||||
@@ -42,6 +42,13 @@ type ConnectorField = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
@@ -55,7 +62,7 @@ type ConnectorSecurityField = {
|
||||
type ConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook";
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
@@ -76,6 +83,7 @@ type ActiveConnector = {
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
type ConnectorChannelsResponse = {
|
||||
@@ -142,10 +150,41 @@ function isMultilineField(field: ConnectorField): boolean {
|
||||
return label.includes("json") || field.flag.includes("credentials");
|
||||
}
|
||||
|
||||
function shouldIncludeField(
|
||||
field: ConnectorField,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialValuesForChannel(
|
||||
channel?: ConnectorChannel,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of channel?.fields ?? []) {
|
||||
if (field.initialValue) {
|
||||
values[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
|
||||
const channel = channels[0];
|
||||
return {
|
||||
channelId: channels[0]?.id ?? "",
|
||||
values: {},
|
||||
channelId: channel?.id ?? "",
|
||||
values: initialValuesForChannel(channel),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
};
|
||||
@@ -175,6 +214,15 @@ export function ChannelsContent() {
|
||||
() => channels.find((channel) => channel.id === formState.channelId),
|
||||
[channels, formState.channelId],
|
||||
);
|
||||
const visibleFields = useMemo(() => {
|
||||
const values = {
|
||||
...initialValuesForChannel(selectedChannel),
|
||||
...formState.values,
|
||||
};
|
||||
return (selectedChannel?.fields ?? []).filter((field) =>
|
||||
shouldIncludeField(field, values),
|
||||
);
|
||||
}, [selectedChannel, formState.values]);
|
||||
|
||||
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
|
||||
setChannels(response.available);
|
||||
@@ -233,6 +281,9 @@ export function ChannelsContent() {
|
||||
return;
|
||||
}
|
||||
for (const field of selectedChannel.fields) {
|
||||
if (!visibleFields.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field.required && !formState.values[field.flag]?.trim()) {
|
||||
setFormError(`${field.label} is required`);
|
||||
return;
|
||||
@@ -376,6 +427,11 @@ export function ChannelsContent() {
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -413,7 +469,9 @@ export function ChannelsContent() {
|
||||
}
|
||||
setFormState({
|
||||
channelId: value,
|
||||
values: {},
|
||||
values: initialValuesForChannel(
|
||||
channels.find((channel) => channel.id === value),
|
||||
),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
@@ -433,7 +491,7 @@ export function ChannelsContent() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedChannel?.fields.map((field) => (
|
||||
{visibleFields.map((field) => (
|
||||
<div className="grid gap-2" key={field.flag}>
|
||||
<Label>
|
||||
{field.label}
|
||||
@@ -441,7 +499,29 @@ export function ChannelsContent() {
|
||||
<span className="text-destructive"> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{isMultilineField(field) ? (
|
||||
{field.options ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
updateFieldValue(field.flag, value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
formState.values[field.flag] ?? field.initialValue ?? ""
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isMultilineField(field) ? (
|
||||
<Textarea
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SettingsSection = (typeof navCategories)[number];
|
||||
type Theme = "dark" | "light";
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
@@ -525,20 +526,29 @@ function GeneralSettingsContent({
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
const [telemetryError, setTelemetryError] = useState<string | null>(null);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true);
|
||||
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
|
||||
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
|
||||
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
|
||||
|
||||
const loadGlobalSettings = useCallback(async () => {
|
||||
setTelemetryLoading(true);
|
||||
setTelemetryError(null);
|
||||
setAutoUpdateLoading(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -571,6 +581,28 @@ function GeneralSettingsContent({
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoUpdateEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = autoUpdateEnabled;
|
||||
setAutoUpdateEnabled(nextValue);
|
||||
setAutoUpdateSaving(true);
|
||||
setAutoUpdateError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_auto_update_enabled",
|
||||
{
|
||||
auto_update_enabled: nextValue,
|
||||
},
|
||||
);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAutoUpdateEnabled(previousValue);
|
||||
setAutoUpdateError(message);
|
||||
} finally {
|
||||
setAutoUpdateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
@@ -605,6 +637,29 @@ function GeneralSettingsContent({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Auto update</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Automatically install CLI updates on startup.
|
||||
</p>
|
||||
{autoUpdateError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update auto update setting: {autoUpdateError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Auto update"
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={autoUpdateLoading || autoUpdateSaving}
|
||||
onCheckedChange={(checked) =>
|
||||
void updateAutoUpdateEnabled(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
|
||||
@@ -21,17 +21,10 @@ import {
|
||||
type ToolPolicy,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type AgentMessage,
|
||||
type AgentMessagePart,
|
||||
type AgentModelEvent,
|
||||
type AgentTool,
|
||||
buildClineSystemPrompt,
|
||||
createClineTelemetryServiceConfig,
|
||||
createClineTelemetryServiceMetadata,
|
||||
estimateTokens,
|
||||
type GatewayProviderContext,
|
||||
type GatewayProviderRegistration,
|
||||
type GatewayStreamRequest,
|
||||
} from "@cline/shared";
|
||||
import * as vscode from "vscode";
|
||||
import { displayName, version } from "../package.json";
|
||||
@@ -41,7 +34,6 @@ import type {
|
||||
WebviewChatMessage,
|
||||
WebviewInboundMessage,
|
||||
WebviewOutboundMessage,
|
||||
WebviewProviderModel,
|
||||
WebviewSessionSummary,
|
||||
} from "./webview-protocol";
|
||||
|
||||
@@ -52,9 +44,6 @@ const HUB_POLL_INTERVAL_MS = 200;
|
||||
const TERMINAL_SHELL_INTEGRATION_TIMEOUT_MS = 5_000;
|
||||
const TERMINAL_EXECUTION_TIMEOUT_MS = 120_000;
|
||||
const TERMINAL_OUTPUT_LIMIT = 1_000_000;
|
||||
const GITHUB_COPILOT_PROVIDER_ID = "github-copilot";
|
||||
const GITHUB_COPILOT_AUTO_MODEL_ID = "copilot-auto";
|
||||
const VSCODE_EXTENSION_HUB_OWNER_LABEL = `vscode-extension:${process.pid}`;
|
||||
const REFRESH_SESSION_EVENTS = new Set([
|
||||
"session.created",
|
||||
"session.updated",
|
||||
@@ -69,11 +58,9 @@ const REFRESH_SESSION_EVENTS = new Set([
|
||||
let extensionTelemetryHandle:
|
||||
| ReturnType<typeof createVscodeTelemetry>
|
||||
| undefined;
|
||||
let githubCopilotProviderRegistered = false;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const outputChannel = vscode.window.createOutputChannel("Cline");
|
||||
registerGitHubCopilotProvider();
|
||||
extensionTelemetryHandle = createVscodeTelemetry({
|
||||
extensionVersion: version,
|
||||
clineType: displayName,
|
||||
@@ -240,310 +227,6 @@ function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function stringifyAgentPart(part: AgentMessagePart): string {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return part.text;
|
||||
case "reasoning":
|
||||
return part.text;
|
||||
case "file":
|
||||
return `<file path="${part.path}">\n${part.content}\n</file>`;
|
||||
case "image":
|
||||
return "[Image attachment omitted: VS Code Language Model API text request]";
|
||||
case "tool-call":
|
||||
return JSON.stringify({
|
||||
type: "tool_call",
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: part.input,
|
||||
});
|
||||
case "tool-result":
|
||||
return stringifyContent(part.output);
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyAgentMessage(message: AgentMessage): string {
|
||||
return message.content.map((part) => stringifyAgentPart(part)).join("");
|
||||
}
|
||||
|
||||
function toVsCodeSystemMessage(
|
||||
systemPrompt: string | undefined,
|
||||
): vscode.LanguageModelChatMessage[] {
|
||||
const trimmed = systemPrompt?.trim();
|
||||
return trimmed
|
||||
? [vscode.LanguageModelChatMessage.User(`[System]\n${trimmed}`)]
|
||||
: [];
|
||||
}
|
||||
|
||||
async function listGitHubCopilotModels(): Promise<WebviewProviderModel[]> {
|
||||
const models = await vscode.lm.selectChatModels({ vendor: "copilot" });
|
||||
if (!models.length) {
|
||||
return [
|
||||
{
|
||||
id: GITHUB_COPILOT_AUTO_MODEL_ID,
|
||||
name: "Copilot Auto",
|
||||
supportsThinking: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return models
|
||||
.map((model) => ({
|
||||
id: model.id || GITHUB_COPILOT_AUTO_MODEL_ID,
|
||||
name: model.name || model.id || "Copilot Auto",
|
||||
supportsThinking: false,
|
||||
supportsReasoning: false,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getRegisteredGatewayProvider(
|
||||
providerId: string,
|
||||
): GatewayProviderRegistration | undefined {
|
||||
return Llms.getRegisteredGatewayProviders().find(
|
||||
(provider) => provider.manifest.id === providerId,
|
||||
);
|
||||
}
|
||||
|
||||
function listGatewayProviderModels(
|
||||
registration: GatewayProviderRegistration,
|
||||
): WebviewProviderModel[] {
|
||||
const manifestModels =
|
||||
registration.manifest.models.length > 0
|
||||
? registration.manifest.models
|
||||
: [
|
||||
{
|
||||
id: registration.manifest.defaultModelId,
|
||||
name: registration.manifest.defaultModelId,
|
||||
providerId: registration.manifest.id,
|
||||
capabilities: registration.manifest.capabilities?.includes("tools")
|
||||
? (["text", "tools"] as const)
|
||||
: (["text"] as const),
|
||||
},
|
||||
];
|
||||
return manifestModels
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
supportsReasoning: model.capabilities?.includes("reasoning"),
|
||||
supportsThinking: model.capabilities?.includes("reasoning"),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function toVsCodeLanguageModelMessage(
|
||||
message: AgentMessage,
|
||||
): vscode.LanguageModelChatMessage {
|
||||
if (message.role === "assistant") {
|
||||
const content: Array<
|
||||
vscode.LanguageModelTextPart | vscode.LanguageModelToolCallPart
|
||||
> = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-call") {
|
||||
content.push(
|
||||
new vscode.LanguageModelToolCallPart(
|
||||
part.toolCallId,
|
||||
part.toolName,
|
||||
(part.input && typeof part.input === "object"
|
||||
? part.input
|
||||
: { input: part.input }) as object,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (part.type !== "tool-result") {
|
||||
content.push(
|
||||
new vscode.LanguageModelTextPart(stringifyAgentPart(part)),
|
||||
);
|
||||
}
|
||||
}
|
||||
return vscode.LanguageModelChatMessage.Assistant(content);
|
||||
}
|
||||
|
||||
const content: Array<
|
||||
vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart
|
||||
> = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-result") {
|
||||
content.push(
|
||||
new vscode.LanguageModelToolResultPart(part.toolCallId, [
|
||||
new vscode.LanguageModelTextPart(stringifyContent(part.output)),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (part.type !== "tool-call") {
|
||||
content.push(new vscode.LanguageModelTextPart(stringifyAgentPart(part)));
|
||||
}
|
||||
}
|
||||
return vscode.LanguageModelChatMessage.User(content);
|
||||
}
|
||||
|
||||
async function selectGitHubCopilotModel(
|
||||
modelId: string,
|
||||
): Promise<vscode.LanguageModelChat> {
|
||||
const selector =
|
||||
modelId && modelId !== GITHUB_COPILOT_AUTO_MODEL_ID
|
||||
? { vendor: "copilot", id: modelId }
|
||||
: { vendor: "copilot" };
|
||||
const models = await vscode.lm.selectChatModels(selector);
|
||||
const model = models[0];
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
"GitHub Copilot chat models are not available. Install or enable GitHub Copilot Chat, then sign in.",
|
||||
);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async function* streamGitHubCopilotRequest(
|
||||
request: GatewayStreamRequest,
|
||||
context: GatewayProviderContext,
|
||||
): AsyncIterable<AgentModelEvent> {
|
||||
const model = await selectGitHubCopilotModel(request.modelId);
|
||||
const tokenSource = new vscode.CancellationTokenSource();
|
||||
const abortListener = () => tokenSource.cancel();
|
||||
request.signal?.addEventListener("abort", abortListener, { once: true });
|
||||
|
||||
const messages = [
|
||||
...toVsCodeSystemMessage(request.systemPrompt),
|
||||
...request.messages.map((message) => toVsCodeLanguageModelMessage(message)),
|
||||
];
|
||||
const inputTokenSource = [
|
||||
request.systemPrompt ?? "",
|
||||
...request.messages.map((message) => stringifyAgentMessage(message)),
|
||||
JSON.stringify(request.tools ?? []),
|
||||
].join("\n");
|
||||
const inputTokens = estimateTokens(inputTokenSource.length);
|
||||
let outputChars = 0;
|
||||
let sawToolCall = false;
|
||||
|
||||
const recordOutputChars = (value: unknown): void => {
|
||||
outputChars += stringifyContent(value).length;
|
||||
};
|
||||
|
||||
const finishReason = (): AgentModelEvent => ({
|
||||
type: "finish",
|
||||
reason: sawToolCall ? "tool-calls" : "stop",
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await model.sendRequest(
|
||||
messages,
|
||||
{
|
||||
justification: `Cline would like to use '${model.name}' from GitHub Copilot.`,
|
||||
tools: request.tools?.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
toolMode: request.tools?.length
|
||||
? vscode.LanguageModelChatToolMode.Auto
|
||||
: undefined,
|
||||
},
|
||||
tokenSource.token,
|
||||
);
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
if (chunk instanceof vscode.LanguageModelTextPart) {
|
||||
outputChars += chunk.value.length;
|
||||
yield { type: "text-delta", text: chunk.value };
|
||||
continue;
|
||||
}
|
||||
if (chunk instanceof vscode.LanguageModelToolCallPart) {
|
||||
sawToolCall = true;
|
||||
recordOutputChars({
|
||||
callId: chunk.callId,
|
||||
name: chunk.name,
|
||||
input: chunk.input,
|
||||
});
|
||||
yield {
|
||||
type: "tool-call-delta",
|
||||
toolCallId: chunk.callId,
|
||||
toolName: chunk.name,
|
||||
input: chunk.input,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens,
|
||||
outputTokens: outputChars > 0 ? estimateTokens(outputChars) : 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
};
|
||||
yield finishReason();
|
||||
} catch (error) {
|
||||
context.logger?.log("GitHub Copilot provider request failed", {
|
||||
severity: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (error instanceof vscode.CancellationError) {
|
||||
yield { type: "finish", reason: "aborted" };
|
||||
return;
|
||||
}
|
||||
yield {
|
||||
type: "finish",
|
||||
reason: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
request.signal?.removeEventListener("abort", abortListener);
|
||||
tokenSource.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function registerGitHubCopilotProvider(): void {
|
||||
if (githubCopilotProviderRegistered) return;
|
||||
githubCopilotProviderRegistered = true;
|
||||
|
||||
const registration: GatewayProviderRegistration = {
|
||||
manifest: {
|
||||
id: GITHUB_COPILOT_PROVIDER_ID,
|
||||
name: "GitHub Copilot",
|
||||
description: "GitHub Copilot through VS Code's Language Model API.",
|
||||
defaultModelId: GITHUB_COPILOT_AUTO_MODEL_ID,
|
||||
env: ["node"],
|
||||
capabilities: ["tools"],
|
||||
models: [
|
||||
{
|
||||
id: GITHUB_COPILOT_AUTO_MODEL_ID,
|
||||
name: "Copilot Auto",
|
||||
providerId: GITHUB_COPILOT_PROVIDER_ID,
|
||||
capabilities: ["text", "tools"],
|
||||
},
|
||||
],
|
||||
},
|
||||
createProvider: () => ({
|
||||
stream: streamGitHubCopilotRequest,
|
||||
}),
|
||||
};
|
||||
|
||||
Llms.registerProvider({
|
||||
provider: {
|
||||
id: GITHUB_COPILOT_PROVIDER_ID,
|
||||
name: "GitHub Copilot",
|
||||
description: "GitHub Copilot through VS Code's Language Model API.",
|
||||
defaultModelId: GITHUB_COPILOT_AUTO_MODEL_ID,
|
||||
client: "custom",
|
||||
source: "system",
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
models: {
|
||||
[GITHUB_COPILOT_AUTO_MODEL_ID]: {
|
||||
id: GITHUB_COPILOT_AUTO_MODEL_ID,
|
||||
name: "Copilot Auto",
|
||||
capabilities: ["streaming", "tools"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Llms.registerGatewayProvider(registration);
|
||||
}
|
||||
|
||||
function readTerminalCommandInput(input: unknown): {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
@@ -1015,11 +698,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private async discoverOrStartHub(): Promise<HubResolution | undefined> {
|
||||
// VS Code LM providers must execute inside the extension host because
|
||||
// `vscode.lm` is not available to detached hub daemon processes.
|
||||
const owner = resolveSharedHubOwnerContext(
|
||||
VSCODE_EXTENSION_HUB_OWNER_LABEL,
|
||||
);
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
|
||||
if (this.hubUrl) {
|
||||
const healthy = await probeHubServer(this.hubUrl);
|
||||
@@ -1170,29 +849,16 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
|
||||
private async loadProviders(preferredProvider?: string): Promise<void> {
|
||||
const state = this.providerSettingsManager.read();
|
||||
const gatewayProviders = Llms.getRegisteredGatewayProviders();
|
||||
const gatewayProvidersById = new Map(
|
||||
gatewayProviders.map((provider) => [provider.manifest.id, provider]),
|
||||
);
|
||||
const ids = Array.from(
|
||||
new Set([
|
||||
...Llms.getProviderIds(),
|
||||
...gatewayProviders.map((provider) => provider.manifest.id),
|
||||
]),
|
||||
).sort((a, b) => a.localeCompare(b));
|
||||
const ids = Llms.getProviderIds().sort((a, b) => a.localeCompare(b));
|
||||
const providers: ProviderListItem[] = (
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const info = await Llms.getProvider(id);
|
||||
const gatewayProvider = gatewayProvidersById.get(id);
|
||||
return {
|
||||
id,
|
||||
name: info?.name ?? gatewayProvider?.manifest.name ?? id,
|
||||
enabled:
|
||||
gatewayProvidersById.has(id) ||
|
||||
Boolean(state.providers[id]?.settings),
|
||||
defaultModelId:
|
||||
info?.defaultModelId ?? gatewayProvider?.manifest.defaultModelId,
|
||||
name: info?.name ?? id,
|
||||
enabled: Boolean(state.providers[id]?.settings),
|
||||
defaultModelId: info?.defaultModelId,
|
||||
};
|
||||
}),
|
||||
)
|
||||
@@ -1211,20 +877,6 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
private async loadModels(providerId: string): Promise<void> {
|
||||
const provider = providerId.trim();
|
||||
if (!provider) return;
|
||||
const gatewayProvider = getRegisteredGatewayProvider(provider);
|
||||
if (provider === GITHUB_COPILOT_PROVIDER_ID) {
|
||||
const models = await listGitHubCopilotModels();
|
||||
await this.post({ type: "models", providerId: provider, models });
|
||||
return;
|
||||
}
|
||||
if (gatewayProvider) {
|
||||
await this.post({
|
||||
type: "models",
|
||||
providerId: provider,
|
||||
models: listGatewayProviderModels(gatewayProvider),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const modelMap = (await Llms.getModelsForProvider(provider)) as Record<
|
||||
string,
|
||||
LlmModelInfo
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user