mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b496c5a1f3 | |||
| 2fc06ce446 | |||
| 64c50380f3 | |||
| e36197d911 | |||
| 52d187578e | |||
| a6239c420c | |||
| c7c5e6518b | |||
| 98c0717302 | |||
| b4aed24ff8 | |||
| e9ec82d2ef | |||
| e3ff875e09 | |||
| dc175c73a8 | |||
| d91f1ce166 | |||
| bc5a2e85a4 | |||
| 9c56a726a7 | |||
| 72c9bbfcaa | |||
| 53a7c3e80e | |||
| 8751daaba9 | |||
| aa1fffbe80 | |||
| 283c3ba937 | |||
| 69c9a9ac28 | |||
| 4d238558d0 | |||
| 51e5daf8eb | |||
| 28ef954258 | |||
| 9c4841ea07 | |||
| 1bd200e906 | |||
| fabbc144d6 | |||
| 372f029343 | |||
| 9d9b7aa6f8 | |||
| 8c7095b805 | |||
| 05535e844c | |||
| c3dc4a95bc | |||
| 9466fcc018 | |||
| dd7a1c5fa6 | |||
| fe75eb6271 | |||
| e4091c3686 | |||
| bcf8c1d03e | |||
| 5099eed5ee | |||
| b4c640733b | |||
| 40ba9d25eb | |||
| adb8ee0c4d | |||
| 01617f9a05 | |||
| b72d7f1a6f | |||
| d5b966d0a8 | |||
| 1bb0833287 | |||
| 2c64c4ce5b | |||
| 0bdf2e3468 | |||
| 98024c4243 | |||
| 359e771ab3 | |||
| a21b21de84 | |||
| ca6f6a6c23 | |||
| 3e06abc366 | |||
| 6518b05b6c | |||
| f9f492319e | |||
| c3ab557194 | |||
| 9fa37a9128 | |||
| 9cc7796bbe | |||
| 439baee17c | |||
| 469debdb30 | |||
| 79589c8417 | |||
| 8e5471de9c | |||
| cd553d2343 | |||
| 7b776225b9 | |||
| ae033761ab | |||
| c961ae7730 | |||
| e940b6a335 | |||
| 5a0780a6f9 | |||
| 847276f4a5 | |||
| 8b4d1973b5 | |||
| a5211a3a5c | |||
| fb1333fdc2 | |||
| 59113c309c | |||
| 48d0c38f52 | |||
| c7ab9ff839 | |||
| 045518d19f | |||
| 099c6179e4 | |||
| 26037b17ac | |||
| 1adce5e56d | |||
| 73583ea178 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
|
||||
@@ -0,0 +1,60 @@
|
||||
# Some coding-agent GitHub Apps advertise themselves by auto-commenting on every
|
||||
# new PR ("<Tool> Agent can help with this pull request. Just @<tool> ..."). The
|
||||
# app needs pull_requests:write for its real job (pushing branches, opening PRs),
|
||||
# and GitHub offers no per-behavior control over an installed App, so the ad
|
||||
# cannot be disabled at the source. This deletes those promo comments as they
|
||||
# appear. Genuine agent output comments (work results, reviews) don't match the
|
||||
# promo pattern and are left alone.
|
||||
#
|
||||
# No checkout, API-calls-only — comment text is only ever handled as data inside
|
||||
# the script, never interpolated into the workflow definition.
|
||||
name: repo-delete-agent-promo-comments
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
delete:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# Prefilter so a runner only spins up for bot comments that look like the
|
||||
# ad; the script re-verifies before deleting.
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
endsWith(github.event.comment.user.login, '[bot]') &&
|
||||
contains(github.event.comment.body, 'can help with this pull request')
|
||||
# Comment deletion goes through the issues API, but GitHub gates the
|
||||
# endpoint by where the comment lives: issue comments need `issues`,
|
||||
# PR-conversation comments need `pull-requests`. The prefilter restricts
|
||||
# this job to PR comments, so pull-requests is the one that matters;
|
||||
# issues is kept in case the prefilter is ever widened.
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
|
||||
# write permissions and fires on attacker-postable events.
|
||||
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment
|
||||
|
||||
// Belt and suspenders on top of the job-level prefilter: only
|
||||
// delete when the author is a real GitHub App bot AND the body
|
||||
// matches the self-promotion shape ("... can help with this
|
||||
// pull request. Just @<handle> ..."). A human quoting the ad
|
||||
// text is not a Bot; a bot posting real work output doesn't
|
||||
// match the promo shape.
|
||||
const isBot = comment.user.type === "Bot"
|
||||
const isPromo = /\bcan help with this pull request\b[\s\S]*@\w/i.test(comment.body || "")
|
||||
|
||||
if (!isBot || !isPromo) {
|
||||
core.info("not an agent promo comment, leaving it alone")
|
||||
return
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
...context.repo,
|
||||
comment_id: comment.id,
|
||||
})
|
||||
core.info(`deleted promo comment ${comment.id} by ${comment.user.login} on #${context.payload.issue.number}`)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Cloud coding agents append promotional badge blocks to PR bodies after the
|
||||
# agent's final turn, wrapped around <!-- <VENDOR>_AGENT_PR_BODY_BEGIN/END -->
|
||||
# marker comments. The agent itself never sees that content, so no repo rule or
|
||||
# agent instruction can prevent it. This strips it from the PR description on
|
||||
# open/edit, keeping only the agent-authored content between the markers.
|
||||
#
|
||||
# Uses pull_request_target so the token has write access on PRs from forks. That
|
||||
# trigger is only unsafe when a job checks out and executes PR code — this one
|
||||
# never checks out the repository, it only calls the REST API.
|
||||
name: repo-strip-agent-badges
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
concurrency:
|
||||
group: strip-agent-badges-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
strip:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.body, '_AGENT_PR_BODY')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
|
||||
# write permissions under pull_request_target.
|
||||
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
// Re-fetch instead of trusting the event payload: the body may have
|
||||
// been edited again between the event firing and this run (agent
|
||||
// harnesses edit PR bodies post-open), and updating from the stale
|
||||
// snapshot would clobber the newer content.
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
})
|
||||
const body = pr.body || ""
|
||||
|
||||
// The BEGIN/END comments wrap the agent-authored content; everything
|
||||
// outside them (vendor promo badges, "open in <tool>" links) is
|
||||
// appended by the harness. Keep only what's between the markers.
|
||||
// The backreference requires BEGIN and END to name the same vendor.
|
||||
// No markers -> no match -> body passes through unchanged.
|
||||
const cleaned = body
|
||||
.replace(
|
||||
/^[\s\S]*?<!--\s*([A-Z][A-Z0-9_]*?)_AGENT_PR_BODY_BEGIN\s*-->\r?\n?([\s\S]*?)<!--\s*\1_AGENT_PR_BODY_END\s*-->[\s\S]*$/,
|
||||
"$2",
|
||||
)
|
||||
.trimEnd()
|
||||
|
||||
// No change means a previous run already cleaned this body. Returning
|
||||
// without an update is what stops `edited` from retriggering forever.
|
||||
if (cleaned === body) {
|
||||
core.info("nothing to strip")
|
||||
return
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
...context.repo,
|
||||
pull_number: pr.number,
|
||||
body: cleaned,
|
||||
})
|
||||
core.info(`stripped ${body.length - cleaned.length} chars from PR #${pr.number}`)
|
||||
Vendored
+4
-2
@@ -51,7 +51,8 @@
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"CLINE_ENVIRONMENT": "staging"
|
||||
"CLINE_ENVIRONMENT": "staging",
|
||||
"CLINE_DIR": "${userHome}/.cline_staging"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -75,7 +76,8 @@
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
"CLINE_ENVIRONMENT": "local",
|
||||
"CLINE_DIR": "${userHome}/.cline_local"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
This is the **Cline** monorepo. Toolchain is **Bun 1.3.13** (package manager + task runner) with **Node >=22** as the runtime. Do not use npm/yarn/pnpm.
|
||||
|
||||
## Cloud Agent Instructions
|
||||
|
||||
### Cline CLI
|
||||
- Run from source: `bun run cli` (interactive: `bun run cli -i`; one-shot: append a prompt). This resolves to `apps/cli` and **auto-spawns the `@cline/cline-hub` daemon** — you do not start the hub separately.
|
||||
- Inspect local health with `bun run cli doctor`; `bun run cli version` prints the version.
|
||||
- An actual agent turn requires an **LLM provider credential**. With no credentials the default `cline` provider fails fast with an `Unauthorized` error and the interactive TUI shows a provider sign-in screen. Configure via `cline auth` or provider env vars (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`, `OPENROUTER_API_KEY`); see `apps/cli/README.md`.
|
||||
|
||||
### Build / Lint / test
|
||||
- SDK packages (`@cline/shared|llms|agents|core|sdk`) resolve each other through compiled `dist/` (their `exports` point only at `dist/`, with no `development` source condition). You **must** run `bun run build:sdk` after changing SDK dependencies/source before running the CLI or SDK tests, otherwise imports fail with missing `@cline/*` / missing `dist/` errors. Running processes do **not** hot-reload SDK source changes — rebuild and restart.\
|
||||
- Known cloud-env test artifact: `@cline/core` test `src/services/workspace/workspace-manifest.test.ts > readGitWorkspaceState > prefers origin and returns the current branch` fails because cloud VMs configure git `insteadOf` rules that rewrite GitHub remotes to `https://x-access-token:...@github.com/...`. This is an environment artifact, not a code bug.
|
||||
- Some `@cline/cli` e2e assertions (`bun -F @cline/cli test:e2e`) may fail on exact tool-listing string formats; treat as pre-existing test drift, not an environment problem.
|
||||
|
||||
### GUI display
|
||||
- A virtual X display is live at **`DISPLAY=:1`** (the same desktop used for screenshots). GUI apps (VS Code, the Tauri desktop window) launched with `DISPLAY=:1` render there and can be screenshotted — no need to start your own `xvfb`. Prefer starting long-running GUI/dev processes in a `tmux` session (see the tmux guidance) so they survive.
|
||||
|
||||
### VS Code extension (`apps/vscode`, package `claude-dev`)
|
||||
Toolchain is pre-installed and persisted in the VM: generated gRPC/proto code, the bundled `ripgrep` binaries (`apps/vscode/bin/`), the built webview (`webview-ui/build`), the esbuild bundle (`dist/extension.js`), VS Code itself (`/usr/bin/code`), and the GUI system libraries its tests need.
|
||||
- **Codegen prerequisite:** `bun run protos` (from `apps/vscode`) regenerates `src/generated/*` and the webview grpc client. The `dev`, `build:webview`, and `check-types` scripts already run it, so proto changes are picked up by those commands; run it manually only if you edit `.proto` files without a full build.
|
||||
- **Build:** `bun run build:webview` (webview UI, ~15s) then `bun esbuild.mjs` (extension bundle). `bun run package` does the full production build.
|
||||
- **Run it (dev host):** `DISPLAY=:1 code --no-sandbox --user-data-dir=/tmp/vscode-userdata --extensionDevelopmentPath=/workspace/apps/vscode <some-folder>`, then click the Cline icon in the Activity Bar to open the webview. (`--no-sandbox` is required in this container.)
|
||||
- **Test:** `bun run test:unit` (bun-based, ~984 tests, no VS Code host needed). `bun run test:integration` (`@vscode/test-electron`, downloads a VS Code build, runs under the GUI libs) and `bun run test:e2e` (Playwright) exercise a real extension host — heavier, and the GUI libs for them are already installed.
|
||||
- One-time deps (already installed, listed here in case they must be recreated): ripgrep via `bun run download-ripgrep`; VS Code test GUI libs per `CONTRIBUTING.md` (`libnss3`, `libatk*`, `libgbm1`, `xvfb`, etc.).
|
||||
|
||||
### Desktop app (`apps/examples/desktop-app`, package `@cline/code`)
|
||||
A Tauri v2 (Rust) shell + Next.js webview + a Bun "sidecar" backend. Rust and the Tauri Linux system libs are pre-installed and persisted.
|
||||
- **Headless (no Rust/window):** run the backend and UI separately — `bun run dev:sidecar` (Bun backend on `127.0.0.1:3126`, serves `ws://.../transport`) and `bun run dev:web` (Next.js UI on `http://localhost:3125`).
|
||||
- **Native window:** `bun run dev` (`tauri dev`) — its `beforeDevCommand` builds the sidecar binary and starts `dev:web` (`:3125`), then Rust `main.rs` spawns the sidecar; so free ports `3125`/`3126` first. Launch with `DISPLAY=:1` to see the window. A `libEGL: DRI3 error` warning is benign (software rendering) — the WebKitGTK window still renders.
|
||||
- **Rust version caveat:** the crate graph needs Cargo's `edition2024` feature, so **Rust ≥1.85** is required (the VM's base 1.83 fails with "feature `edition2024` is required"). The toolchain here was updated via `rustup default stable` (currently 1.97). First `cargo` build downloads/compiles the full Tauri crate graph (a few minutes); subsequent builds are cached.
|
||||
- **System libs (already installed):** `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `build-essential`.
|
||||
- **Test/typecheck:** `bun run typecheck`, `bun run test:chat-ui` (Vitest). Both trigger `build:ui` first.
|
||||
@@ -226,7 +226,7 @@ Run Cline with zero interaction for scripting and automation. Pipe input, get JS
|
||||
|
||||
```bash
|
||||
cline "Run tests and fix any failures"
|
||||
git diff origin/main | cline "Review these changes for issues"
|
||||
git diff origin/main | cline "Review these changes for issues"
|
||||
cline --json "List all TODO comments" | jq -r 'select(.type == "agent_event" and .event.text) | .event.text'
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.47
|
||||
|
||||
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
|
||||
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
|
||||
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
|
||||
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
|
||||
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
|
||||
- Aborting a task no longer risks killing the shared hub daemon
|
||||
- Connector status delivery failures are no longer fatal to the turn
|
||||
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
|
||||
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
|
||||
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
|
||||
- Updated the bundled model catalog (from SDK v0.0.66)
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
|
||||
| `--acp` | ACP (Agent Client Protocol) mode |
|
||||
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.46",
|
||||
"version": "3.0.47",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
} from "../connectors/common";
|
||||
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
|
||||
import {
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
stopAllConnectors,
|
||||
} from "./connect";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disableConnectorAutostart: vi.fn(),
|
||||
getPersistedConnectorConnection: vi.fn(),
|
||||
getConnector: vi.fn(),
|
||||
listActiveConnectors: vi.fn(),
|
||||
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
|
||||
persistConnectorConnection: vi.fn(),
|
||||
removePersistedConnectorConnection: vi.fn(),
|
||||
run: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
disableConnectorAutostart: mocks.disableConnectorAutostart,
|
||||
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
|
||||
listActiveConnectors: mocks.listActiveConnectors,
|
||||
persistConnectorConnection: mocks.persistConnectorConnection,
|
||||
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/registry", () => ({
|
||||
getConnector: mocks.getConnector,
|
||||
listConnectors: mocks.listConnectors,
|
||||
}));
|
||||
|
||||
describe("runConnectAdapter", () => {
|
||||
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
|
||||
const io: ConnectIo = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listConnectors.mockReturnValue([]);
|
||||
mocks.listActiveConnectors.mockReturnValue([]);
|
||||
mocks.run.mockImplementation(
|
||||
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
|
||||
context.setPersistenceInstanceId("cline_bot");
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
mocks.validate.mockResolvedValue(0);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDetachedChild === undefined) {
|
||||
delete process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
|
||||
} else {
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = previousDetachedChild;
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a successful detached connector start", async () => {
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
["-k", "token"],
|
||||
);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a successful env-only connector start", async () => {
|
||||
await expect(runConnectAdapter("telegram", [], io)).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
[],
|
||||
);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists connector-resolved launch arguments", async () => {
|
||||
mocks.run.mockImplementation(
|
||||
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
|
||||
context.setPersistenceInstanceId("resolved_bot");
|
||||
context.setPersistenceArgs([
|
||||
"--bot-token",
|
||||
"token",
|
||||
"--bot-username",
|
||||
"resolved_bot",
|
||||
]);
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["--bot-token", "token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"resolved_bot",
|
||||
["--bot-token", "token", "--bot-username", "resolved_bot"],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite persistence when a connector is already running", async () => {
|
||||
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"-i",
|
||||
"--interactive",
|
||||
])("disables autostart after a successful %s foreground run exits", async (interactiveFlag) => {
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token", interactiveFlag], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not change persistence after a failed foreground run", async () => {
|
||||
mocks.run.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist a failed detached launch", async () => {
|
||||
mocks.run.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves persistence unchanged when an internal detached child exits", async () => {
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = "1";
|
||||
|
||||
await expect(
|
||||
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist help invocations", async () => {
|
||||
await expect(runConnectAdapter("telegram", ["--help"], io)).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves autostart unchanged during shared process cleanup", async () => {
|
||||
const stopAll = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
});
|
||||
mocks.listConnectors.mockReturnValue([
|
||||
{ name: "telegram", description: "Telegram" },
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
showHelp: vi.fn(),
|
||||
stopAll,
|
||||
});
|
||||
|
||||
await expect(stopAllConnectors(io)).resolves.toEqual({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
executed: 1,
|
||||
});
|
||||
|
||||
expect(stopAll).toHaveBeenCalledWith(io);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables autostart for an explicit stop-all command", async () => {
|
||||
const stopAll = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 2,
|
||||
});
|
||||
mocks.listConnectors.mockReturnValue([
|
||||
{ name: "telegram", description: "Telegram" },
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
showHelp: vi.fn(),
|
||||
stopAll,
|
||||
});
|
||||
|
||||
await expect(runStopAllConnectors(io)).resolves.toBe(0);
|
||||
|
||||
expect(stopAll).toHaveBeenCalledWith(io);
|
||||
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("validates a replacement before stopping the active instance", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.validate.mockResolvedValue(1);
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "bad-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.validate).toHaveBeenCalledWith(["-k", "bad-token"], io);
|
||||
expect(stopInstance).not.toHaveBeenCalled();
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows restart help without stopping an active instance", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(runRestartConnector("telegram", ["--help"], io)).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledWith(["--help"], io, expect.any(Object));
|
||||
expect(mocks.validate).not.toHaveBeenCalled();
|
||||
expect(stopInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the last successful launch when a replacement fails", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getPersistedConnectorConnection.mockReturnValue({
|
||||
channel: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
connectArgs: ["-k", "new-token"],
|
||||
lastSuccessfulArgs: ["-k", "old-token"],
|
||||
enabled: true,
|
||||
updatedAt: "2026-07-25T00:00:00.000Z",
|
||||
lastConnectedAt: "2026-07-25T00:00:00.000Z",
|
||||
});
|
||||
mocks.run
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockImplementationOnce(
|
||||
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
|
||||
context.setPersistenceInstanceId("cline_bot");
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
|
||||
expect(mocks.run).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
["-k", "new-token"],
|
||||
io,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.run).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
["-k", "old-token"],
|
||||
io,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
|
||||
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
"cline_bot",
|
||||
["-k", "old-token"],
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts an active instance without persisted rollback arguments", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getPersistedConnectorConnection.mockReturnValue(undefined);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
|
||||
expect(mocks.run).toHaveBeenCalledWith(
|
||||
["-k", "new-token"],
|
||||
io,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start a replacement when the active process cannot be stopped", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
|
||||
expect(mocks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not count an already-running instance as a successful replacement", async () => {
|
||||
const stopInstance = vi.fn().mockResolvedValue({
|
||||
stoppedProcesses: 1,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
mocks.listActiveConnectors.mockReturnValue([
|
||||
{
|
||||
id: "telegram:cline_bot",
|
||||
type: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
pid: 123,
|
||||
hubUrl: "ws://127.0.0.1:4317",
|
||||
botUsername: "cline_bot",
|
||||
},
|
||||
]);
|
||||
mocks.getPersistedConnectorConnection.mockReturnValue({
|
||||
channel: "telegram",
|
||||
instanceId: "cline_bot",
|
||||
connectArgs: ["-k", "new-token"],
|
||||
lastSuccessfulArgs: ["-k", "old-token"],
|
||||
enabled: true,
|
||||
updatedAt: "2026-07-25T00:00:00.000Z",
|
||||
lastConnectedAt: "2026-07-25T00:00:00.000Z",
|
||||
});
|
||||
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
mocks.getConnector.mockResolvedValue({
|
||||
name: "telegram",
|
||||
description: "Telegram",
|
||||
run: mocks.run,
|
||||
validate: mocks.validate,
|
||||
showHelp: vi.fn(),
|
||||
stopInstance,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRestartConnector("telegram", ["-k", "new-token"], io),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"[connect] replacement was not started because telegram instance cline_bot is still running",
|
||||
);
|
||||
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,29 @@
|
||||
import {
|
||||
disableConnectorAutostart,
|
||||
getPersistedConnectorConnection,
|
||||
listActiveConnectors,
|
||||
persistConnectorConnection,
|
||||
removePersistedConnectorConnection,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
} from "../connectors/common";
|
||||
import { getConnector, listConnectors } from "../connectors/registry";
|
||||
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
|
||||
import type {
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../connectors/types";
|
||||
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
|
||||
|
||||
export async function stopAllConnectors(
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult & { executed: number }> {
|
||||
let stoppedProcesses = 0;
|
||||
let failedProcesses = 0;
|
||||
let stoppedSessions = 0;
|
||||
let executed = 0;
|
||||
for (const entry of listConnectors()) {
|
||||
@@ -18,42 +37,209 @@ export async function stopAllConnectors(
|
||||
executed += 1;
|
||||
const result = await connector.stopAll(io);
|
||||
stoppedProcesses += result.stoppedProcesses;
|
||||
failedProcesses += result.failedProcesses;
|
||||
stoppedSessions += result.stoppedSessions;
|
||||
}
|
||||
return { stoppedProcesses, stoppedSessions, executed };
|
||||
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
|
||||
}
|
||||
|
||||
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
|
||||
const { stoppedProcesses, stoppedSessions, executed } =
|
||||
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
|
||||
await stopAllConnectors(io);
|
||||
if (executed === 0) {
|
||||
io.writeln("[connect] no adapters support stop yet");
|
||||
return 0;
|
||||
}
|
||||
disableConnectorAutostart();
|
||||
io.writeln(
|
||||
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
|
||||
`[connect] stopped processes=${stoppedProcesses} failed=${failedProcesses} sessions=${stoppedSessions}`,
|
||||
);
|
||||
return 0;
|
||||
return failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runStopConnector(
|
||||
adapterName: string,
|
||||
io: ConnectIo,
|
||||
options: {
|
||||
autostart: "disable" | "preserve";
|
||||
instanceId?: string;
|
||||
} = {
|
||||
autostart: "disable",
|
||||
},
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
if (!connector.stopAll) {
|
||||
const stop = options.instanceId
|
||||
? connector.stopInstance
|
||||
? () => connector.stopInstance?.(options.instanceId ?? "", io)
|
||||
: undefined
|
||||
: connector.stopAll
|
||||
? () => connector.stopAll?.(io)
|
||||
: undefined;
|
||||
if (!stop) {
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
return 1;
|
||||
}
|
||||
const result: ConnectStopResult = await connector.stopAll(io);
|
||||
const result = await stop();
|
||||
if (!result) {
|
||||
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
|
||||
return 1;
|
||||
}
|
||||
if (options.autostart === "disable") {
|
||||
disableConnectorAutostart(connector.name, options.instanceId);
|
||||
}
|
||||
io.writeln(
|
||||
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
|
||||
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
|
||||
);
|
||||
return 0;
|
||||
return result.failedProcesses === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
export async function runRestartConnector(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
requestedInstanceId?: string,
|
||||
): Promise<number> {
|
||||
if (passthroughArgs.some((arg) => HELP_FLAGS.has(arg))) {
|
||||
return await runConnectAdapter(adapterName, passthroughArgs, io);
|
||||
}
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
const activeInstances = listActiveConnectors().filter(
|
||||
(record) => record.type === adapterName,
|
||||
);
|
||||
if (!requestedInstanceId && activeInstances.length > 1) {
|
||||
io.writeErr(
|
||||
`cannot safely restart ${adapterName}: ${activeInstances.length} instances are active; specify an instance`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const instanceId = requestedInstanceId ?? activeInstances[0]?.instanceId;
|
||||
const targetIsActive =
|
||||
instanceId !== undefined &&
|
||||
activeInstances.some((record) => record.instanceId === instanceId);
|
||||
if (!targetIsActive || !instanceId) {
|
||||
return await runConnectAdapter(adapterName, passthroughArgs, io);
|
||||
}
|
||||
|
||||
const validationExitCode = await connector.validate(passthroughArgs, io);
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
const previousConnection = getPersistedConnectorConnection(
|
||||
adapterName,
|
||||
instanceId,
|
||||
);
|
||||
const stopExitCode = await runStopConnector(adapterName, io, {
|
||||
autostart: "preserve",
|
||||
instanceId,
|
||||
});
|
||||
if (stopExitCode !== 0) {
|
||||
return stopExitCode;
|
||||
}
|
||||
const replacement = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
passthroughArgs,
|
||||
io,
|
||||
);
|
||||
if (replacement.exitCode === 0) {
|
||||
if (replacement.instanceId && replacement.instanceId !== instanceId) {
|
||||
removePersistedConnectorConnection(adapterName, instanceId);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (replacement.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
|
||||
io.writeErr(
|
||||
`[connect] replacement was not started because ${adapterName} instance ${instanceId} is still running`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (!previousConnection) {
|
||||
io.writeErr(
|
||||
`[connect] replacement failed and ${adapterName} instance ${instanceId} has no successful launch arguments for rollback`,
|
||||
);
|
||||
return replacement.exitCode;
|
||||
}
|
||||
|
||||
io.writeErr(
|
||||
`[connect] replacement failed; restoring ${adapterName} instance ${instanceId}`,
|
||||
);
|
||||
const rollback = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
previousConnection.lastSuccessfulArgs,
|
||||
io,
|
||||
);
|
||||
if (rollback.exitCode === 0) {
|
||||
io.writeln(`[connect] restored ${adapterName} instance ${instanceId}`);
|
||||
} else {
|
||||
io.writeErr(
|
||||
`[connect] failed to restore ${adapterName} instance ${instanceId}`,
|
||||
);
|
||||
}
|
||||
return replacement.exitCode;
|
||||
}
|
||||
|
||||
interface ConnectAdapterResult {
|
||||
exitCode: number;
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
async function runConnectAdapterWithResult(
|
||||
adapterName: string,
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectAdapterResult> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return { exitCode: 1 };
|
||||
}
|
||||
let persistenceArgs = passthroughArgs;
|
||||
let persistenceInstanceId: string | undefined;
|
||||
const context: ConnectRunContext = {
|
||||
setPersistenceArgs: (args) => {
|
||||
persistenceArgs = [...args];
|
||||
},
|
||||
setPersistenceInstanceId: (instanceId) => {
|
||||
persistenceInstanceId = instanceId;
|
||||
},
|
||||
};
|
||||
const exitCode = await connector.run(passthroughArgs, io, context);
|
||||
if (exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
|
||||
return { exitCode, instanceId: persistenceInstanceId };
|
||||
}
|
||||
const isHelpInvocation = passthroughArgs.some((arg) => HELP_FLAGS.has(arg));
|
||||
const isInteractiveInvocation = passthroughArgs.some((arg) =>
|
||||
INTERACTIVE_FLAGS.has(arg),
|
||||
);
|
||||
const isDetachedChild =
|
||||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
|
||||
if (
|
||||
exitCode === 0 &&
|
||||
!isHelpInvocation &&
|
||||
!isDetachedChild &&
|
||||
isInteractiveInvocation
|
||||
) {
|
||||
disableConnectorAutostart(connector.name, persistenceInstanceId);
|
||||
} else if (
|
||||
exitCode === 0 &&
|
||||
!isHelpInvocation &&
|
||||
!isDetachedChild &&
|
||||
persistenceInstanceId
|
||||
) {
|
||||
persistConnectorConnection(
|
||||
connector.name,
|
||||
persistenceInstanceId,
|
||||
persistenceArgs,
|
||||
);
|
||||
}
|
||||
return { exitCode, instanceId: persistenceInstanceId };
|
||||
}
|
||||
|
||||
export async function runConnectAdapter(
|
||||
@@ -61,12 +247,14 @@ export async function runConnectAdapter(
|
||||
passthroughArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
const connector = await getConnector(adapterName);
|
||||
if (!connector) {
|
||||
io.writeErr(`unknown connect adapter "${adapterName}"`);
|
||||
return 1;
|
||||
}
|
||||
return connector.run(passthroughArgs, io);
|
||||
const result = await runConnectAdapterWithResult(
|
||||
adapterName,
|
||||
passthroughArgs,
|
||||
io,
|
||||
);
|
||||
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
|
||||
? 0
|
||||
: result.exitCode;
|
||||
}
|
||||
|
||||
export function formatAdapterList(): string {
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
mockClearHubDiscovery,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockEnsureFileExists,
|
||||
mockListActiveConnectors,
|
||||
mockStopAllConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
@@ -50,8 +51,10 @@ const {
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
mockEnsureFileExists: vi.fn(),
|
||||
mockListActiveConnectors: vi.fn(() => []),
|
||||
mockStopAllConnectors: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
executed: 0,
|
||||
})),
|
||||
@@ -70,6 +73,7 @@ vi.mock("@cline/core", () => ({
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
listActiveConnectors: mockListActiveConnectors,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/common", () => ({
|
||||
@@ -100,6 +104,7 @@ describe("runDoctorCommand", () => {
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
executed: 0,
|
||||
});
|
||||
@@ -283,6 +288,7 @@ describe("runDoctorCommand", () => {
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 5,
|
||||
executed: 3,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureFileExists,
|
||||
listActiveConnectors,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
@@ -11,15 +12,15 @@ import {
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
formatUptime,
|
||||
resolveClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
listActiveConnectors,
|
||||
} from "../connectors/status";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
|
||||
@@ -136,6 +136,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
interactive: !!opts.tui,
|
||||
outputMode: opts.json ? "json" : "text",
|
||||
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
|
||||
modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen),
|
||||
sandbox: !!opts.dataDir,
|
||||
acpMode: !!opts.acp,
|
||||
thinking: false,
|
||||
|
||||
@@ -148,8 +148,10 @@ export function isJsonPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".json");
|
||||
}
|
||||
|
||||
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
|
||||
if (raw === "act" || raw === "plan") {
|
||||
export function parseMode(
|
||||
raw: string | undefined,
|
||||
): "act" | "plan" | "yolo" | undefined {
|
||||
if (raw === "act" || raw === "plan" || raw === "yolo") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
mergeScheduleMetadata,
|
||||
parseJsonObjectFlag,
|
||||
parseList,
|
||||
parseMode,
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
@@ -63,8 +65,8 @@ export function registerScheduleCommands(
|
||||
.option("--disabled", "Create in disabled state")
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan>", "Execution mode")
|
||||
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
|
||||
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
|
||||
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
@@ -96,7 +98,7 @@ export function registerScheduleCommands(
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: opts.mode === "plan" ? "plan" : "act",
|
||||
mode: parseMode(opts.mode) ?? "yolo",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
modelSelection?.modelId ??
|
||||
parsed.modelId ??
|
||||
parsed.model ??
|
||||
"openai/gpt-5.3-codex",
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
).trim();
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -165,7 +166,10 @@ export function registerScheduleImportCommand(
|
||||
prompt: String(parsed.prompt ?? "").trim(),
|
||||
provider,
|
||||
model,
|
||||
mode: parsed.mode === "plan" ? "plan" : "act",
|
||||
mode:
|
||||
parseMode(
|
||||
typeof parsed.mode === "string" ? parsed.mode : undefined,
|
||||
) ?? "yolo",
|
||||
workspaceRoot,
|
||||
cwd: String(parsed.cwd ?? "").trim() || undefined,
|
||||
systemPrompt:
|
||||
@@ -229,7 +233,7 @@ export function registerScheduleUpdateCommand(
|
||||
.option("--enabled", "Enable the schedule")
|
||||
.option("--max-parallel <n>", "New max parallel executions")
|
||||
.option("--metadata-json <json>", "New metadata as JSON object")
|
||||
.option("--mode <act|plan>", "New execution mode")
|
||||
.option("--mode <act|plan|yolo>", "New execution mode")
|
||||
.option("--model <model>", "New model")
|
||||
.option("--name <name>", "New name")
|
||||
.option("--pause", "Pause the schedule")
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -937,9 +938,18 @@ class DiscordConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopDiscordConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async validateOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
if (!options.applicationId) {
|
||||
@@ -960,7 +970,16 @@ class DiscordConnector extends ConnectorBase<
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectDiscordOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.applicationId);
|
||||
const statePath = this.resolveConnectorStatePath(options.applicationId);
|
||||
const bindingsPath = this.resolveBindingsPath(options.applicationId);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -971,26 +990,24 @@ class DiscordConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<DiscordThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
|
||||
foregroundHint:
|
||||
"[discord] use `cline connect discord -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Discord connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
|
||||
foregroundHint:
|
||||
"[discord] use `cline connect discord -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Discord connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -409,11 +410,66 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopGoogleChatConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
private parseCredentials(
|
||||
options: ConnectGoogleChatOptions,
|
||||
):
|
||||
| { client_email: string; private_key: string; project_id?: string }
|
||||
| undefined {
|
||||
if (!options.credentialsJson) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(options.credentialsJson) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (
|
||||
typeof parsed.client_email !== "string" ||
|
||||
typeof parsed.private_key !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"credentials JSON must include string client_email and private_key fields",
|
||||
);
|
||||
}
|
||||
return {
|
||||
client_email: parsed.client_email,
|
||||
private_key: parsed.private_key,
|
||||
project_id:
|
||||
typeof parsed.project_id === "string" ? parsed.project_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected override async validateOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
try {
|
||||
this.parseCredentials(options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.writeErr(
|
||||
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectGoogleChatOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.userName);
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -424,26 +480,25 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<GoogleChatThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Google Chat connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Google Chat connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
@@ -452,38 +507,7 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
});
|
||||
const logger = createChatSdkLogger(loggerAdapter);
|
||||
const consoleLogger = new ConsoleLogger("info", "gchat-connect");
|
||||
let parsedCredentials:
|
||||
| { client_email: string; private_key: string; project_id?: string }
|
||||
| undefined;
|
||||
if (options.credentialsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(options.credentialsJson) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (
|
||||
typeof parsed.client_email !== "string" ||
|
||||
typeof parsed.private_key !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"credentials JSON must include string client_email and private_key fields",
|
||||
);
|
||||
}
|
||||
parsedCredentials = {
|
||||
client_email: parsed.client_email,
|
||||
private_key: parsed.private_key,
|
||||
project_id:
|
||||
typeof parsed.project_id === "string"
|
||||
? parsed.project_id
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
io.writeErr(
|
||||
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const parsedCredentials = this.parseCredentials(options);
|
||||
const endpointUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/gchat`;
|
||||
const gchat = createGoogleChatAdapter(
|
||||
parsedCredentials
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import { getConnectorSystemPrompt } from "./prompts";
|
||||
@@ -477,11 +478,23 @@ class LinearConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopLinearConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectLinearOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.userName);
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -492,25 +505,24 @@ class LinearConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<LinearThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[linear] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[linear] use `cline connect linear -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Linear connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[linear] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[linear] use `cline connect linear -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Linear connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -668,11 +669,23 @@ class SlackConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopSlackConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectSlackOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
context.setPersistenceInstanceId(options.userName);
|
||||
const statePath = this.resolveConnectorStatePath(options.userName);
|
||||
const bindingsPath = this.resolveBindingsPath(options.userName);
|
||||
const stateStorePath = this.resolveStateStorePath(options.userName);
|
||||
@@ -684,27 +697,26 @@ class SlackConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
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} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
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} mode=${options.connectionMode}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -2,9 +2,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectTelegramOptions } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnDetachedConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../common")>()),
|
||||
spawnDetachedConnector: mocks.spawnDetachedConnector,
|
||||
}));
|
||||
|
||||
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
(
|
||||
telegramConnector as unknown as {
|
||||
@@ -15,6 +25,11 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
const originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
const tempDataDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
});
|
||||
|
||||
function useTempClineDataDir(): string {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
|
||||
tempDataDirs.push(dataDir);
|
||||
@@ -153,7 +168,7 @@ describe("telegramConnector", () => {
|
||||
expect(options.botUsername).toBe("test_bot");
|
||||
});
|
||||
|
||||
it("does not call getMe when the token-only connector is already running", async () => {
|
||||
it("validates a token before reporting its connector as already running", async () => {
|
||||
const dataDir = useTempClineDataDir();
|
||||
const connectorDir = join(dataDir, "connectors", "telegram");
|
||||
mkdirSync(connectorDir, { recursive: true });
|
||||
@@ -167,26 +182,87 @@ describe("telegramConnector", () => {
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected getMe call");
|
||||
});
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
await expect(
|
||||
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => errors.push(text),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
telegramConnector.run(
|
||||
["--bot-token", "123:test", "--cwd", "/tmp/work"],
|
||||
{
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
{
|
||||
setPersistenceArgs: vi.fn(),
|
||||
setPersistenceInstanceId: vi.fn(),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual([
|
||||
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the resolved bot username in persistence args", async () => {
|
||||
const dataDir = useTempClineDataDir();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const setPersistenceArgs = vi.fn();
|
||||
const setPersistenceInstanceId = vi.fn();
|
||||
mocks.spawnDetachedConnector.mockImplementation(() => {
|
||||
const connectorDir = join(dataDir, "connectors", "telegram");
|
||||
mkdirSync(connectorDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(connectorDir, "resolved_bot.json"),
|
||||
JSON.stringify({
|
||||
botUsername: "resolved_bot",
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
return process.pid;
|
||||
});
|
||||
|
||||
await expect(
|
||||
telegramConnector.run(
|
||||
["--bot-token", "123:test", "--cwd", "/tmp/work"],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{ setPersistenceArgs, setPersistenceInstanceId },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(setPersistenceArgs).toHaveBeenCalledWith([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--bot-username",
|
||||
"resolved_bot",
|
||||
]);
|
||||
expect(setPersistenceInstanceId).toHaveBeenCalledWith("resolved_bot");
|
||||
expect(mocks.spawnDetachedConnector).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram bot username resolution", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
|
||||
import { isProcessRunning } from "../common";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
|
||||
import {
|
||||
type ActiveConnectorTurn,
|
||||
handleConnectorUserTurn,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -604,10 +605,37 @@ class TelegramConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopTelegramConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async validateOptions(
|
||||
options: ConnectTelegramOptions,
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
try {
|
||||
await resolveTelegramBotUsername({
|
||||
...options,
|
||||
botUsername: undefined,
|
||||
});
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
inputOptions: ConnectTelegramOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
if (
|
||||
!inputOptions.botUsername &&
|
||||
@@ -621,7 +649,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
io.writeln(
|
||||
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
|
||||
);
|
||||
return 0;
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
}
|
||||
let resolvedBotUsername: string;
|
||||
@@ -638,6 +666,8 @@ class TelegramConnector extends ConnectorBase<
|
||||
const backgroundArgs = inputOptions.botUsername
|
||||
? rawArgs
|
||||
: [...rawArgs, "--bot-username", resolvedBotUsername];
|
||||
context.setPersistenceArgs(backgroundArgs);
|
||||
context.setPersistenceInstanceId(options.botUsername);
|
||||
const statePath = this.resolveConnectorStatePath(options.botUsername);
|
||||
const bindingsPath = this.resolveBindingsPath(options.botUsername);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -648,26 +678,24 @@ class TelegramConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<TelegramThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs: backgroundArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
|
||||
foregroundHint:
|
||||
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch Telegram connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs: backgroundArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
|
||||
foregroundHint:
|
||||
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Telegram connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "../types";
|
||||
import {
|
||||
@@ -444,15 +445,27 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
);
|
||||
}
|
||||
|
||||
override async stopInstance(
|
||||
instanceId: string,
|
||||
io: ConnectIo,
|
||||
): Promise<ConnectStopResult> {
|
||||
return await this.stopWhatsAppConnectorInstance(
|
||||
this.resolveConnectorStatePath(instanceId),
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectWhatsAppOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
const instanceKey = resolveInstanceKey({
|
||||
phoneNumberId: options.phoneNumberId,
|
||||
userName: options.userName,
|
||||
});
|
||||
context.setPersistenceInstanceId(instanceKey);
|
||||
const statePath = this.resolveConnectorStatePath(instanceKey);
|
||||
const bindingsPath = this.resolveBindingsPath(instanceKey);
|
||||
const staleState = this.removeStaleState(
|
||||
@@ -463,26 +476,24 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
if (staleState) {
|
||||
clearBindingSessionIds<WhatsAppThreadState>(bindingsPath);
|
||||
}
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
|
||||
launchFailureMessage:
|
||||
"failed to launch WhatsApp connector in background",
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
const backgroundExitCode = await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
|
||||
statePath,
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch WhatsApp connector in background",
|
||||
});
|
||||
if (backgroundExitCode !== undefined) {
|
||||
return backgroundExitCode;
|
||||
}
|
||||
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConnectorBase } from "./base";
|
||||
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "./common";
|
||||
import type { ConnectIo } from "./types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isProcessRunning: vi.fn(),
|
||||
spawnDetachedConnector: vi.fn(),
|
||||
terminateProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./common")>()),
|
||||
isProcessRunning: mocks.isProcessRunning,
|
||||
spawnDetachedConnector: mocks.spawnDetachedConnector,
|
||||
terminateProcess: mocks.terminateProcess,
|
||||
}));
|
||||
|
||||
class TestConnector extends ConnectorBase<
|
||||
Record<string, never>,
|
||||
{ pid: number }
|
||||
> {
|
||||
constructor() {
|
||||
super("test", "Test connector");
|
||||
}
|
||||
|
||||
protected readOptions(): Record<string, never> {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected async runWithOptions(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
runBackground(
|
||||
io: ConnectIo,
|
||||
options?: {
|
||||
readState?: () => { pid: number } | undefined;
|
||||
isRunning?: (state: { pid: number }) => boolean;
|
||||
startupTimeoutMs?: number;
|
||||
},
|
||||
): Promise<number | undefined> {
|
||||
return this.maybeRunInBackground({
|
||||
rawArgs: ["--token", "secret"],
|
||||
io,
|
||||
interactive: false,
|
||||
childEnvVar: "CLINE_TEST_CONNECT_CHILD",
|
||||
statePath: "/tmp/test-connector.json",
|
||||
readState: options?.readState ?? (() => undefined),
|
||||
isRunning: options?.isRunning ?? (() => false),
|
||||
formatAlreadyRunningMessage: () => "already running",
|
||||
formatBackgroundStartMessage: (pid) => `started ${pid}`,
|
||||
foregroundHint: "foreground hint",
|
||||
launchFailureMessage: "launch failed",
|
||||
startupTimeoutMs: options?.startupTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
stopProcess(
|
||||
io: ConnectIo,
|
||||
options: {
|
||||
statePath: string;
|
||||
readState: (path: string) => { pid: number } | undefined;
|
||||
stopSessions?: (state: { pid: number }) => Promise<number>;
|
||||
clearBindings?: (state: { pid: number }) => void;
|
||||
},
|
||||
) {
|
||||
return this.stopManagedProcess({
|
||||
io,
|
||||
statePath: options.statePath,
|
||||
readState: options.readState,
|
||||
describeStoppedProcess: (state) => `stopped pid=${state.pid}`,
|
||||
getPid: (state) => state.pid,
|
||||
stopSessions: options.stopSessions ?? (async () => 0),
|
||||
clearBindings: options.clearBindings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("ConnectorBase background launch", () => {
|
||||
const io: ConnectIo = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isProcessRunning.mockReturnValue(true);
|
||||
mocks.terminateProcess.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("returns a failure exit code when the detached process is not created", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(0);
|
||||
|
||||
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
|
||||
expect(io.writeErr).toHaveBeenCalledWith("launch failed");
|
||||
});
|
||||
|
||||
it("returns success only after a detached process receives a pid", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
let reads = 0;
|
||||
|
||||
await expect(
|
||||
new TestConnector().runBackground(io, {
|
||||
readState: () => (++reads > 1 ? { pid: 42 } : undefined),
|
||||
isRunning: () => true,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(io.writeln).toHaveBeenCalledWith("started 42");
|
||||
});
|
||||
|
||||
it("fails when the detached child exits before becoming ready", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
mocks.isProcessRunning.mockReturnValue(false);
|
||||
|
||||
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
|
||||
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: child exited before becoming ready",
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates a detached child that never becomes ready", async () => {
|
||||
mocks.spawnDetachedConnector.mockReturnValue(42);
|
||||
|
||||
await expect(
|
||||
new TestConnector().runBackground(io, { startupTimeoutMs: 0 }),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"launch failed: timed out after 0ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a distinct result when a connector is already running", async () => {
|
||||
await expect(
|
||||
new TestConnector().runBackground(io, {
|
||||
readState: () => ({ pid: 99 }),
|
||||
isRunning: () => true,
|
||||
}),
|
||||
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
|
||||
|
||||
expect(io.writeln).toHaveBeenCalledWith("already running");
|
||||
expect(mocks.spawnDetachedConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps state and reports failure when the process survives termination", async () => {
|
||||
const connector = new TestConnector();
|
||||
const removeStateFile = vi.spyOn(
|
||||
connector as unknown as { removeStateFile: (path: string) => void },
|
||||
"removeStateFile",
|
||||
);
|
||||
const stopSessions = vi.fn(async () => 1);
|
||||
const clearBindings = vi.fn();
|
||||
mocks.terminateProcess.mockResolvedValue(false);
|
||||
mocks.isProcessRunning.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
connector.stopProcess(io, {
|
||||
statePath: "/tmp/test-connector.json",
|
||||
readState: () => ({ pid: 42 }),
|
||||
stopSessions,
|
||||
clearBindings,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
});
|
||||
|
||||
expect(removeStateFile).not.toHaveBeenCalled();
|
||||
expect(stopSessions).not.toHaveBeenCalled();
|
||||
expect(clearBindings).not.toHaveBeenCalled();
|
||||
expect(io.writeErr).toHaveBeenCalledWith(
|
||||
"[connect] failed to stop connector process pid=42",
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans stale state after confirming the process is already gone", async () => {
|
||||
const connector = new TestConnector();
|
||||
const removeStateFile = vi.spyOn(
|
||||
connector as unknown as { removeStateFile: (path: string) => void },
|
||||
"removeStateFile",
|
||||
);
|
||||
const stopSessions = vi.fn(async () => 1);
|
||||
const clearBindings = vi.fn();
|
||||
mocks.terminateProcess.mockResolvedValue(false);
|
||||
mocks.isProcessRunning.mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
connector.stopProcess(io, {
|
||||
statePath: "/tmp/test-connector.json",
|
||||
readState: () => ({ pid: 42 }),
|
||||
stopSessions,
|
||||
clearBindings,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 1,
|
||||
});
|
||||
|
||||
expect(removeStateFile).toHaveBeenCalledWith("/tmp/test-connector.json");
|
||||
expect(stopSessions).toHaveBeenCalledWith({ pid: 42 });
|
||||
expect(clearBindings).toHaveBeenCalledWith({ pid: 42 });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CONNECT_ALREADY_RUNNING_EXIT_CODE,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
@@ -13,15 +14,19 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectRunContext,
|
||||
ConnectStopResult,
|
||||
} from "./types";
|
||||
|
||||
const SHOW_HELP_ERROR = "__SHOW_HELP__";
|
||||
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const CONNECTOR_STARTUP_POLL_MS = 100;
|
||||
|
||||
export abstract class ConnectorBase<Options, State>
|
||||
implements ConnectCommandDefinition
|
||||
{
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
@@ -41,8 +46,16 @@ export abstract class ConnectorBase<Options, State>
|
||||
options: Options,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number>;
|
||||
|
||||
protected async validateOptions(
|
||||
_options: Options,
|
||||
_io: ConnectIo,
|
||||
): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
showHelp(io: ConnectIo): void {
|
||||
const output = this.createCommand().helpInformation().trimEnd();
|
||||
for (const line of output.split("\n")) {
|
||||
@@ -50,7 +63,11 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
}
|
||||
|
||||
async run(rawArgs: string[], io: ConnectIo): Promise<number> {
|
||||
async run(
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number> {
|
||||
let options: Options;
|
||||
try {
|
||||
options = this.parseArgs(rawArgs);
|
||||
@@ -63,7 +80,27 @@ export abstract class ConnectorBase<Options, State>
|
||||
io.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
return this.runWithOptions(options, rawArgs, io);
|
||||
const validationExitCode = await this.validateOptions(options, io);
|
||||
if (validationExitCode !== 0) {
|
||||
return validationExitCode;
|
||||
}
|
||||
return this.runWithOptions(options, rawArgs, io, context);
|
||||
}
|
||||
|
||||
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
|
||||
let options: Options;
|
||||
try {
|
||||
options = this.parseArgs(rawArgs);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message === SHOW_HELP_ERROR) {
|
||||
this.showHelp(io);
|
||||
return 0;
|
||||
}
|
||||
io.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
return await this.validateOptions(options, io);
|
||||
}
|
||||
|
||||
protected parseArgs(rawArgs: string[]): Options {
|
||||
@@ -145,14 +182,15 @@ export abstract class ConnectorBase<Options, State>
|
||||
formatBackgroundStartMessage: (pid: number) => string;
|
||||
foregroundHint: string;
|
||||
launchFailureMessage: string;
|
||||
}): Promise<boolean> {
|
||||
startupTimeoutMs?: number;
|
||||
}): Promise<number | undefined> {
|
||||
if (input.interactive || process.env[input.childEnvVar] === "1") {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
const runningState = input.readState(input.statePath);
|
||||
if (runningState && input.isRunning(runningState)) {
|
||||
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
|
||||
return true;
|
||||
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
|
||||
}
|
||||
const pid = spawnDetachedConnector(
|
||||
["connect", this.name],
|
||||
@@ -161,11 +199,32 @@ export abstract class ConnectorBase<Options, State>
|
||||
);
|
||||
if (!pid) {
|
||||
input.io.writeErr(input.launchFailureMessage);
|
||||
return true;
|
||||
return 1;
|
||||
}
|
||||
input.io.writeln(input.formatBackgroundStartMessage(pid));
|
||||
input.io.writeln(input.foregroundHint);
|
||||
return true;
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = input.startupTimeoutMs ?? CONNECTOR_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const state = input.readState(input.statePath);
|
||||
if (state && input.isRunning(state)) {
|
||||
return 0;
|
||||
}
|
||||
if (!isProcessRunning(pid)) {
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: child exited before becoming ready`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, CONNECTOR_STARTUP_POLL_MS),
|
||||
);
|
||||
}
|
||||
await terminateProcess(pid);
|
||||
input.io.writeErr(
|
||||
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected async stopAllFromStatePaths(
|
||||
@@ -177,13 +236,15 @@ export abstract class ConnectorBase<Options, State>
|
||||
) => Promise<ConnectStopResult>,
|
||||
): Promise<ConnectStopResult> {
|
||||
let stoppedProcesses = 0;
|
||||
let failedProcesses = 0;
|
||||
let stoppedSessions = 0;
|
||||
for (const statePath of statePaths) {
|
||||
const result = await stopInstance(statePath, io);
|
||||
stoppedProcesses += result.stoppedProcesses;
|
||||
failedProcesses += result.failedProcesses;
|
||||
stoppedSessions += result.stoppedSessions;
|
||||
}
|
||||
return { stoppedProcesses, stoppedSessions };
|
||||
return { stoppedProcesses, failedProcesses, stoppedSessions };
|
||||
}
|
||||
|
||||
protected async stopManagedProcess(input: {
|
||||
@@ -198,17 +259,31 @@ export abstract class ConnectorBase<Options, State>
|
||||
const state = input.readState(input.statePath);
|
||||
if (!state) {
|
||||
this.removeStateFile(input.statePath);
|
||||
return { stoppedProcesses: 0, stoppedSessions: 0 };
|
||||
return {
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
};
|
||||
}
|
||||
const pid = input.getPid(state);
|
||||
let stoppedProcesses = 0;
|
||||
if (await terminateProcess(input.getPid(state))) {
|
||||
if (await terminateProcess(pid)) {
|
||||
stoppedProcesses = 1;
|
||||
input.io.writeln(input.describeStoppedProcess(state));
|
||||
} else if (isProcessRunning(pid)) {
|
||||
input.io.writeErr(
|
||||
`[connect] failed to stop connector process pid=${pid}`,
|
||||
);
|
||||
return {
|
||||
stoppedProcesses: 0,
|
||||
failedProcesses: 1,
|
||||
stoppedSessions: 0,
|
||||
};
|
||||
}
|
||||
const stoppedSessions = await input.stopSessions(state);
|
||||
input.clearBindings?.(state);
|
||||
this.removeStateFile(input.statePath);
|
||||
return { stoppedProcesses, stoppedSessions };
|
||||
return { stoppedProcesses, failedProcesses: 0, stoppedSessions };
|
||||
}
|
||||
|
||||
protected parseOptionalInteger(
|
||||
|
||||
@@ -83,6 +83,24 @@ describe("spawnDetachedConnector", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks detached children and removes the hub-daemon-only environment flag", () => {
|
||||
const env = {
|
||||
CLINE_BUILD_ENV: "production",
|
||||
CLINE_RUN_AS_HUB_DAEMON: "1",
|
||||
UNCHANGED: "value",
|
||||
};
|
||||
|
||||
expect(
|
||||
__test__.buildDetachedConnectorEnv("CLINE_TELEGRAM_CONNECT_CHILD", env),
|
||||
).toEqual({
|
||||
CLINE_BUILD_ENV: "production",
|
||||
CLINE_CONNECTOR_DETACHED_CHILD: "1",
|
||||
CLINE_TELEGRAM_CONNECT_CHILD: "1",
|
||||
UNCHANGED: "value",
|
||||
});
|
||||
expect(env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSessionReplyText", () => {
|
||||
|
||||
@@ -10,11 +10,24 @@ import {
|
||||
import { join } from "node:path";
|
||||
import type { HubSessionClient, HubSessionRow } from "@cline/core";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
|
||||
"CLINE_CONNECTOR_DETACHED_CHILD";
|
||||
|
||||
/**
|
||||
* Internal success from a detached connect when an instance is already running.
|
||||
* `runConnectAdapter` maps this to exit 0 without changing persisted autostart
|
||||
* state.
|
||||
*/
|
||||
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -123,6 +136,19 @@ function buildDetachedConnectorCommand(
|
||||
};
|
||||
}
|
||||
|
||||
function buildDetachedConnectorEnv(
|
||||
childEnvKey: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const childEnv = {
|
||||
...withResolvedClineBuildEnv(env),
|
||||
[childEnvKey]: "1",
|
||||
[CLINE_CONNECTOR_DETACHED_CHILD_ENV]: "1",
|
||||
};
|
||||
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
export function resolveConnectorDebugLogPath(
|
||||
adapterName: string,
|
||||
instanceKey: string,
|
||||
@@ -190,10 +216,7 @@ export function spawnDetachedConnector(
|
||||
detachedLogFd === undefined
|
||||
? "ignore"
|
||||
: ["ignore", detachedLogFd, detachedLogFd],
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
},
|
||||
env: buildDetachedConnectorEnv(childEnvKey),
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
@@ -245,6 +268,7 @@ export function spawnDetachedConnector(
|
||||
export const __test__ = {
|
||||
buildDetachedConnectorArgs,
|
||||
buildDetachedConnectorCommand,
|
||||
buildDetachedConnectorEnv,
|
||||
};
|
||||
|
||||
export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
|
||||
@@ -5,13 +5,25 @@ export type ConnectIo = {
|
||||
|
||||
export type ConnectStopResult = {
|
||||
stoppedProcesses: number;
|
||||
failedProcesses: number;
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectRunContext = {
|
||||
setPersistenceArgs: (args: string[]) => void;
|
||||
setPersistenceInstanceId: (instanceId: string) => void;
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
run(args: string[], io: ConnectIo): Promise<number>;
|
||||
run(
|
||||
args: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number>;
|
||||
validate(args: string[], io: ConnectIo): Promise<number>;
|
||||
showHelp(io: ConnectIo): void;
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
}
|
||||
|
||||
+20
-6
@@ -1,13 +1,19 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import { disposeAll, initVcr, isHubDaemonProcess } from "@cline/shared";
|
||||
import {
|
||||
disposeAll,
|
||||
initVcr,
|
||||
isHubDaemonProcess,
|
||||
setConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { logCliProcessError } from "./logging/errors";
|
||||
import {
|
||||
abortActiveRuntime,
|
||||
cleanupActiveRuntime,
|
||||
isAbortInProgress,
|
||||
} from "./runtime/active-runtime";
|
||||
import { resolveCliLaunchSpec } from "./utils/internal-launch";
|
||||
import { writeErr } from "./utils/output";
|
||||
|
||||
// Initialize VCR before any HTTP requests are made.
|
||||
@@ -16,7 +22,20 @@ initVcr(process.env.CLINE_VCR);
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (isHubDaemonProcess()) {
|
||||
// The hub daemon owns its process-level abort handling. Installing the CLI's
|
||||
// fatal rejection handler first would make expected abort rejections exit it.
|
||||
void import("@cline/core/hub/daemon-entry");
|
||||
} else {
|
||||
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
|
||||
if (cliLaunchSpec) {
|
||||
setConnectorCliLaunchSpec({
|
||||
launcher: cliLaunchSpec.launcher,
|
||||
connectArgsPrefix: [...cliLaunchSpec.childArgsPrefix, "connect"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
let handlingFatalProcessError = false;
|
||||
const forwardSignalToRuntime = () => {
|
||||
@@ -57,11 +76,6 @@ if (!isMainThread) {
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
+257
-4
@@ -1,4 +1,6 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CliMigrationNotice,
|
||||
@@ -18,6 +20,7 @@ vi.mock("node:fs", async () => {
|
||||
const originalArgv = [...process.argv];
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const mockState = vi.hoisted(() => ({
|
||||
runAgentImports: 0,
|
||||
runInteractiveImports: 0,
|
||||
@@ -61,6 +64,13 @@ const kanbanMocks = vi.hoisted(() => ({
|
||||
const dashboardMocks = vi.hoisted(() => ({
|
||||
runDashboardCommand: vi.fn(),
|
||||
}));
|
||||
const connectMocks = vi.hoisted(() => ({
|
||||
formatAdapterList: vi.fn(() => ""),
|
||||
runConnectAdapter: vi.fn(async () => 0),
|
||||
runRestartConnector: vi.fn(async () => 0),
|
||||
runStopAllConnectors: vi.fn(async () => 0),
|
||||
runStopConnector: vi.fn(async () => 0),
|
||||
}));
|
||||
const migrationNoticeMocks = vi.hoisted(() => ({
|
||||
getClineCliMigrationNotice: vi.fn<
|
||||
(
|
||||
@@ -198,6 +208,7 @@ vi.mock("./runtime/prompt", () => ({
|
||||
}));
|
||||
vi.mock("./commands/kanban", () => kanbanMocks);
|
||||
vi.mock("./commands/dashboard", () => dashboardMocks);
|
||||
vi.mock("./commands/connect", () => connectMocks);
|
||||
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
||||
vi.mock("./commands/update", () => updateMocks);
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
@@ -208,8 +219,17 @@ vi.mock("./utils/telemetry", () => telemetryMocks);
|
||||
vi.mock("./utils/worktree", () => worktreeMocks);
|
||||
|
||||
describe("runCli lightweight command dispatch", () => {
|
||||
let globalSettingsRoot: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
process.exitCode = undefined;
|
||||
// Startup now reads persisted general settings; point the resolver at a
|
||||
// fresh temp file so the developer's real settings cannot leak in.
|
||||
globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-"));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
globalSettingsRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
mockState.runAgentImports = 0;
|
||||
mockState.runInteractiveImports = 0;
|
||||
mockState.runAgentCalls = 0;
|
||||
@@ -273,6 +293,16 @@ describe("runCli lightweight command dispatch", () => {
|
||||
kanbanMocks.launchKanban.mockResolvedValue(0);
|
||||
dashboardMocks.runDashboardCommand.mockReset();
|
||||
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
|
||||
connectMocks.formatAdapterList.mockReset();
|
||||
connectMocks.formatAdapterList.mockReturnValue("");
|
||||
connectMocks.runConnectAdapter.mockReset();
|
||||
connectMocks.runConnectAdapter.mockResolvedValue(0);
|
||||
connectMocks.runRestartConnector.mockReset();
|
||||
connectMocks.runRestartConnector.mockResolvedValue(0);
|
||||
connectMocks.runStopAllConnectors.mockReset();
|
||||
connectMocks.runStopAllConnectors.mockResolvedValue(0);
|
||||
connectMocks.runStopConnector.mockReset();
|
||||
connectMocks.runStopConnector.mockResolvedValue(0);
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
|
||||
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
|
||||
@@ -299,6 +329,16 @@ describe("runCli lightweight command dispatch", () => {
|
||||
afterEach(() => {
|
||||
process.exitCode = undefined;
|
||||
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (globalSettingsRoot) {
|
||||
rmSync(globalSettingsRoot, { recursive: true, force: true });
|
||||
globalSettingsRoot = undefined;
|
||||
}
|
||||
|
||||
process.argv = [...originalArgv];
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
value: originalStdinIsTTY,
|
||||
@@ -333,6 +373,55 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
it("routes connector restart arguments through the restart lifecycle", async () => {
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--restart",
|
||||
"telegram",
|
||||
"-k",
|
||||
"token",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
["-k", "token"],
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
|
||||
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a targeted connector restart to one instance", async () => {
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"connect",
|
||||
"--restart-instance",
|
||||
"cline_bot",
|
||||
"telegram",
|
||||
"-k",
|
||||
"token",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
|
||||
"telegram",
|
||||
["-k", "token"],
|
||||
expect.any(Object),
|
||||
"cline_bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not load runtime modules for root update", async () => {
|
||||
@@ -848,6 +937,172 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("persisted general settings at startup", () => {
|
||||
function writePersistedSettings(settings: Record<string, unknown>) {
|
||||
const path = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
if (!path) {
|
||||
throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set");
|
||||
}
|
||||
writeFileSync(path, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
it("restores the persisted plan mode when no mode flag is provided", async () => {
|
||||
writePersistedSettings({ planActMode: "plan" });
|
||||
promptMocks.resolveSystemPrompt.mockClear();
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "plan" }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "plan" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit --act flag over the persisted plan mode", async () => {
|
||||
writePersistedSettings({ planActMode: "plan" });
|
||||
promptMocks.resolveSystemPrompt.mockClear();
|
||||
process.argv = ["bun", "src/index.ts", "--act"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "act" }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: "act" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the persisted auto-approve setting as a runtime policy", async () => {
|
||||
writePersistedSettings({ toolAutoApprove: false });
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: false },
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit --auto-approve flag over the persisted setting", async () => {
|
||||
writePersistedSettings({ toolAutoApprove: false });
|
||||
process.argv = ["bun", "src/index.ts", "--auto-approve", "true"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores disabled compaction across restarts", async () => {
|
||||
writePersistedSettings({
|
||||
compactionEnabled: false,
|
||||
compactionStrategy: "basic",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: false },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the persisted compaction strategy across restarts", async () => {
|
||||
writePersistedSettings({
|
||||
compactionEnabled: true,
|
||||
compactionStrategy: "basic",
|
||||
});
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: true, strategy: "basic" },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit --compaction flag over the persisted mode", async () => {
|
||||
writePersistedSettings({ compactionEnabled: false });
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "agentic"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: true, strategy: "agentic" },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies persisted settings to single-prompt runs as well", async () => {
|
||||
writePersistedSettings({
|
||||
compactionEnabled: true,
|
||||
compactionStrategy: "basic",
|
||||
planActMode: "plan",
|
||||
});
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: { enabled: true, strategy: "basic" },
|
||||
mode: "plan",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces chat view when resuming a session", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "--id", "sess_123"];
|
||||
|
||||
@@ -1302,7 +1557,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
@@ -1389,7 +1643,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables truncation compaction by default for prompt runs", async () => {
|
||||
it("uses Core's agentic compaction default for prompt runs", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
@@ -1404,7 +1658,6 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
|
||||
+51
-12
@@ -43,6 +43,11 @@ import {
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { resolveCliReasoning } from "./utils/reasoning";
|
||||
import {
|
||||
resolveStartupCompactionMode,
|
||||
resolveStartupMode,
|
||||
resolveStartupToolAutoApprove,
|
||||
} from "./utils/startup-settings";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
@@ -362,6 +367,11 @@ export async function runCli(): Promise<void> {
|
||||
.description("Connect to an external channel")
|
||||
.argument("[channel]", "Channel to connect Cline CLI to")
|
||||
.option("--stop", "Kill all current channel connections")
|
||||
.option("--restart", "Restart a channel connection")
|
||||
.option(
|
||||
"--restart-instance <id>",
|
||||
"Restart one connector instance (used by daemon recovery)",
|
||||
)
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.addHelpText(
|
||||
@@ -372,16 +382,32 @@ export async function runCli(): Promise<void> {
|
||||
const {
|
||||
formatAdapterList,
|
||||
runConnectAdapter,
|
||||
runRestartConnector,
|
||||
runStopAllConnectors,
|
||||
runStopConnector,
|
||||
} = await import("./commands/connect");
|
||||
const opts = connectCmd.opts();
|
||||
if (opts.stop) {
|
||||
if (opts.stop && (opts.restart || opts.restartInstance)) {
|
||||
io.writeErr("connect accepts only one of --stop or --restart");
|
||||
ctx.exitCode = 1;
|
||||
} else if (opts.stop) {
|
||||
if (adapter) {
|
||||
ctx.exitCode = await runStopConnector(adapter, io);
|
||||
} else {
|
||||
ctx.exitCode = await runStopAllConnectors(io);
|
||||
}
|
||||
} else if (opts.restart || opts.restartInstance) {
|
||||
if (!adapter) {
|
||||
io.writeErr("connect --restart requires a channel");
|
||||
ctx.exitCode = 1;
|
||||
} else {
|
||||
ctx.exitCode = await runRestartConnector(
|
||||
adapter,
|
||||
connectCmd.args.slice(1),
|
||||
io,
|
||||
opts.restartInstance,
|
||||
);
|
||||
}
|
||||
} else if (adapter) {
|
||||
// connectCmd.args = [adapter, ...passthroughFlags]. Pass only the
|
||||
// connector-specific flags (everything after the adapter name).
|
||||
@@ -844,14 +870,6 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
}
|
||||
setCurrentOutputMode(args.outputMode);
|
||||
const defaultToolAutoApprove = true;
|
||||
const effectiveToolAutoApprove =
|
||||
args.autoApproveOverride ?? defaultToolAutoApprove;
|
||||
const toolPolicies: Record<string, ToolPolicy> = {
|
||||
"*": {
|
||||
autoApprove: effectiveToolAutoApprove,
|
||||
},
|
||||
};
|
||||
|
||||
if (args.outputMode === "json" && (args.interactive || !args.prompt)) {
|
||||
writeErr(
|
||||
@@ -928,6 +946,27 @@ export async function runCli(): Promise<void> {
|
||||
runAgent,
|
||||
} = await loadCliRuntimeModules();
|
||||
|
||||
// General settings toggled in the TUI /settings panel persist to the
|
||||
// global settings file; explicit CLI flags take precedence over the
|
||||
// persisted values, which in turn override the built-in defaults.
|
||||
const persistedGlobalSettings = coreServer.readGlobalSettings();
|
||||
const defaultToolAutoApprove = true;
|
||||
const effectiveToolAutoApprove = resolveStartupToolAutoApprove(
|
||||
args,
|
||||
persistedGlobalSettings,
|
||||
defaultToolAutoApprove,
|
||||
);
|
||||
const toolPolicies: Record<string, ToolPolicy> = {
|
||||
"*": {
|
||||
autoApprove: effectiveToolAutoApprove,
|
||||
},
|
||||
};
|
||||
const effectiveMode = resolveStartupMode(args, persistedGlobalSettings);
|
||||
const effectiveCompactionMode = resolveStartupCompactionMode(
|
||||
args,
|
||||
persistedGlobalSettings,
|
||||
);
|
||||
|
||||
// Register the SDK early logger as early as possible — before any
|
||||
// provider settings reads — so the full startup sequence is captured.
|
||||
// These components operate before/outside ClineCore sessions, so the
|
||||
@@ -1086,13 +1125,13 @@ export async function runCli(): Promise<void> {
|
||||
cwd,
|
||||
explicitSystemPrompt: args.systemPrompt,
|
||||
providerId: provider,
|
||||
mode: args.mode ?? "act",
|
||||
mode: effectiveMode,
|
||||
}),
|
||||
execution: {
|
||||
maxConsecutiveMistakes: args.retries ?? 3,
|
||||
},
|
||||
checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG,
|
||||
compaction: buildCliCompactionConfig(args.compactionMode),
|
||||
compaction: buildCliCompactionConfig(effectiveCompactionMode),
|
||||
timeoutSeconds: args.timeoutSeconds,
|
||||
sandbox: sandboxEnabled,
|
||||
sandboxDataDir,
|
||||
@@ -1100,7 +1139,7 @@ export async function runCli(): Promise<void> {
|
||||
thinking: resolvedReasoning.thinking,
|
||||
reasoningEffort: resolvedReasoning.reasoningEffort,
|
||||
outputMode: args.outputMode,
|
||||
mode: args.mode,
|
||||
mode: effectiveMode,
|
||||
logger: loggerAdapter.core,
|
||||
loggerConfig: loggerAdapter.runtimeConfig,
|
||||
telemetry: getCliTelemetryService(loggerAdapter.core),
|
||||
|
||||
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
|
||||
let activeRuntimeCleanup: (() => void) | undefined;
|
||||
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortInProgress = false;
|
||||
let savedRejectionListeners: Function[] | undefined;
|
||||
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
|
||||
|
||||
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
|
||||
activeRuntimeAbort = abortFn;
|
||||
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners(
|
||||
"unhandledRejection",
|
||||
) as Function[];
|
||||
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
@@ -68,10 +68,7 @@ export function clearAbortInProgress(): void {
|
||||
if (savedRejectionListeners) {
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
for (const listener of savedRejectionListeners) {
|
||||
process.on(
|
||||
"unhandledRejection",
|
||||
listener as (...args: unknown[]) => void,
|
||||
);
|
||||
process.on("unhandledRejection", listener);
|
||||
}
|
||||
savedRejectionListeners = undefined;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@ import {
|
||||
resolveCompactionProviderConfig,
|
||||
} from "./compaction";
|
||||
|
||||
const createHandlerMock = vi.fn();
|
||||
|
||||
// Core defaults to the agentic compaction strategy, which summarizes via a
|
||||
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
|
||||
// key) is needed; every other `@cline/llms` export stays real because
|
||||
// `@cline/core` re-exports them.
|
||||
vi.mock("@cline/llms", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@cline/llms")>()),
|
||||
createHandlerAsync: (config: unknown) => createHandlerMock(config),
|
||||
}));
|
||||
|
||||
async function* streamChunks(
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
@@ -46,6 +65,7 @@ function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
createHandlerMock.mockReset();
|
||||
for (const tempDir of providerSettingsTempDirs.splice(0)) {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
@@ -163,6 +183,15 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
const mockSummary = "## Goal\nMocked agentic compaction summary";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn(() =>
|
||||
streamChunks([
|
||||
{ type: "text", id: "summary-1", text: mockSummary },
|
||||
{ type: "done", id: "summary-1", success: true },
|
||||
]),
|
||||
),
|
||||
});
|
||||
const longText = "x".repeat(16_000);
|
||||
const messages = Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
||||
@@ -189,6 +218,17 @@ describe("compactInteractiveMessages", () => {
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
|
||||
// The agentic strategy folds older messages into a summary message
|
||||
// built from the (mocked) summarizer output.
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
const [summaryMessage] = compactedMessages;
|
||||
const summaryText = Array.isArray(summaryMessage?.content)
|
||||
? summaryMessage.content
|
||||
.map((block) => ("text" in block ? block.text : ""))
|
||||
.join("\n")
|
||||
: String(summaryMessage?.content ?? "");
|
||||
expect(summaryText).toContain(mockSummary);
|
||||
});
|
||||
|
||||
it("reports compaction when core returns changed messages with the same count", async () => {
|
||||
|
||||
@@ -55,54 +55,44 @@ const CLI_CLINE_PASS_LIMIT_MESSAGE = [
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
isClineNotSubscribedMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
extractClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
const prefix = "you have reached your";
|
||||
const suffix = "please try again later.";
|
||||
const start = normalized.indexOf(prefix);
|
||||
if (start === -1) return undefined;
|
||||
const suffixStart = normalized.indexOf(suffix, start);
|
||||
if (suffixStart === -1) return undefined;
|
||||
const end = suffixStart + suffix.length;
|
||||
if (!normalized.slice(start, end).includes("clinepass limit")) {
|
||||
return undefined;
|
||||
}
|
||||
return text.slice(start, end);
|
||||
},
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
},
|
||||
}));
|
||||
vi.mock(
|
||||
"@cline/core",
|
||||
async (importActual: () => Promise<typeof import("@cline/core")>) => ({
|
||||
...(await importActual()),
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
isClineNotSubscribedMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
isClinePassLimitError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClinePassLimitError",
|
||||
isClinePassLimitMessage: (text: string) => {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes("you have reached your") &&
|
||||
normalized.includes("clinepass limit") &&
|
||||
normalized.includes("please try again later.")
|
||||
);
|
||||
},
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../utils/approval", () => ({
|
||||
askQuestionInTerminal: vi.fn(),
|
||||
|
||||
@@ -205,7 +205,9 @@ export async function runAgent(
|
||||
event.error.message.trim()
|
||||
) {
|
||||
displayedErrorMessages.add(
|
||||
formatCliErrorMessage(event.error.message).trim(),
|
||||
formatCliErrorMessage(event.error.message, {
|
||||
modelId: config.modelId,
|
||||
}).trim(),
|
||||
);
|
||||
}
|
||||
handleEvent(event, config);
|
||||
@@ -390,7 +392,9 @@ export async function runAgent(
|
||||
}
|
||||
|
||||
if (result.finishReason !== "completed") {
|
||||
const errorText = formatCliErrorMessage(result.text).trim();
|
||||
const errorText = formatCliErrorMessage(result.text, {
|
||||
modelId: config.modelId,
|
||||
}).trim();
|
||||
if (
|
||||
errorText &&
|
||||
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
|
||||
@@ -411,7 +415,7 @@ export async function runAgent(
|
||||
);
|
||||
process.exitCode = 0;
|
||||
} catch (err) {
|
||||
const message = formatCliErrorMessage(err);
|
||||
const message = formatCliErrorMessage(err, { modelId: config.modelId });
|
||||
logCliError(config.logger, "CLI task run failed", { error: err });
|
||||
writeErr(message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
getCurrentContextSize,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
setCompactionModeGlobally,
|
||||
setPlanActModeGlobally,
|
||||
setToolAutoApproveGlobally,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { formatModeSwitchNotice } from "@cline/shared";
|
||||
@@ -712,15 +715,20 @@ export async function runInteractive(
|
||||
onTurnErrorReported: () => {},
|
||||
onAutoApproveChange: (enabled) => {
|
||||
setInteractiveAutoApprove(enabled);
|
||||
setToolAutoApproveGlobally(enabled);
|
||||
void refreshInteractiveSessionPolicies();
|
||||
},
|
||||
onCompactionModeChange: async (mode) => {
|
||||
await sessionRuntime.ensureReady();
|
||||
applyCliCompactionMode(config, mode);
|
||||
setCompactionModeGlobally(mode);
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onModeChange: async (mode) => {
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
// Persist the user's choice immediately, even when the switch is
|
||||
// deferred until the current turn aborts, so it survives restarts.
|
||||
setPlanActModeGlobally(mode);
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
|
||||
@@ -17,14 +17,19 @@
|
||||
// - Auto-approve all (Shift+Tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { test } from "@microsoft/tui-test";
|
||||
import { expect, test } from "@microsoft/tui-test";
|
||||
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js";
|
||||
import { clineEnv } from "../helpers/env.js";
|
||||
import {
|
||||
toggleAutoApproveAll,
|
||||
waitForChatReady,
|
||||
} from "../helpers/page-objects/chat.js";
|
||||
import { expectVisible } from "../helpers/terminal.js";
|
||||
import {
|
||||
expectNotVisible,
|
||||
expectVisible,
|
||||
typeAndSubmit,
|
||||
} from "../helpers/terminal.js";
|
||||
|
||||
test.describe("cline (authenticated) - shows chat view", () => {
|
||||
test.use({
|
||||
@@ -53,3 +58,113 @@ test.describe("Auto-approve all - Shift+Tab toggle", () => {
|
||||
await toggleAutoApproveAll(terminal);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Dialog dismissal - panel is fully removed", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
});
|
||||
|
||||
type Background = {
|
||||
mode: number | undefined;
|
||||
color: number | undefined;
|
||||
};
|
||||
type TerminalSnapshot = ReturnType<Terminal["serialize"]> & {
|
||||
baseY: number;
|
||||
};
|
||||
const backgroundsEqual = (
|
||||
left: Background | undefined,
|
||||
right: Background | undefined,
|
||||
): boolean => left?.mode === right?.mode && left?.color === right?.color;
|
||||
const snapshotTerminal = (terminal: Terminal): TerminalSnapshot => ({
|
||||
...terminal.serialize(),
|
||||
baseY: terminal.getCursor().baseY,
|
||||
});
|
||||
|
||||
const findTextPosition = (
|
||||
terminal: Terminal,
|
||||
text: string,
|
||||
): { x: number; y: number } => {
|
||||
const lines = terminal.getViewableBuffer();
|
||||
for (let y = 0; y < lines.length; y++) {
|
||||
const x = lines[y].join("").indexOf(text);
|
||||
if (x !== -1) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to locate visible text: ${text}`);
|
||||
};
|
||||
|
||||
const getCellBackground = (
|
||||
snapshot: TerminalSnapshot,
|
||||
position: { x: number; y: number },
|
||||
): Background => {
|
||||
const targetRow = snapshot.baseY + position.y;
|
||||
let background: Background = { mode: undefined, color: undefined };
|
||||
|
||||
for (let y = snapshot.baseY; y <= targetRow; y++) {
|
||||
for (let x = 0; x < TERMINAL_WIDE.columns; x++) {
|
||||
const shift = snapshot.shifts.get(`${x},${y}`);
|
||||
if (shift?.bgColorMode !== undefined) {
|
||||
background = { mode: shift.bgColorMode, color: shift.bgColor };
|
||||
}
|
||||
if (x === position.x && y === targetRow) {
|
||||
return background;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cell is outside the visible terminal: ${position.x},${position.y}`,
|
||||
);
|
||||
};
|
||||
|
||||
// @opentui-ui/dialog is built against @opentui/core ^0.1.69, whose
|
||||
// Renderable.remove(id) took an id. Core 0.4.x renamed it to
|
||||
// remove(child) and throws on a non-renderable argument, so the
|
||||
// package's removeDialog() aborted before detaching its panel — the React
|
||||
// portal content unmounted, but the imperative grey box stayed on screen
|
||||
// over the chat. Asserting on the panel's background (not its text) is what
|
||||
// distinguishes a leaked box from a clean teardown.
|
||||
test("closing the help dialog removes its grey panel", async ({
|
||||
terminal,
|
||||
}) => {
|
||||
await waitForChatReady(terminal);
|
||||
const terminalBeforeDialog = snapshotTerminal(terminal);
|
||||
await typeAndSubmit(terminal, "/help");
|
||||
await expectVisible(terminal, "Keyboard Shortcuts");
|
||||
const dialogPosition = findTextPosition(terminal, "Keyboard Shortcuts");
|
||||
const backgroundAtDialogPosition = getCellBackground(
|
||||
terminalBeforeDialog,
|
||||
dialogPosition,
|
||||
);
|
||||
const dialogBackground = getCellBackground(
|
||||
snapshotTerminal(terminal),
|
||||
dialogPosition,
|
||||
);
|
||||
expect(dialogBackground).not.toEqual(backgroundAtDialogPosition);
|
||||
|
||||
terminal.keyEscape();
|
||||
await expectNotVisible(terminal, "Keyboard Shortcuts");
|
||||
|
||||
// The panel unmounts a frame after its content. Poll the title's former
|
||||
// position until the background captured from the visible panel is gone.
|
||||
const deadline = Date.now() + 10_000;
|
||||
let backgroundAfterDialog = getCellBackground(
|
||||
snapshotTerminal(terminal),
|
||||
dialogPosition,
|
||||
);
|
||||
while (
|
||||
!backgroundsEqual(backgroundAfterDialog, backgroundAtDialogPosition) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
backgroundAfterDialog = getCellBackground(
|
||||
snapshotTerminal(terminal),
|
||||
dialogPosition,
|
||||
);
|
||||
}
|
||||
expect(backgroundAfterDialog).toEqual(backgroundAtDialogPosition);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ClineSubscriptionPlan } from "@cline/core";
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClineFreeModelLimitResetTime,
|
||||
} from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -8,6 +11,8 @@ import {
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineFreeModelLimitErrorMessage,
|
||||
isClineFreePromotionEndedErrorMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
@@ -29,6 +34,7 @@ import { formatCompactionDividerLabel } from "../utils/compaction-status";
|
||||
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
parseAskQuestionInput,
|
||||
parseEditorInput,
|
||||
@@ -129,12 +135,13 @@ function formatToolParams(
|
||||
case "read_files": {
|
||||
const info = parseReadFilesInput(rawInput);
|
||||
if (!info?.files.length) return fallback;
|
||||
const keys = buildReadFilesKeys(info.files);
|
||||
return info.files.map((f, i) => {
|
||||
const sl = f.startLine != null ? String(f.startLine) : "undefined";
|
||||
const el = f.endLine != null ? String(f.endLine) : "undefined";
|
||||
const sep = i > 0 ? "; " : "";
|
||||
return (
|
||||
<span key={`${i}:${f.path}`}>
|
||||
<span key={keys[i]}>
|
||||
{sep}
|
||||
{shortenPath(f.path)}
|
||||
<span fg="gray">
|
||||
@@ -478,14 +485,6 @@ function ClinePassLimitErrorView(props: {
|
||||
selectable
|
||||
content="Switch to Cline usage-based billing and retry with the Cline provider."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Interactive CLI: </text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="type /model, press tab to change provider, choose Cline, then retry."
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Headless CLI: </text>
|
||||
<text fg={props.defaultFg} selectable content="rerun with " />
|
||||
@@ -502,6 +501,71 @@ function ClinePassLimitErrorView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineFreeModelLimitErrorView(props: {
|
||||
message: string;
|
||||
defaultFg?: string;
|
||||
}) {
|
||||
const resetTime = extractClineFreeModelLimitResetTime(props.message);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Daily free model limit reached</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="You've reached today's free usage limit for this model."
|
||||
/>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={
|
||||
resetTime
|
||||
? `Try again in ${resetTime} or select another model.`
|
||||
: "Try again later or select another model."
|
||||
}
|
||||
/>
|
||||
<text fg="gray">Open the model selector with /model.</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={palette.act}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Free model promotion ended</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="The free promotion for this model has ended and it is no longer available."
|
||||
/>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="Select another model to continue."
|
||||
/>
|
||||
<text fg="gray">Open the model selector with /model.</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -626,6 +690,17 @@ export function ChatEntryView(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClineFreeModelLimitErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineFreeModelLimitErrorView
|
||||
defaultFg={defaultFg}
|
||||
message={entry.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
|
||||
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import type React from "react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
parseApplyPatchInput,
|
||||
parseEditorInput,
|
||||
parseReadFilesInput,
|
||||
@@ -22,13 +23,14 @@ export function formatApprovalParams(
|
||||
case "read_files": {
|
||||
const info = parseReadFilesInput(rawInput);
|
||||
if (!info?.files.length) break;
|
||||
const keys = buildReadFilesKeys(info.files);
|
||||
return info.files.map((f, i) => {
|
||||
const range =
|
||||
f.startLine != null
|
||||
? ` lines ${f.startLine}-${f.endLine ?? "end"}`
|
||||
: "";
|
||||
return (
|
||||
<text key={f.path} fg="gray" selectable>
|
||||
<text key={keys[i]} fg="gray" selectable>
|
||||
{" "}
|
||||
{shortenPath(f.path, 60)}
|
||||
{range && <span fg="gray">{range}</span>}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AgentEventDeps {
|
||||
}) => void;
|
||||
onTurnErrorReported: TuiProps["onTurnErrorReported"];
|
||||
verbose: boolean;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
@@ -45,6 +46,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
addUsageDelta,
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
modelId,
|
||||
} = deps;
|
||||
|
||||
// Compaction dividers that arrived while an assistant message was still
|
||||
@@ -224,7 +226,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
if (!event.recoverable || verbose) {
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error),
|
||||
text: formatCliErrorMessage(event.error, { modelId }),
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -289,6 +291,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
addUsageDelta,
|
||||
onTurnErrorReported,
|
||||
verbose,
|
||||
modelId,
|
||||
closeToolEntry,
|
||||
finalizeDanglingCompactionEntry,
|
||||
flushPendingCompactionEntries,
|
||||
|
||||
@@ -38,6 +38,7 @@ export function usePromptInputController(input: {
|
||||
onSubmit: TuiProps["onSubmit"];
|
||||
initialPrompt?: string;
|
||||
providerId: string;
|
||||
modelId?: string;
|
||||
configVerbose: boolean;
|
||||
refreshRepoStatus: () => void;
|
||||
setAppView: (view: AppView) => void;
|
||||
@@ -50,6 +51,7 @@ export function usePromptInputController(input: {
|
||||
onSubmit,
|
||||
initialPrompt,
|
||||
providerId,
|
||||
modelId,
|
||||
configVerbose,
|
||||
refreshRepoStatus,
|
||||
setAppView,
|
||||
@@ -377,7 +379,7 @@ export function usePromptInputController(input: {
|
||||
if (!turnErrorReportedRef.current) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(error),
|
||||
text: formatCliErrorMessage(error, { modelId }),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -393,6 +395,7 @@ export function usePromptInputController(input: {
|
||||
clearPasteAttachments,
|
||||
configVerbose,
|
||||
inputHistory,
|
||||
modelId,
|
||||
onSubmit,
|
||||
providerId,
|
||||
refreshRepoStatus,
|
||||
|
||||
@@ -724,6 +724,7 @@ function App(props: TuiProps) {
|
||||
addUsageDelta: session.addUsageDelta,
|
||||
onTurnErrorReported: props.onTurnErrorReported,
|
||||
verbose: props.config.verbose ?? false,
|
||||
modelId: props.config.modelId,
|
||||
});
|
||||
|
||||
const promptInput = usePromptInputController({
|
||||
@@ -733,6 +734,7 @@ function App(props: TuiProps) {
|
||||
onSubmit: props.onSubmit,
|
||||
initialPrompt: props.initialPrompt,
|
||||
providerId: props.config.providerId,
|
||||
modelId: props.config.modelId,
|
||||
configVerbose: props.config.verbose ?? false,
|
||||
refreshRepoStatus,
|
||||
setAppView,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
|
||||
|
||||
describe("buildReadFilesKeys", () => {
|
||||
it("produces unique keys when the same path is read twice", () => {
|
||||
const info = parseReadFilesInput({
|
||||
files: [{ path: "/a/SKILL.md" }, { path: "/a/SKILL.md" }],
|
||||
});
|
||||
const keys = buildReadFilesKeys(info?.files ?? []);
|
||||
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("produces unique keys for duplicate paths from the file_paths shape", () => {
|
||||
const info = parseReadFilesInput({
|
||||
file_paths: ["/a/SKILL.md", "/a/SKILL.md", "/b/SKILL.md"],
|
||||
});
|
||||
const keys = buildReadFilesKeys(info?.files ?? []);
|
||||
|
||||
expect(keys).toHaveLength(3);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("keeps distinct paths in unique keys", () => {
|
||||
const keys = buildReadFilesKeys([{ path: "/a.ts" }, { path: "/b.ts" }]);
|
||||
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
});
|
||||
|
||||
it("returns no keys for an empty list", () => {
|
||||
expect(buildReadFilesKeys([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,12 @@ export function parseReadFilesInput(input: unknown): ReadFilesInfo | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A read_files call can list the same path more than once, so the raw path is
|
||||
// not a unique React key. Prefix the array index to keep keys unique per row.
|
||||
export function buildReadFilesKeys(files: { path: string }[]): string[] {
|
||||
return files.map((f, i) => `${i}:${f.path}`);
|
||||
}
|
||||
|
||||
export interface RunCommandsInfo {
|
||||
commands: string[];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClineFreeModelLimitMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineFreeModelLimitErrorMessage,
|
||||
isClineFreePromotionEndedErrorMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
@@ -48,6 +51,9 @@ describe("cline-pass-errors", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
|
||||
"deepseek-v4-flash",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
@@ -67,4 +73,44 @@ describe("cline-pass-errors", () => {
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("recognizes and formats daily free model limits without usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
|
||||
|
||||
expect(isClineFreeModelLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClineFreeModelLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClineFreeModelLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).not.toContain("Error 429");
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Try again in 23h 59m",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"select another model",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
|
||||
"usage-based billing",
|
||||
);
|
||||
expect(
|
||||
isClineFreeModelLimitErrorMessage(getCliClineFreeModelLimitMessage(raw)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("formats model-not-found errors for removed free models", () => {
|
||||
const raw = new Error("Error 404: model not found");
|
||||
|
||||
expect(
|
||||
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
|
||||
).toContain("Free model promotion ended");
|
||||
expect(
|
||||
isClineFreePromotionEndedErrorMessage(
|
||||
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
formatCliErrorMessage(raw, { modelId: "vendor/retired-model" }),
|
||||
).toBe(raw.message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClineFreeModelLimitResetTime,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineFreeModelLimitError,
|
||||
isClineFreeModelLimitMessage,
|
||||
isClineModelNotFoundMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
@@ -39,6 +43,31 @@ export function getCliClinePassLimitMessage(message: string): string {
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODEL_PREFIX = "cline-free/";
|
||||
const CLINE_FREE_PROMOTION_ENDED_HEADER = "Free model promotion ended";
|
||||
const CLINE_FREE_MODEL_LIMIT_HEADER = "Daily free model limit reached";
|
||||
|
||||
export function getCliClineFreePromotionEndedMessage(): string {
|
||||
return [
|
||||
CLINE_FREE_PROMOTION_ENDED_HEADER,
|
||||
"The free promotion for this model has ended and it is no longer available.",
|
||||
"Select another model to continue.",
|
||||
"Open the model selector with /model.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function getCliClineFreeModelLimitMessage(message: string): string {
|
||||
const resetTime = extractClineFreeModelLimitResetTime(message);
|
||||
return [
|
||||
CLINE_FREE_MODEL_LIMIT_HEADER,
|
||||
"You've reached today's free usage limit for this model.",
|
||||
resetTime
|
||||
? `Try again in ${resetTime} or select another model.`
|
||||
: "Try again later or select another model.",
|
||||
"Open the model selector with /model.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
@@ -114,7 +143,55 @@ export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
// Detects that a deleted free model was requested: the backend answers "model
|
||||
// not found" once a free promotion ends and the cline-free/ model is removed.
|
||||
// The modelId gate keeps regular model-not-found errors on their generic path.
|
||||
export function isClineFreePromotionEndedErrorMessage(
|
||||
error: unknown,
|
||||
modelId?: string,
|
||||
): boolean {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "";
|
||||
if (
|
||||
message
|
||||
.toLowerCase()
|
||||
.includes(CLINE_FREE_PROMOTION_ENDED_HEADER.toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!modelId?.startsWith(CLINE_FREE_MODEL_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
return isClineModelNotFoundMessage(message);
|
||||
}
|
||||
|
||||
export function isClineFreeModelLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClineFreeModelLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineFreeModelLimitError" ||
|
||||
isClineFreeModelLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(error
|
||||
.toLowerCase()
|
||||
.includes(CLINE_FREE_MODEL_LIMIT_HEADER.toLowerCase()) ||
|
||||
isClineFreeModelLimitMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(
|
||||
error: unknown,
|
||||
options?: { modelId?: string },
|
||||
): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
@@ -126,6 +203,14 @@ export function formatCliErrorMessage(error: unknown): string {
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (isClineFreeModelLimitErrorMessage(error)) {
|
||||
return getCliClineFreeModelLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (isClineFreePromotionEndedErrorMessage(error, options?.modelId)) {
|
||||
return getCliClineFreePromotionEndedMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@ function createConfig(compaction?: Config["compaction"]): Config {
|
||||
}
|
||||
|
||||
describe("CLI compaction mode helpers", () => {
|
||||
it("defaults enabled compaction to basic truncation", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
|
||||
it("defaults enabled compaction to agentic summarization", () => {
|
||||
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
|
||||
expect(getCliCompactionMode(createConfig())).toBe(
|
||||
DEFAULT_CLI_COMPACTION_MODE,
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
|
||||
"Truncation",
|
||||
);
|
||||
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
|
||||
});
|
||||
|
||||
it("maps basic and off modes to core compaction config", () => {
|
||||
@@ -47,7 +45,6 @@ describe("CLI compaction mode helpers", () => {
|
||||
it("builds default and explicit core compaction config", () => {
|
||||
expect(buildCliCompactionConfig()).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
});
|
||||
expect(buildCliCompactionConfig("agentic")).toEqual({
|
||||
enabled: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
|
||||
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
|
||||
CliCompactionMode,
|
||||
"agentic" | "basic"
|
||||
> = "basic";
|
||||
> = "agentic";
|
||||
|
||||
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
|
||||
agentic: "agentic",
|
||||
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
|
||||
} as const satisfies Record<CliCompactionMode, string>;
|
||||
|
||||
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
|
||||
"Context compaction mode: agentic|basic|off (default: basic)";
|
||||
"Context compaction mode: agentic|basic|off (default: agentic)";
|
||||
|
||||
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
|
||||
|
||||
@@ -31,8 +31,11 @@ export function parseCliCompactionMode(
|
||||
}
|
||||
|
||||
export function buildCliCompactionConfig(
|
||||
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
|
||||
mode?: CliCompactionMode,
|
||||
): NonNullable<Config["compaction"]> {
|
||||
if (mode === undefined) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (mode === "off") {
|
||||
return { enabled: false };
|
||||
}
|
||||
@@ -43,9 +46,7 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
|
||||
if (config.compaction?.enabled === false) {
|
||||
return "off";
|
||||
}
|
||||
return config.compaction?.strategy === "agentic"
|
||||
? "agentic"
|
||||
: DEFAULT_CLI_COMPACTION_MODE;
|
||||
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
|
||||
}
|
||||
|
||||
export function applyCliCompactionMode(
|
||||
|
||||
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
|
||||
export async function prepareCliEnterpriseIntegration(
|
||||
input: ClineCoreStartInput,
|
||||
) {
|
||||
const workspacePath =
|
||||
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
|
||||
if (!workspacePath) {
|
||||
return undefined;
|
||||
}
|
||||
const bundle = await loadCliRemoteConfigBundle();
|
||||
if (!bundle) {
|
||||
return undefined;
|
||||
}
|
||||
captureRemoteConfigInitialized(bundle);
|
||||
return prepareRemoteConfigCoreIntegration({
|
||||
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
|
||||
workspacePath,
|
||||
pluginName: "enterprise",
|
||||
controlPlane: {
|
||||
name: "cline-account",
|
||||
|
||||
@@ -222,6 +222,37 @@ describe("handleEvent text formatting", () => {
|
||||
expect(errorOutput).toContain("--provider cline");
|
||||
});
|
||||
|
||||
it("formats daily free model limit agent errors before writing to stderr", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
|
||||
),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("Daily free model limit reached");
|
||||
expect(errorOutput).toContain("select another model");
|
||||
expect(errorOutput).not.toContain("usage-based billing");
|
||||
});
|
||||
|
||||
it("formats removed free model errors using the configured model id", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "error",
|
||||
error: new Error("Error 404: model not found"),
|
||||
recoverable: false,
|
||||
} as unknown as AgentEvent,
|
||||
{ modelId: "cline-free/retired-model" } as Config,
|
||||
);
|
||||
|
||||
expect(errorOutput).toContain("Free model promotion ended");
|
||||
expect(errorOutput).toContain("Select another model");
|
||||
});
|
||||
|
||||
it("suppresses heartbeat-only team progress messages", () => {
|
||||
handleTeamEvent({
|
||||
type: "run_progress",
|
||||
|
||||
@@ -204,7 +204,9 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
case "error":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (!event.recoverable || config.verbose) {
|
||||
writeErr(formatCliErrorMessage(event.error));
|
||||
writeErr(
|
||||
formatCliErrorMessage(event.error, { modelId: config.modelId }),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -39,6 +39,36 @@ describe("shouldZeroClineFreeModelCost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "cline-free/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "cline-free/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("parseArgs", () => {
|
||||
interactive: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
modeExplicitlySet: false,
|
||||
sandbox: false,
|
||||
acpMode: false,
|
||||
thinking: false,
|
||||
@@ -220,6 +221,15 @@ describe("parseArgs", () => {
|
||||
expect(parsedYolo.autoApproveOverride).toBe(true);
|
||||
});
|
||||
|
||||
it("marks explicit mode flags so persisted settings do not override them", () => {
|
||||
expect(parseArgs([]).modeExplicitlySet).toBe(false);
|
||||
expect(parseArgs(["Audit the repo"]).modeExplicitlySet).toBe(false);
|
||||
expect(parseArgs(["--plan"]).modeExplicitlySet).toBe(true);
|
||||
expect(parseArgs(["--act"]).modeExplicitlySet).toBe(true);
|
||||
expect(parseArgs(["--yolo"]).modeExplicitlySet).toBe(true);
|
||||
expect(parseArgs(["--zen", "do it"]).modeExplicitlySet).toBe(true);
|
||||
});
|
||||
|
||||
it("parses --zen flag for background hub dispatch", () => {
|
||||
const parsedLong = parseArgs(["--zen", "do it"]);
|
||||
expect(parsedLong.mode).toBe("zen");
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { GlobalSettings } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveStartupCompactionMode,
|
||||
resolveStartupMode,
|
||||
resolveStartupToolAutoApprove,
|
||||
} from "./startup-settings";
|
||||
|
||||
function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
autoUpdateEnabled: true,
|
||||
telemetryOptOut: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveStartupMode", () => {
|
||||
it("uses the parsed default when nothing is persisted", () => {
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "act", modeExplicitlySet: false },
|
||||
makeSettings(),
|
||||
),
|
||||
).toBe("act");
|
||||
});
|
||||
|
||||
it("restores the persisted plan/act mode when no mode flag is provided", () => {
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "act", modeExplicitlySet: false },
|
||||
makeSettings({ planActMode: "plan" }),
|
||||
),
|
||||
).toBe("plan");
|
||||
});
|
||||
|
||||
it("prefers an explicit mode flag over the persisted mode", () => {
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "act", modeExplicitlySet: true },
|
||||
makeSettings({ planActMode: "plan" }),
|
||||
),
|
||||
).toBe("act");
|
||||
expect(
|
||||
resolveStartupMode(
|
||||
{ mode: "yolo", modeExplicitlySet: true },
|
||||
makeSettings({ planActMode: "plan" }),
|
||||
),
|
||||
).toBe("yolo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupToolAutoApprove", () => {
|
||||
it("falls back to the built-in default when nothing is persisted", () => {
|
||||
expect(resolveStartupToolAutoApprove({}, makeSettings(), true)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the persisted auto-approve setting", () => {
|
||||
expect(
|
||||
resolveStartupToolAutoApprove(
|
||||
{},
|
||||
makeSettings({ toolAutoApprove: false }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers an explicit --auto-approve flag over the persisted setting", () => {
|
||||
expect(
|
||||
resolveStartupToolAutoApprove(
|
||||
{ autoApproveOverride: true },
|
||||
makeSettings({ toolAutoApprove: false }),
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveStartupToolAutoApprove(
|
||||
{ autoApproveOverride: false },
|
||||
makeSettings({ toolAutoApprove: true }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupCompactionMode", () => {
|
||||
it("returns undefined so Core's default applies when nothing is persisted", () => {
|
||||
expect(resolveStartupCompactionMode({}, makeSettings())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores the persisted strategy", () => {
|
||||
expect(
|
||||
resolveStartupCompactionMode(
|
||||
{},
|
||||
makeSettings({ compactionEnabled: true, compactionStrategy: "basic" }),
|
||||
),
|
||||
).toBe("basic");
|
||||
});
|
||||
|
||||
it("restores the off state even when a strategy is retained on disk", () => {
|
||||
expect(
|
||||
resolveStartupCompactionMode(
|
||||
{},
|
||||
makeSettings({ compactionEnabled: false, compactionStrategy: "basic" }),
|
||||
),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("prefers an explicit --compaction flag over the persisted mode", () => {
|
||||
expect(
|
||||
resolveStartupCompactionMode(
|
||||
{ compactionMode: "agentic" },
|
||||
makeSettings({ compactionEnabled: false }),
|
||||
),
|
||||
).toBe("agentic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GlobalSettings } from "@cline/core";
|
||||
import type { CliAgentMode, CliCompactionMode, ParsedArgs } from "./types";
|
||||
|
||||
/**
|
||||
* Resolves general settings at CLI startup with the precedence
|
||||
* explicit CLI flag -> persisted global setting -> built-in default,
|
||||
* so choices made in the TUI /settings panel survive restarts (see
|
||||
* https://github.com/cline/cline/issues/12158).
|
||||
*/
|
||||
|
||||
export function resolveStartupMode(
|
||||
args: Pick<ParsedArgs, "mode" | "modeExplicitlySet">,
|
||||
settings: GlobalSettings,
|
||||
): CliAgentMode {
|
||||
if (args.modeExplicitlySet) {
|
||||
return args.mode;
|
||||
}
|
||||
return settings.planActMode ?? args.mode;
|
||||
}
|
||||
|
||||
export function resolveStartupToolAutoApprove(
|
||||
args: Pick<ParsedArgs, "autoApproveOverride">,
|
||||
settings: GlobalSettings,
|
||||
defaultToolAutoApprove: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
args.autoApproveOverride ??
|
||||
settings.toolAutoApprove ??
|
||||
defaultToolAutoApprove
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns undefined when neither a flag nor a persisted value exists, so
|
||||
* callers fall through to Core's compaction default.
|
||||
*/
|
||||
export function resolveStartupCompactionMode(
|
||||
args: Pick<ParsedArgs, "compactionMode">,
|
||||
settings: GlobalSettings,
|
||||
): CliCompactionMode | undefined {
|
||||
if (args.compactionMode) {
|
||||
return args.compactionMode;
|
||||
}
|
||||
if (settings.compactionEnabled === false) {
|
||||
return "off";
|
||||
}
|
||||
return settings.compactionStrategy;
|
||||
}
|
||||
@@ -71,6 +71,8 @@ export interface ParsedArgs {
|
||||
interactive: boolean;
|
||||
outputMode: CliOutputMode;
|
||||
mode: CliAgentMode;
|
||||
/** Whether a mode flag (--plan/--act/--yolo/--zen) was explicitly provided */
|
||||
modeExplicitlySet?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
invalidTimeoutSeconds?: string;
|
||||
thinking: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import {
|
||||
ensureSchedulerHub,
|
||||
type HubScheduleClient,
|
||||
@@ -135,10 +136,11 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
const mode = await p.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
|
||||
{ value: "act", label: "Act", hint: "execute tasks" },
|
||||
{ value: "plan", label: "Plan", hint: "plan only" },
|
||||
],
|
||||
initialValue: "act",
|
||||
initialValue: "yolo",
|
||||
});
|
||||
if (isCancel(mode)) return;
|
||||
|
||||
@@ -214,8 +216,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
cronPattern,
|
||||
prompt: (prompt as string).trim(),
|
||||
provider: provider ?? "cline",
|
||||
model: model ?? "openai/gpt-5.3-codex",
|
||||
mode: (mode as string) === "plan" ? "plan" : "act",
|
||||
model: model ?? CLINE_DEFAULT_MODEL_ID,
|
||||
mode: mode as "act" | "plan" | "yolo",
|
||||
workspaceRoot: (workspace as string).trim(),
|
||||
systemPrompt,
|
||||
maxIterations,
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
import {
|
||||
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
|
||||
readConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __test__, configureConnectorCliLaunch } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLaunchSpec === undefined) {
|
||||
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
} else {
|
||||
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the CLI connect command for the detached daemon", () => {
|
||||
const expected = __test__.buildCliConnectCommand([]);
|
||||
|
||||
configureConnectorCliLaunch();
|
||||
|
||||
expect(readConnectorCliLaunchSpec()).toEqual({
|
||||
launcher: expected.launcher,
|
||||
connectArgsPrefix: expected.childArgs,
|
||||
cwd: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
@@ -94,4 +120,47 @@ describe("connector launch command", () => {
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds connector start arguments through the shared platform definition", () => {
|
||||
expect(
|
||||
__test__.buildConnectorStartArgs({
|
||||
channel: "telegram",
|
||||
values: { "-k": " 123456:token " },
|
||||
security: {
|
||||
enabled: true,
|
||||
values: { userId: " 987654321 " },
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
"telegram",
|
||||
"-k",
|
||||
"123456:token",
|
||||
"--allowed-user-id",
|
||||
"987654321",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the atomic restart command for an active connector", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "restart"),
|
||||
).toEqual(["--restart", "telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("starts an inactive connector directly", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "start"),
|
||||
).toEqual(["telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("rejects a channel-wide restart when multiple instances are active", () => {
|
||||
expect(() => __test__.resolveConnectorLaunchMode("telegram", 2)).toThrow(
|
||||
"cannot safely restart telegram: 2 instances are active",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when connector readiness times out", async () => {
|
||||
await expect(
|
||||
__test__.waitForConnectorState(() => false, 0),
|
||||
).rejects.toThrow("connector did not reach expected state within 0ms");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { listActiveConnectors } from "@cline/core";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
buildConnectorConnectArgs,
|
||||
CONNECTOR_PLATFORMS,
|
||||
listConnectorCatalog,
|
||||
setConnectorCliLaunchSpec,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
WebviewConnectorChannel,
|
||||
WebviewConnectorChannelsResponse,
|
||||
@@ -78,12 +79,21 @@ function buildCliConnectCommand(
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function configureConnectorCliLaunch(): void {
|
||||
const command = buildCliConnectCommand([]);
|
||||
setConnectorCliLaunchSpec({
|
||||
launcher: command.launcher,
|
||||
connectArgsPrefix: command.childArgs,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
|
||||
(platform) => supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
@@ -154,12 +164,15 @@ async function waitForConnectorState(
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
`connector did not reach expected state within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -177,32 +190,40 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
const securityInput = asRecord(args?.security);
|
||||
const rawSecurityValues = asRecord(securityInput?.values) ?? {};
|
||||
const securityValues: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(rawSecurityValues)) {
|
||||
if (typeof value === "string") {
|
||||
securityValues[key] = value.trim();
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
return [
|
||||
channel,
|
||||
...buildConnectorConnectArgs(platform, fieldValues, {
|
||||
enabled: securityInput?.enabled === true,
|
||||
values: securityValues,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function buildConnectorLaunchArgs(
|
||||
cliArgs: string[],
|
||||
mode: "start" | "restart",
|
||||
): string[] {
|
||||
return mode === "restart" ? ["--restart", ...cliArgs] : cliArgs;
|
||||
}
|
||||
|
||||
function resolveConnectorLaunchMode(
|
||||
channel: string,
|
||||
activeCount: number,
|
||||
): "start" | "restart" {
|
||||
if (activeCount > 1) {
|
||||
throw new Error(
|
||||
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
|
||||
);
|
||||
}
|
||||
return cliArgs;
|
||||
return activeCount === 1 ? "restart" : "start";
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
@@ -210,7 +231,13 @@ export async function startConnectorChannel(
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
const activeCount = listActiveConnectors().filter(
|
||||
(connector) => connector.type === channel,
|
||||
).length;
|
||||
const mode = resolveConnectorLaunchMode(channel, activeCount);
|
||||
const result = await runCliConnectCommand(
|
||||
buildConnectorLaunchArgs(cliArgs, mode),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
@@ -227,7 +254,11 @@ export async function startConnectorChannel(
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
buildConnectorLaunchArgs,
|
||||
buildConnectorStartArgs,
|
||||
normalizeConnectorError,
|
||||
resolveConnectorLaunchMode,
|
||||
waitForConnectorState,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
@@ -241,7 +272,9 @@ export async function stopConnectorChannel(
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
// `--stop` must precede the channel: the connect command uses
|
||||
// passThroughOptions, so flags after the channel go to the adapter.
|
||||
const result = await runCliConnectCommand(["--stop", channel]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
rejectAllPendingApprovals,
|
||||
requestToolApprovalFromWebview,
|
||||
} from "./approvals";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import { workspaceRoot } from "./deps";
|
||||
import {
|
||||
formatClientName,
|
||||
@@ -90,6 +91,7 @@ export async function syncHubClientsAndSessions(
|
||||
}
|
||||
|
||||
export async function attachHub(ctx: HubContext): Promise<void> {
|
||||
configureConnectorCliLaunch();
|
||||
const hub = await ensureDetachedHubServer(workspaceRoot);
|
||||
ctx.hubUrl = hub.url;
|
||||
ctx.hubAuthToken = hub.authToken;
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
|
||||
readHubScheduleMode,
|
||||
} from "@cline/shared";
|
||||
import { asTrimmedString, toPositiveInt } from "./utils";
|
||||
|
||||
let scheduleService: HubScheduleService | undefined;
|
||||
@@ -36,6 +42,31 @@ async function clientCommand(
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function routineScheduleTiming(
|
||||
args?: Record<string, unknown>,
|
||||
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
|
||||
if (args?.schedule_type === "once") {
|
||||
const runAt =
|
||||
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
|
||||
return Number.isFinite(runAt)
|
||||
? {
|
||||
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
return cronPattern ? { cronPattern } : undefined;
|
||||
}
|
||||
|
||||
function asTrimmedStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const values = value
|
||||
.map((item) => asTrimmedString(item))
|
||||
.filter((item): item is string => item !== undefined);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -76,23 +107,23 @@ export async function handleRoutineScheduleCommand(
|
||||
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
|
||||
"createSchedule requires name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
cronPattern,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
mode: readHubScheduleMode(args, "yolo"),
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
systemPrompt: asTrimmedString(args?.system_prompt),
|
||||
@@ -100,12 +131,7 @@ export async function handleRoutineScheduleCommand(
|
||||
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags:
|
||||
Array.isArray(args?.tags) && args.tags.length > 0
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: undefined,
|
||||
tags: asTrimmedStringArray(args?.tags),
|
||||
});
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
@@ -113,25 +139,26 @@ export async function handleRoutineScheduleCommand(
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "update_routine_schedule") {
|
||||
const mode = readHubScheduleMode(args);
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const timing = routineScheduleTiming(args);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
|
||||
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
cronPattern,
|
||||
...timing,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd) ?? null,
|
||||
systemPrompt:
|
||||
@@ -148,11 +175,7 @@ export async function handleRoutineScheduleCommand(
|
||||
: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: Array.isArray(args?.tags)
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: [],
|
||||
tags: asTrimmedStringArray(args?.tags) ?? [],
|
||||
});
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
|
||||
if (
|
||||
normalizedType === "code-sidecar" ||
|
||||
normalizedType === "code-sidecar-approvals" ||
|
||||
normalizedType === "code-sidecar-observer" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { listActiveConnectors } from "@cline/core";
|
||||
import type { WebviewHubState } from "../webview-protocol";
|
||||
import {
|
||||
clientSummariesPayload,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
ONE_TIME_SCHEDULE_CRON_PATTERN,
|
||||
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
Circle,
|
||||
Eye,
|
||||
@@ -72,6 +77,7 @@ interface RoutineSchedule {
|
||||
scheduleId: string;
|
||||
name: string;
|
||||
cronPattern: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -145,7 +151,7 @@ interface ProcessContext {
|
||||
}
|
||||
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
cline: [CLINE_DEFAULT_MODEL_ID],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
"openai-native": ["gpt-5.3-codex"],
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
@@ -170,13 +176,9 @@ interface RoutineFormState {
|
||||
prompt: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: "act" | "plan";
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
maxIterations: string;
|
||||
timeoutSeconds: string;
|
||||
maxParallel: string;
|
||||
tags: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -203,6 +205,15 @@ function formatDateTime(value?: DateTimeValue | null): string {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function getOneTimeScheduleRunAt(
|
||||
schedule: RoutineSchedule,
|
||||
): number | undefined {
|
||||
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
|
||||
return typeof runAt === "number" && Number.isFinite(runAt)
|
||||
? runAt
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatScheduleModel(schedule: RoutineSchedule): string {
|
||||
const provider =
|
||||
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
|
||||
@@ -226,7 +237,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
|
||||
model:
|
||||
schedule.modelSelection?.modelId?.trim() ||
|
||||
schedule.model?.trim() ||
|
||||
"openai/gpt-5.3-codex",
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,20 +252,27 @@ function formatExecutionResult(execution?: RoutineExecution): string {
|
||||
return when === "-" ? status : `${status} at ${when}`;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(text: string): number | undefined {
|
||||
const trimmed = text.trim();
|
||||
function asTrimmedFormString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(value: unknown): number | undefined {
|
||||
const trimmed = asTrimmedFormString(value);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
const parsedValue = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] | undefined {
|
||||
const tags = text
|
||||
function parseTags(value: unknown): string[] | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const tags = value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
@@ -382,14 +400,10 @@ export function RoutineSchedulesContent() {
|
||||
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
mode: "act",
|
||||
model: CLINE_DEFAULT_MODEL_ID,
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
systemPrompt: "",
|
||||
maxIterations: "",
|
||||
timeoutSeconds: "",
|
||||
maxParallel: "1",
|
||||
tags: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -702,13 +716,9 @@ export function RoutineSchedulesContent() {
|
||||
prompt: "Review PRs opened yesterday and summarize issues.",
|
||||
provider: preferredProvider,
|
||||
model: preferredModel,
|
||||
mode: "act",
|
||||
workspaceRoot: context.workspaceRoot || context.cwd,
|
||||
cwd: context.cwd || "",
|
||||
systemPrompt: "",
|
||||
maxIterations: "",
|
||||
timeoutSeconds: "",
|
||||
maxParallel: "1",
|
||||
tags: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -716,6 +726,9 @@ export function RoutineSchedulesContent() {
|
||||
};
|
||||
|
||||
const openEditDialog = (schedule: RoutineSchedule) => {
|
||||
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
|
||||
return;
|
||||
}
|
||||
const { provider, model } = getScheduleProviderModel(schedule);
|
||||
const parsedCron = parseCronPattern(schedule.cronPattern);
|
||||
setEditingSchedule(schedule);
|
||||
@@ -738,22 +751,12 @@ export function RoutineSchedulesContent() {
|
||||
prompt: schedule.prompt,
|
||||
provider,
|
||||
model,
|
||||
mode: schedule.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: schedule.workspaceRoot ?? "",
|
||||
cwd: schedule.cwd ?? "",
|
||||
systemPrompt: schedule.systemPrompt ?? "",
|
||||
maxIterations:
|
||||
typeof schedule.maxIterations === "number"
|
||||
? String(schedule.maxIterations)
|
||||
: "",
|
||||
timeoutSeconds:
|
||||
typeof schedule.timeoutSeconds === "number"
|
||||
? String(schedule.timeoutSeconds)
|
||||
: "",
|
||||
maxParallel:
|
||||
typeof schedule.maxParallel === "number"
|
||||
? String(schedule.maxParallel)
|
||||
: "1",
|
||||
tags: schedule.tags?.join(",") ?? "",
|
||||
enabled: schedule.enabled,
|
||||
});
|
||||
@@ -761,7 +764,7 @@ export function RoutineSchedulesContent() {
|
||||
};
|
||||
|
||||
const submitCreateForm = async () => {
|
||||
const name = createForm.name.trim();
|
||||
const name = asTrimmedFormString(createForm.name);
|
||||
if (!name) {
|
||||
setCreateFormError("Routine name is required.");
|
||||
return;
|
||||
@@ -775,12 +778,12 @@ export function RoutineSchedulesContent() {
|
||||
setCreateFormError("Select at least one day and a valid time.");
|
||||
return;
|
||||
}
|
||||
const prompt = createForm.prompt.trim();
|
||||
const prompt = asTrimmedFormString(createForm.prompt);
|
||||
if (!prompt) {
|
||||
setCreateFormError("Prompt is required.");
|
||||
return;
|
||||
}
|
||||
const workspaceRoot = createForm.workspaceRoot.trim();
|
||||
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
setCreateFormError("Workspace root is required.");
|
||||
return;
|
||||
@@ -789,18 +792,17 @@ export function RoutineSchedulesContent() {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const provider =
|
||||
normalizeProviderId(createForm.provider) ||
|
||||
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
|
||||
availableProviders[0] ||
|
||||
"cline";
|
||||
const model =
|
||||
createForm.model.trim() ||
|
||||
asTrimmedFormString(createForm.model) ||
|
||||
(visibleProviderModels[provider] ?? [])[0] ||
|
||||
"openai/gpt-5.3-codex";
|
||||
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
|
||||
CLINE_DEFAULT_MODEL_ID;
|
||||
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
|
||||
const timeoutSeconds = parseOptionalPositiveInt(
|
||||
createForm.timeoutSeconds,
|
||||
);
|
||||
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
|
||||
const tags = parseTags(createForm.tags);
|
||||
const command = editingSchedule
|
||||
? "update_routine_schedule"
|
||||
@@ -814,19 +816,16 @@ export function RoutineSchedulesContent() {
|
||||
prompt,
|
||||
provider,
|
||||
model,
|
||||
mode: createForm.mode,
|
||||
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
|
||||
workspace_root: workspaceRoot,
|
||||
cwd: createForm.cwd.trim() || undefined,
|
||||
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
|
||||
system_prompt: editingSchedule
|
||||
? createForm.systemPrompt.trim() || null
|
||||
: createForm.systemPrompt.trim() || undefined,
|
||||
max_iterations: editingSchedule
|
||||
? (maxIterations ?? null)
|
||||
: maxIterations,
|
||||
? systemPrompt || null
|
||||
: systemPrompt || undefined,
|
||||
timeout_seconds: editingSchedule
|
||||
? (timeoutSeconds ?? null)
|
||||
: timeoutSeconds,
|
||||
max_parallel: maxParallel,
|
||||
max_parallel: 1,
|
||||
enabled: createForm.enabled,
|
||||
tags: tags ?? [],
|
||||
});
|
||||
@@ -948,7 +947,9 @@ export function RoutineSchedulesContent() {
|
||||
{schedule.mode}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.cronPattern}
|
||||
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
|
||||
: schedule.cronPattern}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -967,7 +968,10 @@ export function RoutineSchedulesContent() {
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
disabled={
|
||||
isBusy ||
|
||||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -1373,27 +1377,6 @@ export function RoutineSchedulesContent() {
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Mode</Label>
|
||||
<Select
|
||||
value={createForm.mode}
|
||||
onValueChange={(value) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
mode: value === "plan" ? "plan" : "act",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="act">act</SelectItem>
|
||||
<SelectItem value="plan">plan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-workspace">Workspace root</Label>
|
||||
<Input
|
||||
@@ -1408,20 +1391,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-cwd">CWD (optional)</Label>
|
||||
<Input
|
||||
id="routine-cwd"
|
||||
value={createForm.cwd}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="routine-system-prompt">
|
||||
System prompt (optional)
|
||||
@@ -1439,23 +1408,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-max-iterations">
|
||||
Max iterations (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="routine-max-iterations"
|
||||
value={createForm.maxIterations}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
maxIterations: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-timeout">
|
||||
Timeout seconds (optional)
|
||||
@@ -1473,21 +1425,6 @@ export function RoutineSchedulesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-max-parallel">Max parallel</Label>
|
||||
<Input
|
||||
id="routine-max-parallel"
|
||||
value={createForm.maxParallel}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
maxParallel: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="routine-tags">
|
||||
Tags (comma-separated, optional)
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
|
||||
- New sidebar update indicator: once an update has been downloaded, an accent-colored icon stays in the sidebar showing the new version with a one-click restart, so the update is still reachable after you dismiss the toast (and restart failures are now surfaced instead of silently doing nothing).
|
||||
- No more appearance flash on launch — the app paints in your saved (or system) light/dark theme before the first frame.
|
||||
- The header and sidebar now show the full workspace name and git branch, and lay out correctly on narrow windows; a transient git lookup no longer wipes a valid branch name back to "no git".
|
||||
- Cleaner collapsed-sidebar settings layout: compact width, left-aligned navigation, stacked account details.
|
||||
- Clarified the auto-update setting — it's now "Keep CLI up to date" and explains that it governs the `cline` terminal command, not the app itself (the app updates separately).
|
||||
- Toggle switches now use a solid accent color when on, for clearer contrast.
|
||||
|
||||
## 0.0.5
|
||||
|
||||
- Major performance overhaul: the app now feels snappy end-to-end. The animated background renders at a locked 60fps instead of ~10fps, typing in the composer no longer stutters (245 slow keystrokes → 3), streaming responses coalesce updates instead of re-rendering the whole chat per token, and app boot fetches the provider catalog once instead of three times.
|
||||
- The native folder picker and command execution no longer freeze the app while the sidecar writes session logs or discovers your editor.
|
||||
- Fixed the composer getting stuck on "Agent is working..." after queued turns finished.
|
||||
- Added a Cline API key path to onboarding, and you can now cancel a pending browser sign-in instead of being stuck waiting for it.
|
||||
- Fixed window dragging.
|
||||
- MCP server cards are now consistent across marketplace views, with a single uninstall action and setup guidance shown on installed servers.
|
||||
- Fixed agentic compaction silently falling back to basic compaction for OpenAI-Compatible providers, and manual /compact never actually reaching the model when auto-compaction was off.
|
||||
|
||||
## 0.0.4
|
||||
|
||||
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
|
||||
- New first-run onboarding flow to get you set up on launch.
|
||||
- Drag and drop files directly onto the chat to attach them.
|
||||
- Image attachments now display inline in the chat transcript.
|
||||
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
|
||||
- New custom overlay title bar with in-app navigation.
|
||||
- Redesigned channel setup as expandable cards.
|
||||
- Added a setting to replay the new-user experience.
|
||||
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
|
||||
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
|
||||
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
|
||||
- Headless routines now default to YOLO mode so they can run unattended.
|
||||
- Fixed request metering for the SAP AI Core provider.
|
||||
|
||||
## 0.0.3
|
||||
|
||||
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
|
||||
|
||||
## 0.0.2
|
||||
|
||||
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
|
||||
|
||||
@@ -16,6 +16,20 @@ From `apps/examples/desktop-app/`:
|
||||
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
## Login Shell PATH Resolution
|
||||
|
||||
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
|
||||
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
|
||||
agent-run commands would miss Homebrew-installed tools like `gh` even though
|
||||
they work fine from a terminal. At startup the sidecar asks the user's login
|
||||
shell — read from the account database via `getpwuid`, falling back to
|
||||
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
|
||||
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
|
||||
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
|
||||
API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
|
||||
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
|
||||
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
|
||||
|
||||
## Web Visual System
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
@@ -86,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
|
||||
Startup flow:
|
||||
|
||||
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
|
||||
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
|
||||
2. The desktop backend starts the Bun sidecar, which discovers or starts the
|
||||
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
|
||||
for desktop commands, queries, and pushed events.
|
||||
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
|
||||
4. Tool approval updates are pushed from the backend instead of polled from the UI.
|
||||
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
|
||||
@@ -107,8 +123,8 @@ Desktop transport envelope:
|
||||
## Key Files
|
||||
|
||||
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
|
||||
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
|
||||
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
|
||||
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
|
||||
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
|
||||
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
|
||||
@@ -143,5 +159,7 @@ Logging can be configured with the same environment variables as the CLI:
|
||||
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
|
||||
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
|
||||
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
|
||||
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
|
||||
The next desktop or CLI Hub connection will reuse a compatible running Hub or
|
||||
replace an incompatible one through the shared discovery path.
|
||||
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The sidecar is a single Bun process that handles the desktop backend runtime directly.
|
||||
The sidecar is a Bun process that adapts the desktop UI and native operations to
|
||||
the shared Cline Hub.
|
||||
|
||||
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
|
||||
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
|
||||
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
|
||||
sidecar does not own a private agent runtime Hub.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -14,7 +17,7 @@ sidecar/
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # In-process chat session management
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
├── types.ts # Shared types
|
||||
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Chat Sessions — In-Process via LocalRuntimeHost
|
||||
### 1. Chat Sessions — Shared Hub Client
|
||||
|
||||
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
|
||||
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
|
||||
the same compatible Hub discovered by the CLI or starts the canonical detached
|
||||
Hub when the desktop is the first client:
|
||||
|
||||
```typescript
|
||||
import { LocalRuntimeHost } from "@cline/core";
|
||||
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
// Push approval request to frontend via WebSocket event
|
||||
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Tool Approval — In-Memory Promise Resolution
|
||||
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
No more file-system watchers. Tool approvals use in-memory promise maps:
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
session. Desktop approvals use in-memory promise maps while the webview is
|
||||
online:
|
||||
|
||||
```typescript
|
||||
const pendingApprovals = new Map<string, {
|
||||
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
|
||||
|
||||
### 5. Routine Schedules — Direct Hub Commands
|
||||
|
||||
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
|
||||
Routine operations use the same connected Hub client as chat session
|
||||
observation. They never start a second in-process Hub:
|
||||
|
||||
```typescript
|
||||
import { ensureHubServer, sendHubCommand } from "@cline/core";
|
||||
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
|
||||
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
|
||||
await ctx.hubClient.command("schedule.list", { limit: 200 });
|
||||
```
|
||||
|
||||
### 6. Native Commands
|
||||
@@ -122,7 +138,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | `LocalRuntimeHost` in-process |
|
||||
| `chat_session_command` | shared Hub through `ClineCore` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `save_provider_settings` | `saveLocalProviderSettings` |
|
||||
@@ -137,14 +153,14 @@ Supported commands:
|
||||
| `list_mcp_servers` | Direct file I/O |
|
||||
| `upsert_mcp_server` | Direct file I/O |
|
||||
| `delete_mcp_server` | Direct file I/O |
|
||||
| `get_git_branch` | `execFileSync("git", ...)` |
|
||||
| `list_git_branches` | `execFileSync("git", ...)` |
|
||||
| `checkout_git_branch` | `execFileSync("git", ...)` |
|
||||
| `get_git_branch` | async `execFile("git", ...)` |
|
||||
| `list_git_branches` | async `execFile("git", ...)` |
|
||||
| `checkout_git_branch` | async `execFile("git", ...)` |
|
||||
| `search_workspace_files` | `getFileIndex` |
|
||||
| `get_process_context` | In-memory context |
|
||||
| `poll_tool_approvals` | In-memory pending map |
|
||||
| `respond_tool_approval` | In-memory promise resolution |
|
||||
| `list_routine_schedules` | local hub schedule commands |
|
||||
| `list_routine_schedules` | shared Hub schedule commands |
|
||||
| `list_user_instruction_configs` | Direct core API |
|
||||
| `pick_workspace_directory` | OS native dialog |
|
||||
| `open_mcp_settings_file` | OS `open` command |
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
materializeUserFiles,
|
||||
reconcileQueuedAttachments,
|
||||
sessionAttachmentsDir,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import type { LiveSession } from "./types";
|
||||
|
||||
const sessionId = "attachment-test-session";
|
||||
let previousSessionDataDir: string | undefined;
|
||||
let testSessionDataDir: string;
|
||||
|
||||
function createSession(): LiveSession {
|
||||
return {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("materialized attachment lifecycle", () => {
|
||||
it("only deletes files inside the session attachments dir", () => {
|
||||
const [staged] = materializeUserFiles(sessionId, [
|
||||
{ name: "notes.txt", content: "hello" },
|
||||
]) as string[];
|
||||
const outside = join(testSessionDataDir, "outside.txt");
|
||||
writeFileSync(outside, "keep me", "utf8");
|
||||
|
||||
deleteMaterializedAttachments(sessionId, [staged, outside]);
|
||||
|
||||
expect(existsSync(staged)).toBe(false);
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes consumed files when the turn ends", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: files,
|
||||
},
|
||||
],
|
||||
files,
|
||||
);
|
||||
|
||||
markQueuedAttachmentsSubmitted(session, "pending_1");
|
||||
expect(existsSync(files[0] as string)).toBe(true);
|
||||
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
expect(existsSync(files[0] as string)).toBe(false);
|
||||
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps files for a submitted prompt that gets requeued", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: files,
|
||||
},
|
||||
],
|
||||
files,
|
||||
);
|
||||
markQueuedAttachmentsSubmitted(session, "pending_1");
|
||||
|
||||
// Drain send failed → prompt is back in the queue snapshot.
|
||||
reconcileQueuedAttachments(session, ["pending_1"]);
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
expect(existsSync(files[0] as string)).toBe(true);
|
||||
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
|
||||
});
|
||||
|
||||
it("tracks files as consumed when the prompt is no longer queued", () => {
|
||||
const session = createSession();
|
||||
const files = materializeUserFiles(sessionId, [
|
||||
{ name: "a.txt", content: "a" },
|
||||
]) as string[];
|
||||
|
||||
trackQueuedAttachments(session, [], files);
|
||||
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
|
||||
expect(session.consumedAttachmentFiles?.size).toBe(1);
|
||||
});
|
||||
|
||||
it("discards all tracked files on session end", () => {
|
||||
const session = createSession();
|
||||
const queued = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const consumed = materializeUserFiles(sessionId, [
|
||||
{ name: "consumed.txt", content: "c" },
|
||||
]) as string[];
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
[
|
||||
{
|
||||
id: "pending_1",
|
||||
prompt: "p",
|
||||
delivery: "queue",
|
||||
attachmentCount: 1,
|
||||
userFiles: queued,
|
||||
},
|
||||
],
|
||||
queued,
|
||||
);
|
||||
trackQueuedAttachments(session, [], consumed);
|
||||
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
|
||||
expect(existsSync(queued[0] as string)).toBe(false);
|
||||
expect(existsSync(consumed[0] as string)).toBe(false);
|
||||
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve, sep } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { SessionPendingPrompt } from "@cline/core";
|
||||
import { sharedSessionDataDir } from "./paths";
|
||||
import type { ChatTurnAttachments, LiveSession } from "./types";
|
||||
|
||||
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
|
||||
if (!session.queuedAttachmentFiles) {
|
||||
session.queuedAttachmentFiles = new Map();
|
||||
}
|
||||
return session.queuedAttachmentFiles;
|
||||
}
|
||||
|
||||
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
|
||||
if (!session.consumedAttachmentFiles) {
|
||||
session.consumedAttachmentFiles = new Map();
|
||||
}
|
||||
return session.consumedAttachmentFiles;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Materialized user-attachment lifecycle
|
||||
//
|
||||
// Non-image attachments arrive from the webview as inline content and are
|
||||
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
|
||||
// load them by path at turn start. The sidecar owns these files and must
|
||||
// delete them once consumed (turn completed) or discarded (queued prompt
|
||||
// removed / session ended) — otherwise user data accumulates on disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sessionAttachmentsDir(sessionId: string): string {
|
||||
return join(sharedSessionDataDir(), sessionId, "user-attachments");
|
||||
}
|
||||
|
||||
export function materializeUserFiles(
|
||||
sessionId: string,
|
||||
files: ChatTurnAttachments["userFiles"],
|
||||
): string[] | undefined {
|
||||
if (!files?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const attachmentDir = sessionAttachmentsDir(sessionId);
|
||||
mkdirSync(attachmentDir, { recursive: true });
|
||||
return files.map((file) => {
|
||||
const requestedName = basename(file.name.trim());
|
||||
const safeName =
|
||||
requestedName && requestedName !== "." && requestedName !== ".."
|
||||
? requestedName
|
||||
: "attachment.txt";
|
||||
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
|
||||
writeFileSync(path, file.content, "utf8");
|
||||
return path;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete materialized attachment files. Only paths inside the session's
|
||||
* user-attachments directory are removed, so files referenced from elsewhere
|
||||
* (e.g. `@`-mentions) are never touched.
|
||||
*/
|
||||
export function deleteMaterializedAttachments(
|
||||
sessionId: string,
|
||||
paths: string[] | undefined,
|
||||
): void {
|
||||
if (!paths?.length) return;
|
||||
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
|
||||
for (const path of paths) {
|
||||
if (!resolve(path).startsWith(attachmentDir)) continue;
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; leftover files are removed with the session dir.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track files staged for a queued/steered prompt so they can be deleted once
|
||||
* the prompt is consumed or discarded. If the prompt is no longer in the
|
||||
* queue (already submitted), the files are tracked as consumed and deleted
|
||||
* when the running turn finishes.
|
||||
*/
|
||||
export function trackQueuedAttachments(
|
||||
session: LiveSession | undefined,
|
||||
prompts: SessionPendingPrompt[],
|
||||
userFiles: string[] | undefined,
|
||||
): void {
|
||||
if (!session || !userFiles?.length) return;
|
||||
const match = prompts.find((prompt) =>
|
||||
isDeepStrictEqual(prompt.userFiles, userFiles),
|
||||
);
|
||||
if (match) {
|
||||
queuedFilesMap(session).set(match.id, userFiles);
|
||||
} else {
|
||||
// Not in the queue → already being consumed by the running turn. Key by a
|
||||
// fresh id so it never collides with a prompt-id key used elsewhere in the
|
||||
// consumed bucket.
|
||||
consumedFilesMap(session).set(randomUUID(), userFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a submitted queued prompt's files into the consumed bucket. */
|
||||
export function markQueuedAttachmentsSubmitted(
|
||||
session: LiveSession | undefined,
|
||||
promptId: string,
|
||||
): void {
|
||||
const files = session?.queuedAttachmentFiles?.get(promptId);
|
||||
if (!session || !files) return;
|
||||
session.queuedAttachmentFiles?.delete(promptId);
|
||||
consumedFilesMap(session).set(promptId, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt id reappearing in the queue means a submitted prompt was requeued
|
||||
* (e.g. the drain send failed) — move its files back to the queued bucket so
|
||||
* the turn-end flush does not delete files still pending consumption.
|
||||
*/
|
||||
export function reconcileQueuedAttachments(
|
||||
session: LiveSession | undefined,
|
||||
queuedPromptIds: string[],
|
||||
): void {
|
||||
if (!session?.consumedAttachmentFiles?.size) return;
|
||||
for (const id of queuedPromptIds) {
|
||||
const files = session.consumedAttachmentFiles.get(id);
|
||||
if (!files) continue;
|
||||
session.consumedAttachmentFiles.delete(id);
|
||||
queuedFilesMap(session).set(id, files);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete files for prompts whose turn has finished. */
|
||||
export function flushConsumedAttachments(
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
): void {
|
||||
if (!session?.consumedAttachmentFiles?.size) return;
|
||||
for (const files of session.consumedAttachmentFiles.values()) {
|
||||
deleteMaterializedAttachments(sessionId, files);
|
||||
}
|
||||
session.consumedAttachmentFiles.clear();
|
||||
}
|
||||
|
||||
/** Delete every tracked file for a session (queued prompts are discarded). */
|
||||
export function discardAllTrackedAttachments(
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
): void {
|
||||
if (!session) return;
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
if (!session.queuedAttachmentFiles?.size) return;
|
||||
for (const files of session.queuedAttachmentFiles.values()) {
|
||||
deleteMaterializedAttachments(sessionId, files);
|
||||
}
|
||||
session.queuedAttachmentFiles.clear();
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
@@ -128,6 +129,51 @@ describe("hasProviderChanged", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathless session starts", () => {
|
||||
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
});
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
sessionManager: { start },
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
enableTools: true,
|
||||
},
|
||||
})) as {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionId: "session-pathless",
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-send connection updates", () => {
|
||||
const baseConfig = {
|
||||
provider: "cline",
|
||||
@@ -141,7 +187,7 @@ describe("first-send connection updates", () => {
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const updateSessionConnection = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
const send = vi.fn(async (_input?: unknown) => ({
|
||||
text: "done",
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
@@ -208,6 +254,266 @@ describe("first-send connection updates", () => {
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows an image-only user turn", async () => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "",
|
||||
attachments: {
|
||||
userImages: ["data:image/png;base64,aGVsbG8="],
|
||||
userFiles: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery: undefined,
|
||||
userImages: ["data:image/png;base64,aGVsbG8="],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"queue",
|
||||
] as const)("forwards file attachments for %s delivery", async (delivery) => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
|
||||
);
|
||||
let sentFileContent: string | undefined;
|
||||
send.mockImplementation(async (input?: unknown) => {
|
||||
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
|
||||
if (files?.[0]) {
|
||||
sentFileContent = readFileSync(files[0], "utf8");
|
||||
}
|
||||
return { text: "done", finishReason: "completed", messages: [] };
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery,
|
||||
attachments: {
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
const input = send.mock.calls[0]?.[0] as
|
||||
| { userFiles?: string[] }
|
||||
| undefined;
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: "",
|
||||
delivery,
|
||||
userImages: undefined,
|
||||
userFiles: [expect.stringMatching(/notes\.txt$/)],
|
||||
});
|
||||
expect(sentFileContent).toBe("hello");
|
||||
if (delivery === "queue") {
|
||||
// Queued attachments stay on disk until the prompt is consumed.
|
||||
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
|
||||
} else {
|
||||
// Immediate turns delete the materialized file once the send resolves.
|
||||
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes materialized attachments when a queued prompt is removed", async () => {
|
||||
const { ctx, send, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-remove-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const queue: Array<{
|
||||
id: string;
|
||||
prompt: string;
|
||||
delivery: "queue";
|
||||
attachmentCount: number;
|
||||
userFiles?: string[];
|
||||
}> = [];
|
||||
const manager = ctx.sessionManager as unknown as {
|
||||
send: typeof send;
|
||||
pendingPrompts: {
|
||||
list: (input: unknown) => Promise<unknown[]>;
|
||||
delete: (input: {
|
||||
sessionId: string;
|
||||
promptId: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
manager.send = vi.fn(async (input?: unknown) => {
|
||||
const { prompt, userFiles } = input as {
|
||||
prompt: string;
|
||||
userFiles?: string[];
|
||||
};
|
||||
queue.push({
|
||||
id: "pending_1",
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
attachmentCount: userFiles?.length ?? 0,
|
||||
userFiles,
|
||||
});
|
||||
return undefined;
|
||||
}) as unknown as typeof send;
|
||||
manager.pendingPrompts = {
|
||||
list: vi.fn(async () => [...queue]),
|
||||
delete: vi.fn(async ({ promptId }) => {
|
||||
const index = queue.findIndex((entry) => entry.id === promptId);
|
||||
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
|
||||
return {
|
||||
sessionId,
|
||||
prompts: [...queue],
|
||||
prompt: removed,
|
||||
removed: index >= 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "send",
|
||||
sessionId,
|
||||
prompt: "queued with file",
|
||||
delivery: "queue",
|
||||
attachments: {
|
||||
userFiles: [{ name: "notes.txt", content: "hello" }],
|
||||
},
|
||||
});
|
||||
const filePath = queue[0]?.userFiles?.[0] ?? "";
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(
|
||||
ctx.liveSessions
|
||||
.get(sessionId)
|
||||
?.queuedAttachmentFiles?.get("pending_1"),
|
||||
).toEqual([filePath]);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "remove_pending_prompt",
|
||||
sessionId,
|
||||
promptId: "pending_1",
|
||||
});
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
expect(
|
||||
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes tracked attachments when a session is reset", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-reset-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const [consumedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "consumed.txt", content: "c" },
|
||||
]) as string[];
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) throw new Error("missing session");
|
||||
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
|
||||
session.consumedAttachmentFiles = new Map([
|
||||
["pending_2", [consumedFile]],
|
||||
]);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "reset",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(existsSync(consumedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.has(sessionId)).toBe(false);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves tracked attachments across re-attach", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-attachments-attach-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session) throw new Error("missing session");
|
||||
const queuedMap = new Map([["pending_1", [queuedFile]]]);
|
||||
session.queuedAttachmentFiles = queuedMap;
|
||||
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
|
||||
async () => ({
|
||||
status: "idle",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "attach",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(true);
|
||||
expect(
|
||||
ctx.liveSessions
|
||||
.get(sessionId)
|
||||
?.queuedAttachmentFiles?.get("pending_1"),
|
||||
).toEqual([queuedFile]);
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates a changed connection before sending", async () => {
|
||||
const { ctx, send, sessionId, updateSessionConnection } = createContext({
|
||||
config: { ...baseConfig, reasoningEffort: "low" },
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
buildConnectionUpdate,
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCore,
|
||||
type CoreSessionConfig,
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import { buildClineSystemPrompt } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import type {
|
||||
@@ -162,6 +168,8 @@ function createLiveSession(
|
||||
prompt: overrides?.prompt,
|
||||
title: overrides?.title,
|
||||
attachedViaHub: overrides?.attachedViaHub ?? false,
|
||||
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
|
||||
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
|
||||
const workspaceRoot =
|
||||
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
|
||||
const cwd =
|
||||
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
|
||||
const thinking =
|
||||
typeof config.thinking === "boolean" ? config.thinking : undefined;
|
||||
const reasoningEffort =
|
||||
@@ -218,8 +231,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
|
||||
...(workspaceRoot ? { workspaceRoot } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
||||
maxIterations: config.maxIterations ?? config.max_iterations,
|
||||
enableTools: config.enableTools ?? config.enable_tools ?? true,
|
||||
@@ -402,7 +415,13 @@ function sendPromptsInQueueSnapshot(
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
sendEvent(ctx, "prompts_in_queue_state", {
|
||||
sessionId,
|
||||
items: session?.promptsInQueue ?? [],
|
||||
items:
|
||||
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
})) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -412,6 +431,7 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
|
||||
prompt: item.prompt,
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount: item.attachmentCount,
|
||||
userImages: item.userImages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,7 +446,12 @@ function applyPendingPrompts(
|
||||
session.promptsInQueue = mapped;
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return mapped;
|
||||
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function getSessionManager(ctx: SidecarContext): ClineCore {
|
||||
@@ -471,7 +496,7 @@ async function handleStart(
|
||||
modelId: String(coreConfig.modelId ?? ""),
|
||||
});
|
||||
const startResult = await manager.start({
|
||||
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages
|
||||
@@ -480,19 +505,24 @@ async function handleStart(
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
const workspaceRoot = startResult.manifest.workspace_root;
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
const session = createLiveSession(request.config, {
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
: undefined,
|
||||
title: requestedSessionId
|
||||
? readSessionMetadataTitle(requestedSessionId)
|
||||
: undefined,
|
||||
status: "idle",
|
||||
});
|
||||
const session = createLiveSession(
|
||||
{ ...request.config, cwd, workspaceRoot },
|
||||
{
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
: undefined,
|
||||
title: requestedSessionId
|
||||
? readSessionMetadataTitle(requestedSessionId)
|
||||
: undefined,
|
||||
status: "idle",
|
||||
},
|
||||
);
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
return { sessionId };
|
||||
return { sessionId, cwd, workspaceRoot };
|
||||
}
|
||||
|
||||
async function handleAttach(
|
||||
@@ -550,6 +580,11 @@ async function handleAttach(
|
||||
existing?.title,
|
||||
endedAt: isoTimestampToMs(session.endedAt),
|
||||
attachedViaHub: true,
|
||||
// Preserve tracked attachment files so re-attach (called on every
|
||||
// webview hydrate) does not orphan materialized files still awaiting
|
||||
// cleanup.
|
||||
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
|
||||
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -582,7 +617,7 @@ async function startRebuiltSession(
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -679,8 +714,13 @@ async function handleSend(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
const prompt = request.prompt?.trim() ?? "";
|
||||
const hasAttachments =
|
||||
(request.attachments?.userImages?.length ?? 0) > 0 ||
|
||||
(request.attachments?.userFiles?.length ?? 0) > 0;
|
||||
if (!prompt && !hasAttachments) {
|
||||
throw new Error("prompt or attachment is required");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session?.transitioningProvider) {
|
||||
@@ -742,6 +782,10 @@ async function handleSend(
|
||||
}
|
||||
}
|
||||
|
||||
const userFiles = materializeUserFiles(
|
||||
sessionId,
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (delivery === "queue") {
|
||||
if (session) {
|
||||
session.prompt = prompt;
|
||||
@@ -751,8 +795,10 @@ async function handleSend(
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
});
|
||||
const prompts = await manager.pendingPrompts.list({ sessionId });
|
||||
trackQueuedAttachments(session, prompts, userFiles);
|
||||
return {
|
||||
sessionId,
|
||||
ok: true,
|
||||
@@ -766,12 +812,33 @@ async function handleSend(
|
||||
promptLength: prompt.length,
|
||||
delivery,
|
||||
});
|
||||
const result = await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
});
|
||||
let result: Awaited<ReturnType<ClineCore["send"]>>;
|
||||
try {
|
||||
result = await manager.send({
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
});
|
||||
} catch (error) {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
throw error;
|
||||
}
|
||||
if (result === undefined) {
|
||||
// The runtime queued or steered the prompt instead of running it
|
||||
// (busy interactive session / steer delivery) — track the files so
|
||||
// they are deleted once the prompt is consumed or discarded.
|
||||
if (userFiles?.length) {
|
||||
trackQueuedAttachments(
|
||||
session,
|
||||
await manager.pendingPrompts.list({ sessionId }),
|
||||
userFiles,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
}
|
||||
ctx.logger?.log("Desktop chat prompt completed", {
|
||||
sessionId,
|
||||
finishReason: result?.finishReason,
|
||||
@@ -932,7 +999,7 @@ async function handleFork(
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
initialMessages: sourceMessages,
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -941,6 +1008,10 @@ async function handleFork(
|
||||
toolPolicies: resolveToolPolicies(forkConfig),
|
||||
});
|
||||
const newSessionId = startResult.sessionId;
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
@@ -980,6 +1051,7 @@ async function handleReset(
|
||||
) {
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
@@ -1018,7 +1090,7 @@ async function handleRestoreCheckpoint(
|
||||
buildCoreSessionConfig({
|
||||
...request.config,
|
||||
systemPrompt: await resolveSystemPrompt(request.config),
|
||||
}) as unknown as CoreSessionConfig,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1030,6 +1102,10 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
@@ -1131,6 +1207,10 @@ async function handleRemovePendingPrompt(
|
||||
sessionId,
|
||||
promptId,
|
||||
});
|
||||
if (result.removed === true) {
|
||||
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
|
||||
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
removed: result.removed === true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
|
||||
readConnectorCliLaunchSpec,
|
||||
} from "@cline/shared";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __test__, configureConnectorCliLaunch } from "./connectors";
|
||||
|
||||
describe("desktop connector lifecycle", () => {
|
||||
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLaunchSpec === undefined) {
|
||||
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
|
||||
} else {
|
||||
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the CLI connect command for a desktop-started daemon", () => {
|
||||
const workspaceRoot = "/repo";
|
||||
const expected = __test__.buildCliConnectCommand(workspaceRoot, []);
|
||||
|
||||
configureConnectorCliLaunch(workspaceRoot);
|
||||
|
||||
expect(readConnectorCliLaunchSpec()).toEqual({
|
||||
launcher: expected.launcher,
|
||||
connectArgsPrefix: expected.childArgs,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the atomic restart command for an active channel", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true),
|
||||
).toEqual(["--restart", "telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("starts an inactive channel directly", () => {
|
||||
expect(
|
||||
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], false),
|
||||
).toEqual(["telegram", "-k", "token"]);
|
||||
});
|
||||
|
||||
it("rejects a channel-wide restart when multiple instances are active", () => {
|
||||
expect(() => __test__.shouldRestartConnector("telegram", 2)).toThrow(
|
||||
"cannot safely restart telegram: 2 instances are active",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import { listActiveConnectors } from "@cline/core";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
buildConnectorConnectArgs,
|
||||
CONNECTOR_PLATFORMS,
|
||||
listConnectorCatalog,
|
||||
setConnectorCliLaunchSpec,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type ConnectorField = {
|
||||
@@ -75,6 +76,16 @@ function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function asStringRecord(value: unknown): Record<string, string> {
|
||||
const record = asRecord(value);
|
||||
if (!record) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
@@ -125,12 +136,21 @@ function buildCliConnectCommand(
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function configureConnectorCliLaunch(workspaceRoot: string): void {
|
||||
const command = buildCliConnectCommand(workspaceRoot, []);
|
||||
setConnectorCliLaunchSpec({
|
||||
launcher: command.launcher,
|
||||
connectArgsPrefix: command.childArgs,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
|
||||
(platform) => supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
@@ -211,7 +231,7 @@ async function waitForConnectorState(
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -219,42 +239,36 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
if (!supported.has(platform.id)) {
|
||||
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) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
return [
|
||||
channel,
|
||||
...buildConnectorConnectArgs(
|
||||
platform,
|
||||
asStringRecord(args?.values),
|
||||
security
|
||||
? {
|
||||
enabled: security.enabled === true,
|
||||
values: asStringRecord(security.values),
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildConnectorLaunchArgs(
|
||||
cliArgs: string[],
|
||||
isRestart: boolean,
|
||||
): string[] {
|
||||
return isRestart ? ["--restart", ...cliArgs] : cliArgs;
|
||||
}
|
||||
|
||||
function shouldRestartConnector(channel: string, activeCount: number): boolean {
|
||||
if (activeCount > 1) {
|
||||
throw new Error(
|
||||
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
|
||||
);
|
||||
}
|
||||
return cliArgs;
|
||||
return activeCount === 1;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
@@ -263,7 +277,14 @@ export async function startConnectorChannel(
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
|
||||
const activeCount = listActiveConnectors().filter(
|
||||
(connector) => connector.type === channel,
|
||||
).length;
|
||||
const isRestart = shouldRestartConnector(channel, activeCount);
|
||||
const result = await runCliConnectCommand(
|
||||
workspaceRoot,
|
||||
buildConnectorLaunchArgs(cliArgs, isRestart),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
@@ -278,6 +299,12 @@ export async function startConnectorChannel(
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
buildConnectorLaunchArgs,
|
||||
shouldRestartConnector,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
workspaceRoot: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -290,7 +317,7 @@ export async function stopConnectorChannel(
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
|
||||
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RuntimeCapabilities } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidecarContext } from "./types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { materializeUserFiles } from "./attachments";
|
||||
import type { LiveSession, SidecarContext } from "./types";
|
||||
|
||||
const createCoreMock = vi.hoisted(() => vi.fn());
|
||||
const connectMock = vi.hoisted(() => vi.fn());
|
||||
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubCommandMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
|
||||
const hubGetUrlMock = vi.hoisted(() => vi.fn());
|
||||
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
|
||||
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
|
||||
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
|
||||
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -17,19 +24,16 @@ vi.mock("@cline/core", async () => {
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
})),
|
||||
resolveHubOwnerContext: resolveHubOwnerContextMock,
|
||||
startHubWebSocketServer: startHubWebSocketServerMock,
|
||||
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
|
||||
NodeHubClient: class {
|
||||
constructor(options: unknown) {
|
||||
nodeHubClientCtorMock(options);
|
||||
}
|
||||
connect = connectMock;
|
||||
command = hubCommandMock;
|
||||
getConnectionError = hubGetConnectionErrorMock;
|
||||
getUrl = hubGetUrlMock;
|
||||
isConnected = hubIsConnectedMock;
|
||||
subscribe = subscribeMock;
|
||||
dispose = vi.fn();
|
||||
},
|
||||
@@ -53,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
beforeEach(() => {
|
||||
createCoreMock.mockReset();
|
||||
connectMock.mockReset();
|
||||
ensureCompatibleLocalHubUrlMock.mockReset();
|
||||
hubCommandMock.mockReset();
|
||||
hubGetConnectionErrorMock.mockReset();
|
||||
hubGetUrlMock.mockReset();
|
||||
hubIsConnectedMock.mockReset();
|
||||
nodeHubClientCtorMock.mockReset();
|
||||
resolveHubOwnerContextMock.mockReset();
|
||||
startHubWebSocketServerMock.mockReset();
|
||||
subscribeMock.mockReset();
|
||||
connectMock.mockResolvedValue(undefined);
|
||||
resolveHubOwnerContextMock.mockReturnValue({
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
});
|
||||
startHubWebSocketServerMock.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
close: vi.fn(),
|
||||
});
|
||||
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
);
|
||||
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
|
||||
hubGetConnectionErrorMock.mockReturnValue(null);
|
||||
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
|
||||
hubIsConnectedMock.mockReturnValue(true);
|
||||
subscribeMock.mockReturnValue(() => {});
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
@@ -83,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 0,
|
||||
owner: {
|
||||
ownerId: "code-sidecar-test",
|
||||
discoveryPath: "/tmp/code-sidecar-test.json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
@@ -102,23 +98,27 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const hubOptions = createCoreMock.mock.calls[0][0].hub;
|
||||
expect(hubOptions).not.toHaveProperty("endpoint");
|
||||
expect(hubOptions).not.toHaveProperty("authToken");
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
clientType: "code-sidecar-approvals",
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wires the desktop logger and telemetry through the client and embedded hub", async () => {
|
||||
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
@@ -135,9 +135,6 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ logger, telemetry }),
|
||||
);
|
||||
expect(createCoreMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: "cline-code",
|
||||
@@ -147,6 +144,84 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the connected shared Hub endpoint in process context", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
const session = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
} satisfies LiveSession;
|
||||
ctx.liveSessions.set("running-session", session);
|
||||
ctx.liveSessions.set("idle-session", {
|
||||
...session,
|
||||
busy: false,
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
runningSessionCount: 1,
|
||||
hub: {
|
||||
status: "connected",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts or reuses the shared Hub when a command needs a client", async () => {
|
||||
const { createSidecarContext, ensureSharedHubClient } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
expect(hubClient).toBe(ctx.hubClient);
|
||||
|
||||
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: "/workspace/project",
|
||||
cwd: "/workspace/project",
|
||||
});
|
||||
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
clientType: "code-sidecar-observer",
|
||||
}),
|
||||
);
|
||||
expect(connectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serializes queued image data when a queued prompt starts", async () => {
|
||||
const { serializeQueuedPromptStart } = await import("./context");
|
||||
|
||||
expect(
|
||||
JSON.parse(
|
||||
serializeQueuedPromptStart({
|
||||
promptId: "queued-prompt-1",
|
||||
prompt: "Describe this",
|
||||
attachmentCount: 1,
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
promptId: "queued-prompt-1",
|
||||
prompt: "Describe this",
|
||||
attachmentCount: 1,
|
||||
userImages: ["data:image/png;base64,AQID"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -221,8 +296,7 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
requestToolApproval: expect.any(Function),
|
||||
}),
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
strategy: "require-hub",
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
}),
|
||||
@@ -282,4 +356,79 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const { handleCommand } = await import("./commands");
|
||||
hubCommandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "pause_routine_schedule", {
|
||||
schedule_id: "schedule-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
schedule: { scheduleId: "schedule-1", enabled: false },
|
||||
});
|
||||
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
|
||||
scheduleId: "schedule-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
let previousSessionDataDir: string | undefined;
|
||||
let testSessionDataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
testSessionDataDir = join(
|
||||
tmpdir(),
|
||||
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(testSessionDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("deletes tracked attachments for all live sessions on shutdown", async () => {
|
||||
const { createSidecarContext, disposeSidecarContext } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
|
||||
const sessionId = "dispose-session";
|
||||
const [queuedFile] = materializeUserFiles(sessionId, [
|
||||
{ name: "queued.txt", content: "q" },
|
||||
]) as string[];
|
||||
const session: LiveSession = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "idle",
|
||||
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
|
||||
};
|
||||
ctx.liveSessions.set(sessionId, session);
|
||||
|
||||
await disposeSidecarContext(ctx, "test_shutdown");
|
||||
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
@@ -7,17 +7,21 @@ import {
|
||||
type BasicLogger,
|
||||
ClineCore,
|
||||
type CoreSessionEvent,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
ensureCompatibleLocalHubUrl,
|
||||
type ITelemetryService,
|
||||
NodeHubClient,
|
||||
type RuntimeCapabilities,
|
||||
resolveHubOwnerContext,
|
||||
setHomeDirIfUnset,
|
||||
startHubWebSocketServer,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { AgentEvent } from "@cline/shared";
|
||||
import {
|
||||
discardAllTrackedAttachments,
|
||||
flushConsumedAttachments,
|
||||
markQueuedAttachmentsSubmitted,
|
||||
reconcileQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { sessionLogPath } from "./paths";
|
||||
import type {
|
||||
LiveSession,
|
||||
@@ -28,6 +32,10 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
SidecarContext,
|
||||
Promise<NodeHubClient>
|
||||
>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — WebSocket broadcast
|
||||
@@ -51,6 +59,11 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Session log appends are chained per session so writes stay ordered, but
|
||||
// they run asynchronously: a synchronous write per streamed token would stall
|
||||
// the sidecar event loop (and therefore every pending UI command) under load.
|
||||
const sessionLogWriteTails = new Map<string, Promise<void>>();
|
||||
|
||||
function appendSessionChunk(
|
||||
sessionId: string,
|
||||
stream: string,
|
||||
@@ -58,9 +71,21 @@ function appendSessionChunk(
|
||||
ts: number,
|
||||
): void {
|
||||
const path = sessionLogPath(sessionId);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify({ ts, stream, chunk })}\n`, {
|
||||
flag: "a",
|
||||
const line = `${JSON.stringify({ ts, stream, chunk })}\n`;
|
||||
const tail = sessionLogWriteTails.get(sessionId) ?? Promise.resolve();
|
||||
const next = tail
|
||||
.then(async () => {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, line);
|
||||
})
|
||||
.catch(() => {
|
||||
// Session logs are best-effort diagnostics; never fail the stream.
|
||||
});
|
||||
sessionLogWriteTails.set(sessionId, next);
|
||||
void next.finally(() => {
|
||||
if (sessionLogWriteTails.get(sessionId) === next) {
|
||||
sessionLogWriteTails.delete(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,7 +136,29 @@ export function broadcastChunk(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
|
||||
return session.promptsInQueue;
|
||||
return session.promptsInQueue.map(
|
||||
({ id, prompt, steer, attachmentCount, userImages }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function serializeQueuedPromptStart(input: {
|
||||
promptId: string;
|
||||
prompt: string;
|
||||
attachmentCount?: number;
|
||||
userImages?: string[];
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
promptId: input.promptId,
|
||||
prompt: input.prompt,
|
||||
attachmentCount: input.attachmentCount ?? 0,
|
||||
userImages: input.userImages,
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(
|
||||
@@ -303,9 +350,16 @@ function handleCoreSessionEvent(
|
||||
prompt: item.prompt ?? "",
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount: item.attachmentCount ?? 0,
|
||||
userImages: item.userImages,
|
||||
}))
|
||||
.filter((item) => item.id && item.prompt);
|
||||
.filter(
|
||||
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
|
||||
);
|
||||
if (session) {
|
||||
reconcileQueuedAttachments(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
const previous = session.promptsInQueue;
|
||||
session.promptsInQueue = mapped;
|
||||
if (
|
||||
@@ -317,9 +371,11 @@ function handleCoreSessionEvent(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
serializeQueuedPromptStart({
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -328,14 +384,18 @@ function handleCoreSessionEvent(
|
||||
break;
|
||||
}
|
||||
case "pending_prompt_submitted": {
|
||||
const { sessionId, prompt, attachmentCount } = event.payload;
|
||||
const { sessionId, id, prompt, attachmentCount, userImages } =
|
||||
event.payload;
|
||||
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
JSON.stringify({
|
||||
serializeQueuedPromptStart({
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -348,6 +408,7 @@ function handleCoreSessionEvent(
|
||||
session.endedAt = nowMs();
|
||||
session.status = reason || "ended";
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
|
||||
break;
|
||||
}
|
||||
@@ -367,6 +428,10 @@ function handleCoreSessionEvent(
|
||||
if (session) {
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
if (status !== "running") {
|
||||
// The turn that consumed submitted attachments has finished.
|
||||
flushConsumedAttachments(sessionId, session);
|
||||
}
|
||||
}
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
break;
|
||||
@@ -397,7 +462,6 @@ export function createSidecarContext(
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
hubServer: null,
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
@@ -414,6 +478,11 @@ export async function disposeSidecarContext(
|
||||
ctx.unsubscribeSessionEvents?.();
|
||||
ctx.unsubscribeSessionEvents = null;
|
||||
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
}
|
||||
ctx.liveSessions.clear();
|
||||
|
||||
for (const client of ctx.wsClients) {
|
||||
try {
|
||||
client.close?.();
|
||||
@@ -444,12 +513,6 @@ export async function disposeSidecarContext(
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
}
|
||||
|
||||
const hubServer = ctx.hubServer;
|
||||
ctx.hubServer = null;
|
||||
if (hubServer) {
|
||||
cleanup.push(hubServer.close());
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
@@ -702,15 +765,6 @@ export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
const hubServer = await startHubWebSocketServer({
|
||||
port: 0,
|
||||
owner: resolveHubOwnerContext(
|
||||
`code-sidecar:${process.pid}:${randomUUID()}`,
|
||||
),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
backendMode: "hub",
|
||||
@@ -718,8 +772,7 @@ export async function initializeSessionManager(
|
||||
logger: ctx.logger,
|
||||
telemetry: ctx.telemetry,
|
||||
hub: {
|
||||
endpoint: hubServer.url,
|
||||
authToken: hubServer.authToken,
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
@@ -732,25 +785,64 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
|
||||
const runtimeAddress = sessionManager.runtimeAddress?.trim();
|
||||
let hubClient: NodeHubClient | null = null;
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
authToken: hubServer.authToken,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
try {
|
||||
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.hubServer = hubServer;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
export async function ensureSharedHubClient(
|
||||
ctx: SidecarContext,
|
||||
preferredUrl?: string,
|
||||
): Promise<NodeHubClient> {
|
||||
if (ctx.hubClient) {
|
||||
return ctx.hubClient;
|
||||
}
|
||||
const pending = hubClientInitialization.get(ctx);
|
||||
if (pending) {
|
||||
return await pending;
|
||||
}
|
||||
|
||||
const initialization = (async () => {
|
||||
const url =
|
||||
preferredUrl?.trim() ||
|
||||
(await ensureCompatibleLocalHubUrl({
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
}));
|
||||
if (!url) {
|
||||
throw new Error("Unable to start or connect to the shared Cline Hub.");
|
||||
}
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
hubClientInitialization.delete(ctx);
|
||||
});
|
||||
|
||||
hubClientInitialization.set(ctx, initialization);
|
||||
return await initialization;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { homedir } from "node:os";
|
||||
import { setHomeDirIfUnset } from "@cline/core";
|
||||
import { isHubDaemonProcess } from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
@@ -38,8 +41,16 @@ async function main() {
|
||||
throw new Error("sidecar must be run with Bun");
|
||||
}
|
||||
|
||||
// When launched from Finder/the Dock the app inherits launchd's minimal
|
||||
// PATH, so agent-spawned processes can't find shell-profile-installed
|
||||
// tools like `gh`. Kick resolution off first so it overlaps the rest of
|
||||
// startup, but await it before the session manager exists — that's what
|
||||
// spawns children (agent sessions, MCP servers, scheduled runs).
|
||||
const shellPathPromise = ensureLoginShellPath();
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
|
||||
setHomeDirIfUnset(homedir());
|
||||
configureConnectorCliLaunch(workspaceRoot);
|
||||
const observability = createDesktopObservability();
|
||||
activeObservability = observability;
|
||||
const ctx = createSidecarContext(workspaceRoot, observability);
|
||||
@@ -49,6 +60,10 @@ async function main() {
|
||||
});
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
await shellPathPromise,
|
||||
);
|
||||
await initializeSessionManager(ctx);
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -121,7 +136,15 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
async function runEntrypoint(): Promise<void> {
|
||||
if (isHubDaemonProcess()) {
|
||||
await import("@cline/core/hub/daemon-entry");
|
||||
return;
|
||||
}
|
||||
await main();
|
||||
}
|
||||
|
||||
runEntrypoint().catch(async (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
activeObservability?.logger.error?.("Desktop sidecar process failed", {
|
||||
error,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cancelProviderOAuthLogin,
|
||||
cancelProviderOAuthLoginsForOwner,
|
||||
OAuthLoginCancelledError,
|
||||
runCancellableProviderOAuthLogin,
|
||||
} from "./oauth-login";
|
||||
|
||||
type Credentials = { accessToken: string };
|
||||
|
||||
function makeManager(): ProviderSettingsManager {
|
||||
return {
|
||||
getProviderSettings: () => undefined,
|
||||
} as unknown as ProviderSettingsManager;
|
||||
}
|
||||
|
||||
function makeDependencies(overrides: {
|
||||
login: () => Promise<Credentials>;
|
||||
save?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const save =
|
||||
overrides.save ??
|
||||
vi.fn(() => ({
|
||||
provider: "cline",
|
||||
auth: { accessToken: "saved-token" },
|
||||
}));
|
||||
return {
|
||||
dependencies: {
|
||||
login: overrides.login as never,
|
||||
save: save as never,
|
||||
markEnabled: vi.fn() as never,
|
||||
},
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCancellableProviderOAuthLogin", () => {
|
||||
it("saves credentials and returns the access token on success", async () => {
|
||||
const { dependencies, save } = makeDependencies({
|
||||
login: async () => ({ accessToken: "fresh-token" }),
|
||||
});
|
||||
|
||||
const result = await runCancellableProviderOAuthLogin(
|
||||
makeManager(),
|
||||
"cline",
|
||||
() => undefined,
|
||||
{},
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ provider: "cline", accessToken: "saved-token" });
|
||||
});
|
||||
|
||||
it("rejects promptly on cancel and never persists a late completion", async () => {
|
||||
let resolveLogin: (credentials: Credentials) => void = () => undefined;
|
||||
const { dependencies, save } = makeDependencies({
|
||||
login: () =>
|
||||
new Promise<Credentials>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
const pending = runCancellableProviderOAuthLogin(
|
||||
makeManager(),
|
||||
"cline",
|
||||
() => undefined,
|
||||
{},
|
||||
dependencies,
|
||||
);
|
||||
// Cancellation must reject the pending login right away, without
|
||||
// waiting for the browser round-trip to finish.
|
||||
expect(cancelProviderOAuthLogin("cline")).toBe(true);
|
||||
await expect(pending).rejects.toBeInstanceOf(OAuthLoginCancelledError);
|
||||
|
||||
// The user completes the abandoned browser flow afterwards: the
|
||||
// credentials must be discarded, not saved.
|
||||
resolveLogin({ accessToken: "late-token" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports when there is no pending login to cancel", () => {
|
||||
expect(cancelProviderOAuthLogin("cline")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels a dangling attempt when a new login starts for the provider", async () => {
|
||||
let resolveFirstLogin: (credentials: Credentials) => void = () => undefined;
|
||||
const firstSave = vi.fn(() => ({
|
||||
provider: "cline",
|
||||
auth: { accessToken: "first-token" },
|
||||
}));
|
||||
const first = runCancellableProviderOAuthLogin(
|
||||
makeManager(),
|
||||
"cline",
|
||||
() => undefined,
|
||||
{},
|
||||
makeDependencies({
|
||||
login: () =>
|
||||
new Promise<Credentials>((resolve) => {
|
||||
resolveFirstLogin = resolve;
|
||||
}),
|
||||
save: firstSave,
|
||||
}).dependencies,
|
||||
);
|
||||
|
||||
const { dependencies: secondDependencies, save: secondSave } =
|
||||
makeDependencies({
|
||||
login: async () => ({ accessToken: "second-token" }),
|
||||
});
|
||||
const second = runCancellableProviderOAuthLogin(
|
||||
makeManager(),
|
||||
"cline",
|
||||
() => undefined,
|
||||
{},
|
||||
secondDependencies,
|
||||
);
|
||||
|
||||
await expect(first).rejects.toBeInstanceOf(OAuthLoginCancelledError);
|
||||
await expect(second).resolves.toEqual({
|
||||
provider: "cline",
|
||||
accessToken: "saved-token",
|
||||
});
|
||||
|
||||
// The first attempt's late completion is discarded.
|
||||
resolveFirstLogin({ accessToken: "first-token" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(firstSave).not.toHaveBeenCalled();
|
||||
expect(secondSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels pending logins when their transport connection closes", async () => {
|
||||
let resolveLogin: (credentials: Credentials) => void = () => undefined;
|
||||
const { dependencies, save } = makeDependencies({
|
||||
login: () =>
|
||||
new Promise<Credentials>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
});
|
||||
const connection = {};
|
||||
|
||||
const pending = runCancellableProviderOAuthLogin(
|
||||
makeManager(),
|
||||
"cline",
|
||||
() => undefined,
|
||||
{ owner: connection },
|
||||
dependencies,
|
||||
);
|
||||
|
||||
// A different connection closing must not cancel this login.
|
||||
expect(cancelProviderOAuthLoginsForOwner({})).toBe(0);
|
||||
|
||||
// The initiating connection closing cancels it, so a lost cancel
|
||||
// command (transport drop, webview reload) cannot leave an abandoned
|
||||
// flow that persists credentials later.
|
||||
expect(cancelProviderOAuthLoginsForOwner(connection)).toBe(1);
|
||||
await expect(pending).rejects.toBeInstanceOf(OAuthLoginCancelledError);
|
||||
|
||||
resolveLogin({ accessToken: "late-token" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import {
|
||||
getProviderAuthStorageId,
|
||||
loginLocalProvider,
|
||||
markLocalProviderEnabled,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
|
||||
export class OAuthLoginCancelledError extends Error {
|
||||
constructor(providerId: string) {
|
||||
super(`Sign-in was cancelled for provider "${providerId}"`);
|
||||
this.name = "OAuthLoginCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
type PendingOAuthLogin = {
|
||||
cancelled: boolean;
|
||||
cancel: () => void;
|
||||
/** Transport connection that initiated the login, when known. */
|
||||
owner?: object;
|
||||
};
|
||||
|
||||
// One pending browser round-trip per provider. Starting a new login for the
|
||||
// same provider cancels the previous dangling attempt so an abandoned browser
|
||||
// tab can never race a fresh sign-in.
|
||||
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
|
||||
|
||||
export type OAuthLoginDependencies = {
|
||||
login: typeof loginLocalProvider;
|
||||
save: typeof saveLocalProviderOAuthCredentials;
|
||||
markEnabled: typeof markLocalProviderEnabled;
|
||||
};
|
||||
|
||||
const defaultDependencies: OAuthLoginDependencies = {
|
||||
login: loginLocalProvider,
|
||||
save: saveLocalProviderOAuthCredentials,
|
||||
markEnabled: markLocalProviderEnabled,
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a provider OAuth login that can be cancelled while the browser
|
||||
* round-trip is pending. Cancellation rejects the returned promise right away
|
||||
* AND guarantees the credentials of a late-completing browser flow are never
|
||||
* persisted, so the UI's signed-out state cannot diverge from disk.
|
||||
*/
|
||||
export async function runCancellableProviderOAuthLogin(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
openUrl: (url: string) => void,
|
||||
options: { owner?: object } = {},
|
||||
dependencies: OAuthLoginDependencies = defaultDependencies,
|
||||
): Promise<{ provider: string; accessToken: string }> {
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
const existing = manager.getProviderSettings(storageProviderId);
|
||||
|
||||
pendingOAuthLoginsByProvider.get(providerId)?.cancel();
|
||||
|
||||
let rejectOnCancel: (error: Error) => void = () => undefined;
|
||||
const cancellation = new Promise<never>((_, reject) => {
|
||||
rejectOnCancel = reject;
|
||||
});
|
||||
const entry: PendingOAuthLogin = {
|
||||
cancelled: false,
|
||||
cancel: () => {
|
||||
entry.cancelled = true;
|
||||
rejectOnCancel(new OAuthLoginCancelledError(providerId));
|
||||
},
|
||||
owner: options.owner,
|
||||
};
|
||||
pendingOAuthLoginsByProvider.set(providerId, entry);
|
||||
|
||||
try {
|
||||
// Promise.race subscribes to the login promise, so a late rejection
|
||||
// after cancellation is observed and cannot become an unhandled
|
||||
// rejection that kills the sidecar.
|
||||
const credentials = await Promise.race([
|
||||
dependencies.login(providerId, existing, openUrl),
|
||||
cancellation,
|
||||
]);
|
||||
if (entry.cancelled) {
|
||||
throw new OAuthLoginCancelledError(providerId);
|
||||
}
|
||||
const saved = dependencies.save(manager, providerId, existing, credentials);
|
||||
if (saved.provider !== providerId) {
|
||||
dependencies.markEnabled(manager, providerId, { tokenSource: "oauth" });
|
||||
}
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
} finally {
|
||||
if (pendingOAuthLoginsByProvider.get(providerId) === entry) {
|
||||
pendingOAuthLoginsByProvider.delete(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the pending OAuth login for a provider, if any. Returns whether a
|
||||
* pending login existed. The cancelled attempt's credentials are discarded
|
||||
* even if the user later completes the already-open browser flow.
|
||||
*/
|
||||
export function cancelProviderOAuthLogin(providerId: string): boolean {
|
||||
const entry = pendingOAuthLoginsByProvider.get(providerId);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
entry.cancel();
|
||||
pendingOAuthLoginsByProvider.delete(providerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels every pending OAuth login initiated by a transport connection.
|
||||
* Called when that connection closes so a lost or undeliverable cancel
|
||||
* command (e.g. the webview reloaded or the transport dropped) can never
|
||||
* leave an abandoned browser flow that persists credentials later.
|
||||
*/
|
||||
export function cancelProviderOAuthLoginsForOwner(owner: object): number {
|
||||
let cancelled = 0;
|
||||
for (const [providerId, entry] of pendingOAuthLoginsByProvider) {
|
||||
if (entry.owner === owner) {
|
||||
entry.cancel();
|
||||
pendingOAuthLoginsByProvider.delete(providerId);
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_HOST,
|
||||
@@ -237,7 +238,9 @@ function createWebSocketHandler(ctx: SidecarContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handleCommand(ctx, request.command, request.args);
|
||||
const result = await handleCommand(ctx, request.command, request.args, {
|
||||
connection: ws,
|
||||
});
|
||||
ws.send(jsonResponse(request.id, true, result));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -246,6 +249,10 @@ function createWebSocketHandler(ctx: SidecarContext) {
|
||||
},
|
||||
close(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.delete(ws);
|
||||
// OAuth logins are interactive: if the connection that started one
|
||||
// goes away (webview reload, transport drop), cancel it so the
|
||||
// abandoned browser flow can never persist credentials later.
|
||||
cancelProviderOAuthLoginsForOwner(ws);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type StoreRecord = {
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
parentAgentId?: string;
|
||||
parentSessionId?: string;
|
||||
status: string;
|
||||
prompt?: string;
|
||||
teamName?: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
messagesPath?: string;
|
||||
};
|
||||
|
||||
const records = new Map<string, StoreRecord>();
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
SqliteSessionStore: class {
|
||||
listChildren(parentSessionId: string, limit = 200) {
|
||||
return [...records.values()]
|
||||
.filter((record) => record.parentSessionId === parentSessionId)
|
||||
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
|
||||
.slice(0, limit);
|
||||
}
|
||||
get(sessionId: string) {
|
||||
return records.get(sessionId);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { listSessionAgents, readChildSessionMessages } from "./agents";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeMessages(name: string, messages: unknown[]): string {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, JSON.stringify({ messages }), "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
const ROOT = "root1";
|
||||
|
||||
function record(overrides: Partial<StoreRecord> & { sessionId: string }) {
|
||||
return {
|
||||
agentId: "agent1",
|
||||
parentSessionId: ROOT,
|
||||
status: "completed",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-5",
|
||||
startedAt: "2026-07-27T00:00:00.000Z",
|
||||
...overrides,
|
||||
} satisfies StoreRecord;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "agents-test-"));
|
||||
records.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("listSessionAgents", () => {
|
||||
it("returns nothing for a blank session id", () => {
|
||||
expect(listSessionAgents("")).toEqual([]);
|
||||
expect(listSessionAgents(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists only children of the requested root", () => {
|
||||
records.set("root1__a", record({ sessionId: "root1__a", agentId: "a" }));
|
||||
records.set(
|
||||
"other__b",
|
||||
record({
|
||||
sessionId: "other__b",
|
||||
agentId: "b",
|
||||
parentSessionId: "other",
|
||||
}),
|
||||
);
|
||||
const agents = listSessionAgents(ROOT);
|
||||
expect(agents.map((agent) => agent.agentId)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("classifies team-task children apart from subagents", () => {
|
||||
records.set("root1__a", record({ sessionId: "root1__a", agentId: "a" }));
|
||||
records.set(
|
||||
"root1__teamtask__b__x1",
|
||||
record({
|
||||
sessionId: "root1__teamtask__b__x1",
|
||||
agentId: "b",
|
||||
teamName: "platform",
|
||||
startedAt: "2026-07-27T00:01:00.000Z",
|
||||
}),
|
||||
);
|
||||
const agents = listSessionAgents(ROOT);
|
||||
expect(agents.map((agent) => agent.kind)).toEqual(["subagent", "teamtask"]);
|
||||
expect(agents[1]?.teamName).toBe("platform");
|
||||
});
|
||||
|
||||
it("skips rows with no agent id", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({ sessionId: "root1__a", agentId: undefined }),
|
||||
);
|
||||
expect(listSessionAgents(ROOT)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports whether a transcript exists yet", () => {
|
||||
records.set(
|
||||
"root1__written",
|
||||
record({
|
||||
sessionId: "root1__written",
|
||||
agentId: "written",
|
||||
messagesPath: writeMessages("written.json", [
|
||||
{ role: "assistant", content: "done" },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
records.set(
|
||||
"root1__empty",
|
||||
record({
|
||||
sessionId: "root1__empty",
|
||||
agentId: "empty",
|
||||
status: "running",
|
||||
messagesPath: writeMessages("empty.json", []),
|
||||
startedAt: "2026-07-27T00:01:00.000Z",
|
||||
}),
|
||||
);
|
||||
records.set(
|
||||
"root1__missing",
|
||||
record({
|
||||
sessionId: "root1__missing",
|
||||
agentId: "missing",
|
||||
status: "running",
|
||||
messagesPath: join(dir, "nope.json"),
|
||||
startedAt: "2026-07-27T00:02:00.000Z",
|
||||
}),
|
||||
);
|
||||
const agents = listSessionAgents(ROOT);
|
||||
expect(agents.map((agent) => [agent.agentId, agent.hasMessages])).toEqual([
|
||||
["written", true],
|
||||
["empty", false],
|
||||
["missing", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the persisted status", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({ sessionId: "root1__a", agentId: "a", status: "failed" }),
|
||||
);
|
||||
expect(listSessionAgents(ROOT)[0]?.status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readChildSessionMessages", () => {
|
||||
it("reads a child transcript from the path recorded on its row", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({
|
||||
sessionId: "root1__a",
|
||||
agentId: "a",
|
||||
messagesPath: writeMessages("a.json", [
|
||||
{ role: "user", content: "do the thing" },
|
||||
{ role: "assistant", content: "did the thing" },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(readChildSessionMessages("root1__a"))).toContain(
|
||||
"did the thing",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for a root session so ordinary reads are untouched", () => {
|
||||
records.set(
|
||||
"root1",
|
||||
record({ sessionId: "root1", parentSessionId: undefined }),
|
||||
);
|
||||
expect(readChildSessionMessages("root1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unknown or blank session", () => {
|
||||
expect(readChildSessionMessages("root1__ghost")).toBeNull();
|
||||
expect(readChildSessionMessages(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the recorded transcript has not been written yet", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({
|
||||
sessionId: "root1__a",
|
||||
agentId: "a",
|
||||
status: "running",
|
||||
messagesPath: join(dir, "not-yet.json"),
|
||||
}),
|
||||
);
|
||||
expect(readChildSessionMessages("root1__a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessionAgents last action", () => {
|
||||
it("reports the most recent tool call", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({
|
||||
sessionId: "root1__a",
|
||||
agentId: "a",
|
||||
status: "running",
|
||||
messagesPath: writeMessages("a.json", [
|
||||
{ role: "user", content: "investigate" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me look around." },
|
||||
{ type: "tool_use", name: "read_files", input: {} },
|
||||
],
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(listSessionAgents(ROOT)[0]?.lastAction).toBe("Running read_files");
|
||||
});
|
||||
|
||||
it("falls back to the latest assistant text", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({
|
||||
sessionId: "root1__a",
|
||||
agentId: "a",
|
||||
messagesPath: writeMessages("a.json", [
|
||||
{ role: "assistant", content: [{ type: "tool_use", name: "grep" }] },
|
||||
{ role: "user", content: [{ type: "tool_result", content: "hits" }] },
|
||||
{ role: "assistant", content: "Found three call sites." },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(listSessionAgents(ROOT)[0]?.lastAction).toBe(
|
||||
"Found three call sites.",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips reasoning blocks, which are intent rather than action", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({
|
||||
sessionId: "root1__a",
|
||||
agentId: "a",
|
||||
messagesPath: writeMessages("a.json", [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Checked the parser." },
|
||||
{ type: "thinking", thinking: "I should double check" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(listSessionAgents(ROOT)[0]?.lastAction).toBe("Checked the parser.");
|
||||
});
|
||||
|
||||
it("collapses whitespace and truncates a long action", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({
|
||||
sessionId: "root1__a",
|
||||
agentId: "a",
|
||||
messagesPath: writeMessages("a.json", [
|
||||
{ role: "assistant", content: `line one\n\n${"x".repeat(400)}` },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
const lastAction = listSessionAgents(ROOT)[0]?.lastAction ?? "";
|
||||
expect(lastAction.endsWith("...")).toBe(true);
|
||||
expect(lastAction.length).toBeLessThanOrEqual(163);
|
||||
expect(lastAction).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("leaves the action undefined when no transcript exists", () => {
|
||||
records.set(
|
||||
"root1__a",
|
||||
record({ sessionId: "root1__a", agentId: "a", status: "running" }),
|
||||
);
|
||||
expect(listSessionAgents(ROOT)[0]?.lastAction).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { SqliteSessionStore } from "@cline/core";
|
||||
|
||||
/**
|
||||
* Child agents of a chat session: `spawn_agent` subagent runs and team-task
|
||||
* teammate runs. Both are persisted as sessions parented to the root session
|
||||
* (see the SDK's team-child-session-manager), which is the only place their
|
||||
* real status lives — the tool events forwarded to the webview carry no agent
|
||||
* attribution at all.
|
||||
*/
|
||||
export type SessionAgentRecord = {
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
parentAgentId?: string;
|
||||
/** "subagent" for spawn_agent runs, "teamtask" for team-task runs. */
|
||||
kind: "subagent" | "teamtask";
|
||||
status: string;
|
||||
/** The task the agent was given — its first prompt. */
|
||||
prompt?: string;
|
||||
/** Most recent thing the agent did, for an at-a-glance progress line. */
|
||||
lastAction?: string;
|
||||
teamName?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
/** Whether a transcript exists to open yet. */
|
||||
hasMessages: boolean;
|
||||
};
|
||||
|
||||
const TEAM_TASK_MARKER = "__teamtask__";
|
||||
const LAST_ACTION_LIMIT = 160;
|
||||
|
||||
function readMessagesFile(path: string): unknown[] | null {
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return Array.isArray(parsed.messages) ? parsed.messages : [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A child row records where its transcript was written. Deriving the path here
|
||||
* instead would mean duplicating the SDK's artifact layout, which nests child
|
||||
* transcripts under the root session directory keyed by agent id.
|
||||
*/
|
||||
function resolveChildMessagesPath(record: {
|
||||
messagesPath?: string;
|
||||
}): string | undefined {
|
||||
return record.messagesPath?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript of a child agent session, or null when the id is not a child or
|
||||
* nothing has been written yet. Lets the ordinary session-reading path open a
|
||||
* subagent session without knowing where child artifacts live.
|
||||
*/
|
||||
export function readChildSessionMessages(sessionId: string): unknown[] | null {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
let record: { parentSessionId?: string; messagesPath?: string } | undefined;
|
||||
try {
|
||||
record = new SqliteSessionStore().get(trimmed);
|
||||
} catch {
|
||||
// No session database on this host; nothing to resolve.
|
||||
return null;
|
||||
}
|
||||
if (!record?.parentSessionId) {
|
||||
return null;
|
||||
}
|
||||
const messagesPath = resolveChildMessagesPath(record);
|
||||
return messagesPath ? readMessagesFile(messagesPath) : null;
|
||||
}
|
||||
|
||||
function truncate(value: string, limit = LAST_ACTION_LIMIT): string {
|
||||
const collapsed = value.replace(/\s+/g, " ").trim();
|
||||
return collapsed.length > limit
|
||||
? `${collapsed.slice(0, limit)}...`
|
||||
: collapsed;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize the agent's most recent step from the tail of its transcript.
|
||||
*
|
||||
* A tool call names itself usefully ("Running read_files"), so it wins over
|
||||
* prose; assistant text is the fallback. Reasoning blocks are skipped — they
|
||||
* describe intent rather than an action taken.
|
||||
*/
|
||||
function deriveLastAction(messages: unknown[]): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = asRecord(messages[index]);
|
||||
if (!message || message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
const text = truncate(content);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let blockIndex = content.length - 1;
|
||||
blockIndex >= 0;
|
||||
blockIndex -= 1
|
||||
) {
|
||||
const block = asRecord(content[blockIndex]);
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
if (block.type === "tool_use" && typeof block.name === "string") {
|
||||
return `Running ${block.name}`;
|
||||
}
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
const text = truncate(block.text);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function listSessionAgents(
|
||||
rootSessionId: string,
|
||||
limit = 200,
|
||||
): SessionAgentRecord[] {
|
||||
const trimmedRootId = rootSessionId.trim();
|
||||
if (!trimmedRootId) {
|
||||
return [];
|
||||
}
|
||||
const store = new SqliteSessionStore();
|
||||
const out: SessionAgentRecord[] = [];
|
||||
for (const record of store.listChildren(trimmedRootId, limit)) {
|
||||
const agentId = record.agentId?.trim();
|
||||
if (!agentId) {
|
||||
continue;
|
||||
}
|
||||
const messagesPath = resolveChildMessagesPath(record);
|
||||
const messages = messagesPath ? readMessagesFile(messagesPath) : null;
|
||||
out.push({
|
||||
sessionId: record.sessionId,
|
||||
agentId,
|
||||
parentAgentId: record.parentAgentId,
|
||||
kind: record.sessionId.includes(TEAM_TASK_MARKER)
|
||||
? "teamtask"
|
||||
: "subagent",
|
||||
status: record.status,
|
||||
prompt: record.prompt,
|
||||
lastAction: messages?.length ? deriveLastAction(messages) : undefined,
|
||||
teamName: record.teamName,
|
||||
provider: record.provider || undefined,
|
||||
model: record.model || undefined,
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt ?? undefined,
|
||||
hasMessages: Boolean(messages && messages.length > 0),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
it("preserves each stored message timestamp across projected blocks", async () => {
|
||||
const sessionId = `timestamp-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_282;
|
||||
const assistantTimestamp = 1_781_041_621_946;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-message",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Question" }],
|
||||
ts: userTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Consider it" },
|
||||
{ type: "text", text: "Answer" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-use",
|
||||
name: "read_files",
|
||||
input: { paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "user-message_text_0",
|
||||
createdAt: userTimestamp,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_text_0",
|
||||
createdAt: assistantTimestamp,
|
||||
reasoning: "Consider it",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_tool_use_2",
|
||||
createdAt: assistantTimestamp,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects image content blocks without replacing them with placeholder text", async () => {
|
||||
const sessionId = `image-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-image",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Describe this" },
|
||||
{
|
||||
type: "image",
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "Describe this",
|
||||
images: [
|
||||
{
|
||||
id: "user-image_image_1",
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
readSessionManifest,
|
||||
sharedSessionMessagesPath,
|
||||
sharedSessionMessagesWritePath,
|
||||
} from "../paths";
|
||||
import type { JsonRecord, SidecarContext } from "../types";
|
||||
import { readChildSessionMessages } from "./agents";
|
||||
import {
|
||||
parseF64Value,
|
||||
parseU64Value,
|
||||
@@ -27,6 +29,17 @@ type ChatTurnResult = {
|
||||
|
||||
const nowMs = () => Date.now();
|
||||
|
||||
function resolveMessageCreatedAt(
|
||||
message: JsonRecord,
|
||||
fallbackCreatedAt: number,
|
||||
): number {
|
||||
return (
|
||||
parseU64Value(message.ts) ??
|
||||
parseU64Value(message.createdAt) ??
|
||||
fallbackCreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function readMessageMetadata(message: JsonRecord): JsonRecord | undefined {
|
||||
return message.metadata && typeof message.metadata === "object"
|
||||
? (message.metadata as JsonRecord)
|
||||
@@ -121,6 +134,20 @@ function trimNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractImageBlock(
|
||||
record: JsonRecord,
|
||||
): { mediaType: string; data: string } | undefined {
|
||||
const mediaType = trimNonEmptyString(record.mediaType);
|
||||
const data = trimNonEmptyString(record.data);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
const validation = validateImageMedia(mediaType, data);
|
||||
return validation.ok
|
||||
? { mediaType: validation.mediaType, data: validation.base64 }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
const path = sharedSessionMessagesPath(sessionId);
|
||||
if (!existsSync(path)) {
|
||||
@@ -301,7 +328,12 @@ export async function readSessionMessages(
|
||||
sessionId: string,
|
||||
maxMessages = 800,
|
||||
): Promise<unknown[]> {
|
||||
const persisted = readPersistedChatMessages(sessionId);
|
||||
const persisted =
|
||||
readPersistedChatMessages(sessionId) ??
|
||||
// A child agent's transcript is not stored under its own session
|
||||
// directory — it lives beside the root session's artifacts — so opening a
|
||||
// subagent session has to resolve the path recorded on its row.
|
||||
readChildSessionMessages(sessionId);
|
||||
const messages =
|
||||
persisted && persisted.length > 0
|
||||
? persisted
|
||||
@@ -312,7 +344,6 @@ export async function readSessionMessages(
|
||||
const out: JsonRecord[] = [];
|
||||
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
|
||||
const pendingToolMessages = new Map<string, [number, string, unknown]>();
|
||||
let nextCreatedAt = baseTs;
|
||||
let userRunCount = 0;
|
||||
|
||||
for (let idx = start; idx < messages.length; idx += 1) {
|
||||
@@ -321,6 +352,7 @@ export async function readSessionMessages(
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
let textMeta = extractMessageUsageMeta(message);
|
||||
const storedMeta = extractStoredMessageMeta(message);
|
||||
if (storedMeta) {
|
||||
@@ -360,13 +392,14 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role,
|
||||
content,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
const images: Array<{ id: string; mediaType: string; data: string }> = [];
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
let textSegmentIndex = 0;
|
||||
@@ -385,7 +418,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role,
|
||||
content: joined,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textSegmentIndex += 1;
|
||||
@@ -415,7 +448,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: buildToolPayloadJson(toolName, input, null, false),
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: {
|
||||
toolName,
|
||||
hookEventName: "history_tool_use",
|
||||
@@ -455,7 +488,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: buildToolPayloadJson("tool_result", null, result, isError),
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: {
|
||||
toolName: "tool_result",
|
||||
hookEventName: "history_tool_result",
|
||||
@@ -476,6 +509,16 @@ export async function readSessionMessages(
|
||||
reasoningRedacted = true;
|
||||
continue;
|
||||
}
|
||||
if (blockType === "image") {
|
||||
const image = extractImageBlock(record);
|
||||
if (image) {
|
||||
images.push({
|
||||
id: `${messageIdBase}_image_${blockIdx}`,
|
||||
...image,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const line = stringifyMessageContent(block);
|
||||
if (line.trim()) {
|
||||
textParts.push(line);
|
||||
@@ -483,6 +526,25 @@ export async function readSessionMessages(
|
||||
}
|
||||
|
||||
flushTextParts();
|
||||
if (images.length > 0) {
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role);
|
||||
if (target) {
|
||||
target.images = images;
|
||||
} else {
|
||||
out.push({
|
||||
id: `${messageIdBase}_images`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
images,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (reasoningParts.length > 0 || reasoningRedacted) {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const target = out
|
||||
@@ -503,7 +565,7 @@ export async function readSessionMessages(
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultShellFor,
|
||||
ensureLoginShellPath,
|
||||
extractMarkedPath,
|
||||
loginShellFor,
|
||||
mergePaths,
|
||||
resolveLoginShellPath,
|
||||
shellInvocation,
|
||||
} from "./shell-path";
|
||||
|
||||
const MARKER_START = "__CLINE_SIDECAR_PATH_START__";
|
||||
const MARKER_END = "__CLINE_SIDECAR_PATH_END__";
|
||||
|
||||
let tempDirs: string[] = [];
|
||||
|
||||
/**
|
||||
* Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c <cmd>`,
|
||||
* so the command to run arrives as $4. The default body mimics a login shell
|
||||
* whose profile prepends Homebrew before running the command.
|
||||
*/
|
||||
function writeFakeShell(
|
||||
script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"',
|
||||
name = "fake-shell",
|
||||
): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-"));
|
||||
tempDirs.push(dir);
|
||||
const shellPath = join(dir, name);
|
||||
writeFileSync(shellPath, `#!/bin/sh\n${script}\n`);
|
||||
chmodSync(shellPath, 0o755);
|
||||
return shellPath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
describe("extractMarkedPath", () => {
|
||||
it("extracts the PATH between markers", () => {
|
||||
expect(
|
||||
extractMarkedPath(
|
||||
`${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`,
|
||||
),
|
||||
).toBe("/opt/homebrew/bin:/usr/bin");
|
||||
});
|
||||
|
||||
it("ignores shell profile noise around the markers", () => {
|
||||
const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`;
|
||||
expect(extractMarkedPath(output)).toBe("/usr/local/bin");
|
||||
});
|
||||
|
||||
it("returns undefined when markers are missing or empty", () => {
|
||||
expect(extractMarkedPath("no markers here")).toBeUndefined();
|
||||
expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined();
|
||||
expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePaths", () => {
|
||||
it("puts shell entries first and keeps current-only entries", () => {
|
||||
expect(
|
||||
mergePaths(
|
||||
"/opt/homebrew/bin:/usr/bin:/bin",
|
||||
"/usr/bin:/bin:/custom/bin",
|
||||
),
|
||||
).toBe("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin");
|
||||
});
|
||||
|
||||
it("drops duplicate and empty entries", () => {
|
||||
expect(mergePaths("/a::/b:/a", "/b:/c:")).toBe("/a:/b:/c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultShellFor", () => {
|
||||
it("uses zsh on macOS and bash elsewhere", () => {
|
||||
expect(defaultShellFor("darwin")).toBe("/bin/zsh");
|
||||
expect(defaultShellFor("linux")).toBe("/bin/bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginShellFor", () => {
|
||||
it("returns the passwd-database shell when one exists", () => {
|
||||
// The test runner's uid has a passwd entry, so $SHELL must lose.
|
||||
const shell = loginShellFor(process.platform, {
|
||||
SHELL: "/env/should-not-win",
|
||||
});
|
||||
expect(shell.startsWith("/")).toBe(true);
|
||||
expect(shell).not.toBe("/env/should-not-win");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellInvocation", () => {
|
||||
it("uses separate login+interactive flags for posix-style shells", () => {
|
||||
expect(shellInvocation("/bin/zsh", "cmd")).toEqual({
|
||||
args: ["-i", "-l", "-c", "cmd"],
|
||||
});
|
||||
expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({
|
||||
args: ["-i", "-l", "-c", "cmd"],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => {
|
||||
expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({
|
||||
args: ["-c", "cmd"],
|
||||
argv0: "-tcsh",
|
||||
});
|
||||
expect(shellInvocation("/bin/csh", "cmd")).toEqual({
|
||||
args: ["-c", "cmd"],
|
||||
argv0: "-csh",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLoginShellPath", () => {
|
||||
it("captures PATH from the shell", async () => {
|
||||
const shell = writeFakeShell();
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBe(
|
||||
"/opt/homebrew/bin:/usr/bin",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads PATH from the environment, not the shell's own expansion", async () => {
|
||||
// Mimics fish: its "$PATH" expansion would space-join the entries,
|
||||
// but the printf runs inside /bin/sh, which reads the exported
|
||||
// colon-delimited PATH env var — so the shell's expansion rules
|
||||
// never apply. This fake shell never evals the command text; it
|
||||
// only exports PATH and runs the command via sh, like fish would.
|
||||
const shell = writeFakeShell(
|
||||
'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"',
|
||||
);
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBe(
|
||||
"/opt/homebrew/bin:/usr/bin",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves undefined when the shell prints garbage", async () => {
|
||||
const shell = writeFakeShell('echo "no markers"');
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves undefined when the shell is missing", async () => {
|
||||
await expect(
|
||||
resolveLoginShellPath("/nonexistent/shell"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("times out hung shells without rejecting", async () => {
|
||||
const shell = writeFakeShell("sleep 60");
|
||||
await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("invokes csh-family shells without login/interactive flags", async () => {
|
||||
// A csh stand-in that rejects any first flag other than -c.
|
||||
const shell = writeFakeShell(
|
||||
'[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"',
|
||||
"tcsh",
|
||||
);
|
||||
await expect(resolveLoginShellPath(shell)).resolves.toBe(
|
||||
"/opt/homebrew/bin:/usr/bin",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureLoginShellPath", () => {
|
||||
it("merges the login shell PATH into env.PATH", async () => {
|
||||
const shell = writeFakeShell();
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: shell,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: "applied",
|
||||
pathEntries: 3,
|
||||
shell,
|
||||
});
|
||||
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin");
|
||||
});
|
||||
|
||||
it("falls back to the default shell when $SHELL can't resolve", async () => {
|
||||
const fallbackShell = writeFakeShell();
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: "/nonexistent/shell",
|
||||
fallbackShell,
|
||||
});
|
||||
expect(result.status).toBe("applied");
|
||||
expect(result).toMatchObject({ shell: fallbackShell });
|
||||
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
|
||||
});
|
||||
|
||||
it("leaves PATH untouched when every shell fails", async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: "/nonexistent/shell",
|
||||
fallbackShell: "/nonexistent/other-shell",
|
||||
});
|
||||
expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" });
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
});
|
||||
|
||||
it("skips on windows", async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
|
||||
const result = await ensureLoginShellPath({ platform: "win32", env });
|
||||
expect(result).toEqual({ status: "skipped", reason: "windows" });
|
||||
});
|
||||
|
||||
it("skips when the escape hatch is set", async () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
PATH: "/usr/bin",
|
||||
CLINE_SIDECAR_SKIP_SHELL_PATH: "1",
|
||||
};
|
||||
const result = await ensureLoginShellPath({ platform: "darwin", env });
|
||||
expect(result.status).toBe("skipped");
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
});
|
||||
|
||||
it("never exposes the resolved PATH in its result", async () => {
|
||||
const shell = writeFakeShell();
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "darwin",
|
||||
env,
|
||||
userShell: shell,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin");
|
||||
});
|
||||
|
||||
it("resolves against a real shell end to end", async () => {
|
||||
const env: NodeJS.ProcessEnv = { PATH: "/bin" };
|
||||
const result = await ensureLoginShellPath({
|
||||
platform: "linux",
|
||||
env,
|
||||
userShell: "/bin/sh",
|
||||
});
|
||||
expect(result.status).toBe("applied");
|
||||
expect(env.PATH).toContain("/bin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Login-shell PATH resolution for the desktop sidecar.
|
||||
*
|
||||
* When the Tauri app is launched from Finder/the Dock on macOS, it inherits
|
||||
* launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the
|
||||
* user's shell PATH. The sidecar — and every process it spawns for the agent
|
||||
* (bash tool, MCP servers) — then can't find tools like `gh` that live in
|
||||
* /opt/homebrew/bin or other shell-profile-added directories, even though
|
||||
* the same task works from the CLI in a terminal.
|
||||
*
|
||||
* At startup we ask the user's login shell for its PATH and merge it into
|
||||
* process.env.PATH, so child processes see the same PATH a terminal would.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { userInfo } from "node:os";
|
||||
import { basename, delimiter } from "node:path";
|
||||
|
||||
const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__";
|
||||
const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__";
|
||||
|
||||
/**
|
||||
* Kept well under the Tauri shell's 5s endpoint-readiness poll: this
|
||||
* resolution overlaps sidecar startup but is awaited before the server
|
||||
* starts, so a pathological shell profile must not eat the whole window.
|
||||
*/
|
||||
const SHELL_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* The command every shell is asked to run. $PATH expansion happens inside
|
||||
* POSIX sh — not the user's shell — so shells with different expansion rules
|
||||
* (fish would space-join "$PATH") still produce a colon-delimited value; sh
|
||||
* reads the PATH environment variable the login shell exported.
|
||||
*/
|
||||
const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`;
|
||||
|
||||
/**
|
||||
* Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched
|
||||
* (e.g. if a broken shell profile makes resolution misbehave).
|
||||
*/
|
||||
const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH";
|
||||
|
||||
export function defaultShellFor(platform: NodeJS.Platform): string {
|
||||
return platform === "darwin" ? "/bin/zsh" : "/bin/bash";
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's configured login shell. The account database is authoritative:
|
||||
* a GUI-launched process has no parent shell, so $SHELL may be unset there.
|
||||
* userInfo() reads getpwuid(), which on macOS goes through DirectoryServices
|
||||
* — the same source `dscl . -read /Users/$USER UserShell` reports — and on
|
||||
* Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform
|
||||
* default are fallbacks for environments with no passwd entry.
|
||||
*/
|
||||
export function loginShellFor(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
try {
|
||||
const shell = userInfo().shell?.trim();
|
||||
if (shell) {
|
||||
return shell;
|
||||
}
|
||||
} catch {
|
||||
// No passwd entry for the current uid (some containers) — fall through.
|
||||
}
|
||||
return env.SHELL?.trim() || defaultShellFor(platform);
|
||||
}
|
||||
|
||||
export interface ShellInvocation {
|
||||
args: string[];
|
||||
/**
|
||||
* argv[0] the shell should see. A leading dash is the historical "you
|
||||
* are a login shell" signal, used where -l can't be passed as a flag.
|
||||
*/
|
||||
argv0?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to invoke a shell so it sources its profiles and runs a command.
|
||||
* csh/tcsh accept -l only as the sole flag, so they're marked login via the
|
||||
* argv[0] dash convention instead (sources ~/.login on top of the always-read
|
||||
* ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile —
|
||||
* Homebrew's shellenv) plus interactive (-i, ~/.zshrc — nvm-style version
|
||||
* managers) as separate flags.
|
||||
*/
|
||||
export function shellInvocation(
|
||||
shell: string,
|
||||
command: string,
|
||||
): ShellInvocation {
|
||||
const kind = basename(shell);
|
||||
if (kind === "csh" || kind === "tcsh") {
|
||||
return { args: ["-c", command], argv0: `-${kind}` };
|
||||
}
|
||||
return { args: ["-i", "-l", "-c", command] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PATH value printed between the sentinel markers, ignoring any
|
||||
* noise a shell profile writes to stdout around it.
|
||||
*/
|
||||
export function extractMarkedPath(output: string): string | undefined {
|
||||
const start = output.indexOf(PATH_MARKER_START);
|
||||
if (start === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const end = output.indexOf(PATH_MARKER_END, start);
|
||||
if (end === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const value = output.slice(start + PATH_MARKER_START.length, end).trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the login shell's PATH with the current one: shell entries first (so
|
||||
* profile-managed dirs like /opt/homebrew/bin win), then any current entries
|
||||
* the shell PATH doesn't already contain (so explicitly-injected dirs from
|
||||
* the launching environment aren't lost). Duplicates are dropped.
|
||||
*/
|
||||
export function mergePaths(shellPath: string, currentPath: string): string {
|
||||
const entries = [
|
||||
...shellPath.split(delimiter),
|
||||
...currentPath.split(delimiter),
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return Array.from(new Set(entries)).join(delimiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the user's shell with its profiles sourced and capture its PATH.
|
||||
* Resolves to undefined on any failure (missing shell, timeout, profile
|
||||
* error) — callers should treat that as "keep the current PATH".
|
||||
*/
|
||||
export function resolveLoginShellPath(
|
||||
shell: string,
|
||||
timeoutMs = SHELL_TIMEOUT_MS,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const invocation = shellInvocation(shell, PRINT_PATH_COMMAND);
|
||||
const child = spawn(shell, invocation.args, {
|
||||
argv0: invocation.argv0,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
detached: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let settled = false;
|
||||
const settle = (value: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
}
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
settle(undefined);
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
output += data.toString("utf8");
|
||||
});
|
||||
child.on("error", () => settle(undefined));
|
||||
child.on("close", () => settle(extractMarkedPath(output)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the login shell's PATH and merge it into process.env.PATH. The
|
||||
* shell comes from the account database (see loginShellFor); if it can't
|
||||
* produce a PATH (exotic shell, broken profile), retry once with the
|
||||
* platform default shell before giving up.
|
||||
*
|
||||
* No-op on Windows (the GUI PATH comes from the registry there) and when
|
||||
* CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the
|
||||
* returned status but never block startup. The result never contains the
|
||||
* resolved PATH itself so it is safe to log verbatim.
|
||||
*/
|
||||
export async function ensureLoginShellPath(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
/** Test seam: overrides passwd/$SHELL discovery of the user's shell. */
|
||||
userShell?: string;
|
||||
/** Test seam: overrides the platform-default fallback shell. */
|
||||
fallbackShell?: string;
|
||||
}): Promise<
|
||||
| { status: "applied"; pathEntries: number; shell: string }
|
||||
| { status: "skipped"; reason: string }
|
||||
| { status: "failed"; shell: string }
|
||||
> {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const env = options?.env ?? process.env;
|
||||
|
||||
if (platform === "win32") {
|
||||
return { status: "skipped", reason: "windows" };
|
||||
}
|
||||
if (env[SKIP_ENV_VAR]?.trim()) {
|
||||
return { status: "skipped", reason: SKIP_ENV_VAR };
|
||||
}
|
||||
|
||||
const userShell = options?.userShell ?? loginShellFor(platform, env);
|
||||
const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform);
|
||||
const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS;
|
||||
// The fallback gets half the budget so the combined worst case stays
|
||||
// bounded even when both shells hang (see SHELL_TIMEOUT_MS).
|
||||
const attempts: Array<[shell: string, timeoutMs: number]> =
|
||||
userShell === fallbackShell
|
||||
? [[userShell, baseTimeoutMs]]
|
||||
: [
|
||||
[userShell, baseTimeoutMs],
|
||||
[fallbackShell, baseTimeoutMs / 2],
|
||||
];
|
||||
|
||||
for (const [shell, timeoutMs] of attempts) {
|
||||
const shellPath = await resolveLoginShellPath(shell, timeoutMs);
|
||||
if (!shellPath) {
|
||||
continue;
|
||||
}
|
||||
const merged = mergePaths(shellPath, env.PATH ?? "");
|
||||
env.PATH = merged;
|
||||
return {
|
||||
status: "applied",
|
||||
pathEntries: merged.split(delimiter).length,
|
||||
shell,
|
||||
};
|
||||
}
|
||||
return { status: "failed", shell: userShell };
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AgentToolContext,
|
||||
BasicLogger,
|
||||
ClineCore,
|
||||
HubServer,
|
||||
ITelemetryService,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
@@ -43,6 +42,7 @@ export type PromptInQueue = {
|
||||
prompt: string;
|
||||
steer: boolean;
|
||||
attachmentCount?: number;
|
||||
userImages?: string[];
|
||||
};
|
||||
|
||||
export type LiveSession = {
|
||||
@@ -57,6 +57,10 @@ export type LiveSession = {
|
||||
prompt?: string;
|
||||
title?: string;
|
||||
attachedViaHub?: boolean;
|
||||
/** Materialized attachment files for prompts still waiting in the queue. */
|
||||
queuedAttachmentFiles?: Map<string, string[]>;
|
||||
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
|
||||
consumedAttachmentFiles?: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export type ToolApprovalRequestItem = {
|
||||
@@ -107,7 +111,6 @@ export type SidecarContext = {
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
hubServer: HubServer | null;
|
||||
workspaceRoot: string;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
|
||||
+399
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
@@ -369,6 +378,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -504,11 +519,16 @@ dependencies = [
|
||||
name = "cline-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -718,6 +738,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1017,6 +1048,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1579,6 +1620,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1750,6 +1806,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2016,6 +2085,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2037,6 +2112,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.1"
|
||||
@@ -2143,9 +2228,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2165,6 +2258,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2225,6 +2319,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -2248,6 +2355,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2263,6 +2371,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2326,6 +2446,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2342,6 +2468,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2645,6 +2785,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -2796,15 +2942,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2840,6 +2991,20 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2868,6 +3033,79 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2883,6 +3121,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2946,6 +3193,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3277,6 +3547,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -3393,6 +3669,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3416,6 +3703,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3512,6 +3800,55 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.1"
|
||||
@@ -3730,6 +4067,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4025,6 +4372,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4387,6 +4740,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4617,6 +4979,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -5035,6 +5406,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -5160,6 +5541,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
@@ -5193,6 +5580,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -9,11 +9,16 @@ tauri-build = { version = "2.0.0", features = [] }
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.1", features = [] }
|
||||
tauri = { version = "2.11.1", features = ["image-png", "tray-icon"] }
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
rfd = "0.15"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main-window",
|
||||
"description": "Permissions required by the main desktop window.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 439 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user