Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
ad3b8af32a chore(release): v0.5.1-nightly.20251219.d07ae35c 2025-12-19 00:21:42 +00:00
36 changed files with 899 additions and 736 deletions

View File

@@ -18,6 +18,8 @@ jobs:
- 'sandbox:docker'
node-version:
- '20.x'
- '22.x'
- '24.x'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
@@ -65,13 +67,10 @@ jobs:
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
KEEP_OUTPUT: 'true'
SANDBOX: '${{ matrix.sandbox }}'
VERBOSE: 'true'
run: |-
if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then
npm run test:integration:sandbox:docker
else
npm run test:integration:sandbox:none
fi
npm run "test:integration:${SANDBOX}"
e2e-test-macos:
name: 'E2E Test - macOS'

View File

@@ -121,11 +121,6 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Build CLI Bundle'
run: |
npm run build
npm run bundle
- name: 'Run Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
@@ -137,6 +132,13 @@ jobs:
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Build CLI for Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run build
npm run bundle
- name: 'Run SDK Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}

View File

@@ -133,8 +133,8 @@ jobs:
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run preflight
npm run test:integration:cli:sandbox:none
npm run test:integration:cli:sandbox:docker
npm run test:integration:sandbox:none
npm run test:integration:sandbox:docker
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'

View File

@@ -5,7 +5,6 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
## Documentation Sections
### [User Guide](./users/overview)
Learn how to use Qwen Code as an end user. This section covers:
- Basic installation and setup

View File

@@ -69,7 +69,7 @@ Settings are organized into categories. All settings should be placed within the
| Setting | Type | Description | Default |
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
@@ -357,38 +357,38 @@ Arguments passed directly when running the CLI can override other configurations
### Command-Line Arguments Table
| Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
## Context Files (Hierarchical Instructional Context)

View File

@@ -16,15 +16,16 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
### 2. Discovery Mechanism: The Lock File
### 2. Discovery Mechanism: The Port File
For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable.
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
`<PORT>.lock`
- `<PORT>`: The port your MCP server is listening on.
`qwen-code-ide-server-${PID}-${PORT}.json`
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
- `${PORT}`: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
```json
@@ -32,20 +33,21 @@ For Qwen Code to connect, it needs to discover what port your server is using. T
"port": 12345,
"workspacePath": "/path/to/project1:/path/to/project2",
"authToken": "a-very-secret-token",
"ppid": 1234,
"ideName": "VS Code"
"ideInfo": {
"name": "vscode",
"displayName": "VS Code"
}
}
```
- `port` (number, required): The port of the MCP server.
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
- `ppid` (number, required): The parent process ID of the IDE process.
- `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- `ideInfo` (object, required): Information about the IDE.
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `<PORT>.lock` file.
**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
## II. The Context Interface

44
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"workspaces": [
"packages/*"
],
@@ -16386,7 +16386,6 @@
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
"integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -17494,7 +17493,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -17525,7 +17524,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"undici": "^7.10.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
"yargs": "^17.7.2",
@@ -17607,18 +17606,9 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/cli/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -17661,7 +17651,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"undici": "^7.10.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
@@ -17756,22 +17746,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"packages/core/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"tiktoken": "^1.0.21"
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^20.14.0",
@@ -20197,7 +20177,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -20209,7 +20189,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
@@ -20229,7 +20209,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
@@ -20244,7 +20224,7 @@
"vitest": "^3.2.4"
},
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.99.0"
}
},
"packages/vscode-ide-companion/node_modules/@types/react": {

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1-nightly.20251219.d07ae35c"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1-nightly.20251219.d07ae35c"
},
"dependencies": {
"@google/genai": "1.16.0",
@@ -64,7 +64,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"undici": "^7.10.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -63,7 +63,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"undici": "^7.10.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},

View File

@@ -15,7 +15,6 @@ export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
export class Storage {
private readonly targetDir: string;
@@ -60,10 +59,6 @@ export class Storage {
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
}
static getGlobalIdeDir(): string {
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
}
static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
}

View File

@@ -151,7 +151,8 @@ describe('BaseLlmClient', () => {
contents: defaultOptions.contents,
config: {
abortSignal: defaultOptions.abortSignal,
topP: 0.8,
temperature: 0,
topP: 1,
tools: [
{
functionDeclarations: [
@@ -188,7 +189,7 @@ describe('BaseLlmClient', () => {
expect.objectContaining({
config: expect.objectContaining({
temperature: 0.8,
topP: 0.8, // Default should remain if not overridden
topP: 1, // Default should remain if not overridden
topK: 10,
tools: expect.any(Array),
}),

View File

@@ -66,7 +66,8 @@ export interface GenerateJsonOptions {
export class BaseLlmClient {
// Default configuration for utility tasks
private readonly defaultUtilityConfig: GenerateContentConfig = {
topP: 0.8,
temperature: 0,
topP: 1,
};
constructor(

View File

@@ -2310,7 +2310,7 @@ ${JSON.stringify(
abortSignal,
systemInstruction: getCoreSystemPrompt(''),
temperature: 0.5,
topP: 0.8,
topP: 1,
},
contents,
},

View File

@@ -94,7 +94,8 @@ const MAX_TURNS = 100;
export class GeminiClient {
private chat?: GeminiChat;
private readonly generateContentConfig: GenerateContentConfig = {
topP: 0.8,
temperature: 0,
topP: 1,
};
private sessionTurnCount = 0;

View File

@@ -32,7 +32,6 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises,
readFile: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
@@ -69,7 +68,6 @@ describe('IdeClient', () => {
command: 'test-ide',
});
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports
mockClient = {
@@ -99,15 +97,19 @@ describe('IdeClient', () => {
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '8080.lock'),
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -118,13 +120,16 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using stdio when stdio config is provided in file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -137,16 +142,19 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should prioritize port over stdio when both are in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
};
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -156,7 +164,6 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using HTTP when port is provided in environment variables', async () => {
@@ -256,8 +263,7 @@ describe('IdeClient', () => {
});
describe('getConnectionConfigFromFile', () => {
it('should return config from the env port lock file if it exists', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
it('should return config from the specific pid file if it exists', async () => {
const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -271,14 +277,18 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '1234.lock'),
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should return undefined if no config files are found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -290,15 +300,20 @@ describe('IdeClient', () => {
expect(result).toBeUndefined();
});
it('should read legacy pid config when available', async () => {
const config = {
port: '5678',
workspacePath: '/test/workspace',
ppid: 12345,
};
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(config),
);
it('should find and parse a single config file with the new naming scheme', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
); // For old path
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -309,18 +324,110 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
'utf8',
);
});
it('should fall back to legacy port file when pid file is missing', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
it('should filter out configs with invalid workspace paths', async () => {
const validConfig = {
port: '5678',
workspacePath: '/test/workspace',
};
const invalidConfig = {
port: '1111',
workspacePath: '/invalid/workspace',
};
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false })
.mockReturnValueOnce({ isValid: true });
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(validateSpy).toHaveBeenCalledWith(
'/invalid/workspace',
'/test/workspace/sub-dir',
);
expect(validateSpy).toHaveBeenCalledWith(
'/test/workspace',
'/test/workspace/sub-dir',
);
});
it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config1);
});
it('should prioritize the config matching the port from the environment variable', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -330,23 +437,28 @@ describe('IdeClient', () => {
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-2222.json'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should fall back to legacy config when env lock file has invalid JSON', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config = { port: '1111', workspacePath: '/test/workspace' };
it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(config));
.mockResolvedValueOnce(JSON.stringify(validConfig));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -355,7 +467,96 @@ describe('IdeClient', () => {
}
).getConnectionConfigFromFile();
expect(result).toEqual(config);
expect(result).toEqual(validConfig);
});
it('should return undefined if readdir throws an error', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
vi.mocked(fs.promises.readdir).mockRejectedValue(
new Error('readdir failed'),
);
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toBeUndefined();
});
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json', // valid
'not-a-config-file.txt', // invalid
'qwen-code-ide-server-asdf.json', // invalid
]);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
'utf8',
);
});
it('should match env port string to a number port in the config', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config1 = { port: 1111, workspacePath: '/test/workspace' };
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
});

View File

@@ -8,7 +8,6 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import {
IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema,
@@ -573,103 +572,98 @@ export class IdeClient {
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
try {
const ideDir = Storage.getGlobalIdeDir();
const lockFile = path.join(ideDir, `${portFromEnv}.lock`);
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
return JSON.parse(lockFileContents);
} catch (_) {
// Fall through to legacy discovery.
}
if (!this.ideProcessInfo) {
return undefined;
}
// Legacy discovery for VSCode extension < v0.5.1.
return this.getLegacyConnectionConfig(portFromEnv);
}
// Legacy connection files were written in the global temp directory.
private async getLegacyConnectionConfig(
portFromEnv?: string,
): Promise<
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
if (this.ideProcessInfo) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For older/newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
}
if (portFromEnv) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${portFromEnv}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// Ignore and fall through.
}
}
return undefined;
}
protected async getAllConnectionConfigs(
ideDir: string,
): Promise<
ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }>
> {
const fileRegex = new RegExp('^\\d+\\.lock$');
let lockFiles: string[];
// For backwards compatability
try {
lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return [];
return undefined;
}
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
const stat = await fs.promises.stat(fullPath);
const content = await fs.promises.readFile(fullPath, 'utf8');
try {
const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) {
logger.debug('Failed to parse JSON from lock file: ', e);
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
}
} catch (e) {
// If we can't stat/read the file, treat it as very old so it doesn't
// win ties, and skip parsing by returning undefined content.
logger.debug('Failed to read/stat IDE lock file:', e);
return { file, mtimeMs: -Infinity, parsed: undefined };
}
}),
);
if (!portFiles) {
return undefined;
}
return fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed);
const fileRegex = new RegExp(
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
let fileContents: string[];
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
return undefined;
}
const parsedContents = fileContents.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
});
const validWorkspaces = parsedContents.filter((content) => {
if (!content) {
return false;
}
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
}
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
}
}
return validWorkspaces[0];
}
private createProxyAwareFetch() {

View File

@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
});
it('should return shell process info if grandparent lookup fails', async () => {
it('should return parent process info if grandparent lookup fails', async () => {
(os.platform as Mock).mockReturnValue('linux');
mockedExec
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
@@ -63,96 +63,134 @@ describe('getIdeProcessInfo', () => {
});
describe('on Windows', () => {
it('should return great-grandparent process using heuristic', async () => {
it('should traverse up and find the great-grandchild of the root process', async () => {
(os.platform as Mock).mockReturnValue('win32');
const processes = [
{
ProcessId: 1000,
ParentProcessId: 900,
Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 800,
Name: 'powershell.exe',
CommandLine: 'powershell.exe',
},
{
ProcessId: 800,
ParentProcessId: 700,
Name: 'code.exe',
CommandLine: 'code.exe',
},
{
ProcessId: 700,
ParentProcessId: 0,
Name: 'wininit.exe',
CommandLine: 'wininit.exe',
},
];
mockedExec.mockImplementation((file: string, _args: string[]) => {
if (file === 'powershell') {
return Promise.resolve({ stdout: JSON.stringify(processes) });
const processInfoMap = new Map([
[
1000,
{
stdout:
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}',
},
],
[
900,
{
stdout:
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}',
},
],
[
700,
{
stdout:
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}',
},
],
]);
mockedExec.mockImplementation((command: string) => {
const pidMatch = command.match(/ProcessId=(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid));
}
return Promise.resolve({ stdout: '' });
return Promise.reject(new Error('Invalid command for mock'));
});
const result = await getIdeProcessInfo();
// Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe)
// ancestors = [1000, 900, 800, 700], length = 4
// Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe)
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
});
it('should handle empty process list gracefully', async () => {
it('should handle non-existent process gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec.mockResolvedValue({ stdout: '[]' });
mockedExec
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo();
// Should return current pid and empty command because process not found in map
expect(result).toEqual({ pid: 1000, command: '' });
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
});
it('should handle malformed JSON output gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
mockedExec
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: '' });
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
});
it('should return last ancestor if chain is too short', async () => {
it('should handle PowerShell errors without crashing the process chain', async () => {
(os.platform as Mock).mockReturnValue('win32');
const processInfoMap = new Map([
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
[
1001,
{
stdout:
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
},
],
]);
const processes = [
{
ProcessId: 1000,
ParentProcessId: 900,
Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 0,
Name: 'explorer.exe',
CommandLine: 'explorer.exe',
},
];
// Mock the process.pid to test traversal with missing processes
Object.defineProperty(process, 'pid', {
value: 1001,
configurable: true,
});
mockedExec.mockImplementation((file: string, _args: string[]) => {
if (file === 'powershell') {
return Promise.resolve({ stdout: JSON.stringify(processes) });
mockedExec.mockImplementation((command: string) => {
const pidMatch = command.match(/ProcessId=(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
}
return Promise.resolve({ stdout: '' });
return Promise.reject(new Error('Invalid command for mock'));
});
const result = await getIdeProcessInfo();
// ancestors = [1000, 900], length = 2 (< 3)
// Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
// Should return the current process command since traversal continues despite missing processes
expect(result).toEqual({ pid: 1001, command: 'parent.exe' });
// Reset process.pid
Object.defineProperty(process, 'pid', {
value: 1000,
configurable: true,
});
});
it('should handle partial JSON data with defaults', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
.mockResolvedValueOnce({
stdout:
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
}); // Get grandparent info
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
});
});
});

View File

@@ -4,43 +4,74 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { exec, execFile } from 'node:child_process';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
import path from 'node:path';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_TRAVERSAL_DEPTH = 32;
/**
* Fetches the parent process ID, name, and command for a given process ID.
*
* @param pid The process ID to inspect.
* @returns A promise that resolves to the parent's PID, name, and command.
*/
async function getProcessInfo(pid: number): Promise<{
parentPid: number;
name: string;
command: string;
}> {
// Only used for Unix systems (macOS and Linux)
try {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
return { parentPid: 0, name: '', command: '' };
const platform = os.platform();
if (platform === 'win32') {
const powershellCommand = [
'$p = Get-CimInstance Win32_Process',
`-Filter 'ProcessId=${pid}'`,
'-ErrorAction SilentlyContinue;',
'if ($p) {',
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
'| ConvertTo-Json',
'}',
].join(' ');
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
const output = stdout.trim();
if (!output) return { parentPid: 0, name: '', command: '' };
const {
Name = '',
ParentProcessId = 0,
CommandLine = '',
} = JSON.parse(output);
return {
parentPid: ParentProcessId,
name: Name,
command: CommandLine ?? '',
};
} else {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
return { parentPid: 0, name: '', command: '' };
}
const ppidString = trimmedStdout.split(/\s+/)[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
const processName = path.basename(fullCommand.split(' ')[0]);
return {
parentPid: isNaN(parentPid) ? 1 : parentPid,
name: processName,
command: fullCommand,
};
}
const parts = trimmedStdout.split(/\s+/);
const ppidString = parts[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
const processName = path.basename(fullCommand.split(' ')[0]);
return {
parentPid: isNaN(parentPid) ? 1 : parentPid,
name: processName,
command: fullCommand,
};
} catch (_e) {
console.debug(`Failed to get process info for pid ${pid}:`, _e);
return { parentPid: 0, name: '', command: '' };
}
}
/**
* Finds the IDE process info on Unix-like systems.
*
@@ -75,15 +106,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
} catch {
// Ignore if getting grandparent fails, we'll just use the parent pid.
}
const { command: ideCommand } = await getProcessInfo(idePid);
return { pid: idePid, command: ideCommand };
const { command } = await getProcessInfo(idePid);
return { pid: idePid, command };
}
if (parentPid <= 1) {
break; // Reached the root
}
currentPid = parentPid;
} catch (_e) {
} catch {
// Process in chain died
break;
}
@@ -93,104 +124,50 @@ async function getIdeProcessInfoForUnix(): Promise<{
return { pid: currentPid, command };
}
interface ProcessInfo {
pid: number;
parentPid: number;
name: string;
command: string;
}
interface RawProcessInfo {
ProcessId?: number;
ParentProcessId?: number;
Name?: string;
CommandLine?: string;
}
/**
* Fetches the entire process table on Windows.
* Finds the IDE process info on Windows.
*
* The strategy is to find the great-grandchild of the root process.
*
* @returns A promise that resolves to the PID and command of the IDE process.
*/
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
const processMap = new Map<number, ProcessInfo>();
try {
const powershellCommand =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
const { stdout } = await execFileAsync(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', powershellCommand],
{ maxBuffer: 10 * 1024 * 1024 },
);
if (!stdout.trim()) {
return processMap;
}
let processes: RawProcessInfo | RawProcessInfo[];
try {
processes = JSON.parse(stdout);
} catch (_e) {
return processMap;
}
if (!Array.isArray(processes)) {
processes = [processes];
}
for (const p of processes) {
if (p && typeof p.ProcessId === 'number') {
processMap.set(p.ProcessId, {
pid: p.ProcessId,
parentPid: p.ParentProcessId || 0,
name: p.Name || '',
command: p.CommandLine || '',
});
}
}
} catch (_e) {
// Fallback or error handling if PowerShell fails
}
return processMap;
}
async function getIdeProcessInfoForWindows(): Promise<{
pid: number;
command: string;
}> {
// Fetch the entire process table in one go.
const processMap = await getProcessTableWindows();
let currentPid = process.pid;
let previousPid = process.pid;
const myPid = process.pid;
const myProc = processMap.get(myPid);
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
try {
const { parentPid } = await getProcessInfo(currentPid);
if (!myProc) {
// Fallback: return current process info if snapshot fails
return { pid: myPid, command: '' };
}
if (parentPid > 0) {
try {
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
if (grandParentPid === 0) {
// We've found the grandchild of the root (`currentPid`). The IDE
// process is its child, which we've stored in `previousPid`.
const { command } = await getProcessInfo(previousPid);
return { pid: previousPid, command };
}
} catch {
// getting grandparent failed, proceed
}
}
// Perform tree traversal in memory
const ancestors: ProcessInfo[] = [];
let curr: ProcessInfo | undefined = myProc;
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
ancestors.push(curr);
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
// Parent process not in map, stop traversal
if (parentPid <= 0) {
break; // Reached the root
}
previousPid = currentPid;
currentPid = parentPid;
} catch {
// Process in chain died
break;
}
curr = processMap.get(curr.parentPid);
}
// Use heuristic: return the great-grandparent (ancestors[length-3])
if (ancestors.length >= 3) {
const target = ancestors[ancestors.length - 3];
return { pid: target.pid, command: target.command };
} else if (ancestors.length > 0) {
const target = ancestors[ancestors.length - 1];
return { pid: target.pid, command: target.command };
}
return { pid: myPid, command: myProc.command };
const { command } = await getProcessInfo(currentPid);
return { pid: currentPid, command };
}
/**

View File

@@ -13,8 +13,9 @@ npm install @qwen-code/sdk
## Requirements
- Node.js >= 20.0.0
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed.
> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary.
## Quick Start
@@ -371,23 +372,6 @@ try {
}
```
## FAQ / Troubleshooting
### Version 0.1.0 Requirements
If you're using SDK version **0.1.0**, please note the following requirements:
#### Qwen Code Installation Required
Version 0.1.0 requires [Qwen Code](https://github.com/QwenLM/qwen-code) **>= 0.4.0** to be installed separately and accessible in your PATH.
```bash
# Install Qwen Code globally
npm install -g qwen-code@^0.4.0
```
**Note**: From version **0.1.1** onwards, the CLI is bundled with the SDK, so no separate Qwen Code installation is needed.
## License
Apache-2.0 - see [LICENSE](./LICENSE) for details.

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
@@ -45,8 +45,7 @@
"node": ">=18.0.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"tiktoken": "^1.0.21"
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^20.14.0",

View File

@@ -91,35 +91,3 @@ if (existsSync(licenseSource)) {
console.warn('Could not copy LICENSE:', error.message);
}
}
console.log('Bundling CLI into SDK package...');
const repoRoot = join(rootDir, '..', '..');
const rootDistDir = join(repoRoot, 'dist');
if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) {
console.log('Building CLI bundle...');
try {
execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot });
} catch (error) {
console.error('Failed to build CLI bundle:', error.message);
throw error;
}
}
const cliDistDir = join(rootDir, 'dist', 'cli');
mkdirSync(cliDistDir, { recursive: true });
console.log('Copying CLI bundle...');
cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js'));
const vendorSource = join(rootDistDir, 'vendor');
if (existsSync(vendorSource)) {
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
}
const localesSource = join(rootDistDir, 'locales');
if (existsSync(localesSource)) {
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
}
console.log('CLI bundle copied successfully to SDK package');

View File

@@ -2,16 +2,24 @@
* CLI path auto-detection and subprocess spawning utilities
*
* Supports multiple execution modes:
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
* 1. Native binary: 'qwen' (production)
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
*
* Auto-detection locations for native binary:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
/**
* Executable types supported by the SDK
@@ -32,38 +40,49 @@ export type SpawnInfo = {
originalInput: string;
};
function getBundledCliPath(): string | null {
try {
const currentFile =
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
if (fs.existsSync(bundledCliPath)) {
return bundledCliPath;
}
return null;
} catch {
return null;
}
}
export function findNativeCliPath(): string {
const bundledCli = getBundledCliPath();
if (bundledCli) {
return bundledCli;
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
const candidates: Array<string | undefined> = [
// 1. Environment variable (highest priority)
process.env['QWEN_CODE_CLI_PATH'],
// 2. Volta bin
path.join(homeDir, '.volta', 'bin', 'qwen'),
// 3. Global npm installations
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
// 4. Common Unix binary locations
'/usr/local/bin/qwen',
// 5. User local bin
path.join(homeDir, '.local', 'bin', 'qwen'),
// 6. Node modules bin in home directory
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
// 7. Yarn global bin
path.join(homeDir, '.yarn', 'bin', 'qwen'),
];
// Find first existing candidate
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return path.resolve(candidate);
}
}
// Not found - throw helpful error
throw new Error(
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
'If you need to use a custom CLI, provide explicit executable:\n' +
' query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
'qwen CLI not found. Please:\n' +
' 1. Install qwen globally: npm install -g qwen\n' +
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
'\n' +
'For development/testing, you can also use:\n' +
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
);
}

View File

@@ -38,8 +38,6 @@ describe('CLI Path Utilities', () => {
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
// Default: return true for existsSync (can be overridden in specific tests)
mockFs.existsSync.mockReturnValue(true);
});
afterEach(() => {
@@ -52,26 +50,28 @@ describe('CLI Path Utilities', () => {
describe('parseExecutableSpec', () => {
describe('auto-detection (no spec provided)', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
it('should auto-detect native CLI when no spec provided', () => {
// Mock environment variable
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec();
expect(result.executablePath).toContain('cli.js');
expect(result.isExplicitRuntime).toBe(false);
expect(result).toEqual({
executablePath: path.resolve('/usr/local/bin/qwen'),
isExplicitRuntime: false,
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should throw when bundled CLI not found', () => {
it('should throw when auto-detection fails', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec()).toThrow(
'Bundled qwen CLI not found',
'qwen CLI not found. Please:',
);
});
});
@@ -361,44 +361,65 @@ describe('CLI Path Utilities', () => {
});
describe('auto-detection fallback', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
it('should auto-detect when no spec provided', () => {
// Mock environment variable
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
const result = prepareSpawnInfo();
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
expect(result).toEqual({
command: path.resolve('/usr/local/bin/qwen'),
args: [],
type: 'native',
originalInput: '',
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
});
});
describe('findNativeCliPath', () => {
it('should find bundled CLI', () => {
// Mock existsSync to return true for bundled CLI
it('should find CLI from environment variable', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = findNativeCliPath();
expect(result).toBe(path.resolve('/custom/path/to/qwen'));
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should search common installation locations', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
// Mock fs.existsSync to return true for volta bin
// Use path.join to match platform-specific path separators
const voltaBinPath = path.join('.volta', 'bin', 'qwen');
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
return p.toString().includes(voltaBinPath);
});
const result = findNativeCliPath();
expect(result).toContain('cli.js');
expect(result).toContain(voltaBinPath);
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should throw descriptive error when bundled CLI not found', () => {
it('should throw descriptive error when CLI not found', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
mockFs.existsSync.mockReturnValue(false);
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
});
@@ -613,10 +634,13 @@ describe('CLI Path Utilities', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Executable file not found at',
'Set QWEN_CODE_CLI_PATH environment variable',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Please check the file path and ensure the file exists',
'Install qwen globally: npm install -g qwen',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.6.0",
"version": "0.5.1-nightly.20251219.d07ae35c",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {
@@ -11,7 +11,7 @@
"directory": "packages/vscode-ide-companion"
},
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.99.0"
},
"license": "LICENSE",
"preview": true,
@@ -137,7 +137,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",

View File

@@ -35,7 +35,7 @@ function npmBin() {
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
shell: process.platform === 'win32' ? true : false,
...opts,
});
if (res.error) {

View File

@@ -27,14 +27,13 @@ vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve(undefined)),
unlink: vi.fn(() => Promise.resolve(undefined)),
chmod: vi.fn(() => Promise.resolve(undefined)),
mkdir: vi.fn(() => Promise.resolve(undefined)),
}));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
return {
...actual,
homedir: vi.fn(() => '/home/test'),
tmpdir: vi.fn(() => '/tmp'),
};
});
@@ -129,24 +128,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should set a single folder path', async () => {
@@ -161,24 +166,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '/foo/bar',
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should set an empty string if no folders are open', async () => {
@@ -193,24 +204,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '',
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should update the path when workspace folders change', async () => {
@@ -239,24 +256,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
// Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
@@ -271,26 +294,36 @@ describe('IDEServer', () => {
workspacePath: '/baz/qux',
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should clear env vars and delete lock file on stop', async () => {
it('should clear env vars and delete port file on stop', async () => {
await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace;
const port = getPortFromMock(replaceMock);
const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`);
expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`);
const ppidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(lockFile);
expect(fs.unlink).toHaveBeenCalledWith(portFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
});
it.skipIf(process.platform !== 'win32')(
@@ -311,24 +344,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
},
);
@@ -340,7 +379,7 @@ describe('IDEServer', () => {
port = (ideServer as unknown as { port: number }).port;
});
it('should reject request without auth token', async () => {
it('should allow request without auth token for backwards compatibility', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -351,9 +390,7 @@ describe('IDEServer', () => {
id: 1,
}),
});
expect(response.status).toBe(401);
const body = await response.text();
expect(body).toBe('Unauthorized');
expect(response.status).not.toBe(401);
});
it('should allow request with valid auth token', async () => {
@@ -513,7 +550,6 @@ describe('IDEServer HTTP endpoints', () => {
headers: {
Host: `localhost:${port}`,
'Content-Type': 'application/json',
Authorization: 'Bearer test-auth-token',
},
},
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),

View File

@@ -10,7 +10,6 @@ import {
IdeContextNotificationSchema,
OpenDiffRequestSchema,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -39,24 +38,12 @@ class CORSError extends Error {
const MCP_SESSION_ID_HEADER = 'mcp-session-id';
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
const QWEN_DIR = '.qwen';
const IDE_DIR = 'ide';
async function getGlobalIdeDir(): Promise<string> {
const homeDir = os.homedir();
// Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior).
const baseDir = homeDir
? path.join(homeDir, QWEN_DIR)
: path.join(os.tmpdir(), QWEN_DIR);
const ideDir = path.join(baseDir, IDE_DIR);
await fs.mkdir(ideDir, { recursive: true });
return ideDir;
}
interface WritePortAndWorkspaceArgs {
context: vscode.ExtensionContext;
port: number;
lockFile: string;
portFile: string;
ppidPortFile: string;
authToken: string;
log: (message: string) => void;
}
@@ -64,7 +51,8 @@ interface WritePortAndWorkspaceArgs {
async function writePortAndWorkspace({
context,
port,
lockFile,
portFile,
ppidPortFile,
authToken,
log,
}: WritePortAndWorkspaceArgs): Promise<void> {
@@ -83,24 +71,26 @@ async function writePortAndWorkspace({
workspacePath,
);
const ideInfo = detectIdeFromEnv();
const content = JSON.stringify({
port,
workspacePath,
ppid: process.ppid,
authToken,
ideName: ideInfo.displayName,
});
log(`Writing IDE lock file to: ${lockFile}`);
log(`Writing port file to: ${portFile}`);
log(`Writing ppid port file to: ${ppidPortFile}`);
try {
await fs.mkdir(path.dirname(lockFile), { recursive: true });
await fs.writeFile(lockFile, content);
await fs.chmod(lockFile, 0o600);
await Promise.all([
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)),
fs
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to write IDE lock file: ${message}`);
log(`Failed to write port to file: ${message}`);
}
}
@@ -131,7 +121,8 @@ export class IDEServer {
private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void;
private lockFile: string | undefined;
private portFile: string | undefined;
private ppidPortFile: string | undefined;
private port: number | undefined;
private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
@@ -183,24 +174,19 @@ export class IDEServer {
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
this.log('Missing Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
}
next();
});
@@ -341,21 +327,22 @@ export class IDEServer {
const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') {
this.port = address.port;
try {
const ideDir = await getGlobalIdeDir();
// Name the lock file by port to support multiple server instances.
this.lockFile = path.join(ideDir, `${this.port}.lock`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.log(`Failed to determine IDE lock directory: ${message}`);
}
this.portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.port}.json`,
);
this.ppidPortFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${process.ppid}.json`,
);
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken && this.lockFile) {
if (this.authToken) {
await writePortAndWorkspace({
context,
port: this.port,
lockFile: this.lockFile,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken,
log: this.log,
});
@@ -384,13 +371,15 @@ export class IDEServer {
this.context &&
this.server &&
this.port &&
this.lockFile &&
this.portFile &&
this.ppidPortFile &&
this.authToken
) {
await writePortAndWorkspace({
context: this.context,
port: this.port,
lockFile: this.lockFile,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken,
log: this.log,
});
@@ -416,9 +405,16 @@ export class IDEServer {
if (this.context) {
this.context.environmentVariableCollection.clear();
}
if (this.lockFile) {
if (this.portFile) {
try {
await fs.unlink(this.lockFile);
await fs.unlink(this.portFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}

View File

@@ -54,31 +54,27 @@ export class AcpSessionManager {
};
return new Promise((resolve, reject) => {
// No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer
// The request should always terminate with a stop_reason
let timeoutId: NodeJS.Timeout | undefined;
let timeoutDuration: number | undefined;
if (method !== AGENT_METHODS.session_prompt) {
// Set timeout for other methods
timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000;
timeoutId = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
// different timeout durations based on methods
let timeoutDuration = 60000; // default 60 seconds
if (
method === AGENT_METHODS.session_prompt ||
method === AGENT_METHODS.initialize
) {
timeoutDuration = 120000; // 2min for session_prompt and initialize
}
const timeoutId = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
const pendingRequest: PendingRequest<T> = {
resolve: (value: T) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
clearTimeout(timeoutId);
resolve(value);
},
reject: (error: Error) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
clearTimeout(timeoutId);
reject(error);
},
timeoutId,

View File

@@ -144,7 +144,10 @@ export const InputForm: React.FC<InputFormProps> = ({
: '';
return (
<div className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0 bg-gradient-to-b from-transparent to-[var(--app-primary-background)]">
<div
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
<div className="block">
<form className="composer-form" onSubmit={onSubmit}>
{/* Inner background layer */}

View File

@@ -48,5 +48,5 @@
}
.assistant-message-container.assistant-message-loading::after {
display: none;
display: none
}

View File

@@ -172,8 +172,7 @@
/* Loading animation for toolcall header */
@keyframes toolcallHeaderPulse {
0%,
100% {
0%, 100% {
opacity: 1;
}
50% {

View File

@@ -152,24 +152,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
this.currentStreamContent = '';
}
/**
* Notify the webview that streaming has finished.
*/
private sendStreamEnd(reason?: string): void {
const data: { timestamp: number; reason?: string } = {
timestamp: Date.now(),
};
if (reason) {
data.reason = reason;
}
this.sendToWebView({
type: 'streamEnd',
data,
});
}
/**
* Prompt user to login and invoke the registered login handler/command.
* Returns true if a login was initiated.
@@ -391,7 +373,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
);
}
this.sendStreamEnd();
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
});
} catch (error) {
console.error('[SessionMessageHandler] Error sending message:', error);
@@ -413,7 +398,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (isAbortLike) {
// Do not show VS Code error popup for intentional cancellations.
// Ensure the webview knows the stream ended due to user action.
this.sendStreamEnd('user_cancelled');
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'user_cancelled' },
});
return;
}
// Check for session not found error and handle it appropriately
@@ -435,39 +423,12 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'sessionExpired',
data: { message: 'Session expired. Please login again.' },
});
this.sendStreamEnd('session_expired');
} else {
const isTimeoutError =
lower.includes('timeout') || lower.includes('timed out');
if (isTimeoutError) {
// Note: session_prompt no longer has a timeout, so this should rarely occur
// This path may still be hit for other methods (initialize, etc.) or network-level timeouts
console.warn(
'[SessionMessageHandler] Request timed out; suppressing popup',
);
const timeoutMessage: ChatMessage = {
role: 'assistant',
content:
'Request timed out. This may be due to a network issue. Please try again.',
timestamp: Date.now(),
};
// Send a timeout message to the WebView
this.sendToWebView({
type: 'message',
data: timeoutMessage,
});
this.sendStreamEnd('timeout');
} else {
// Handling of Non-Timeout Errors
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
this.sendStreamEnd('error');
}
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
}
}
}

View File

@@ -15,14 +15,6 @@ import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
'user_cancelled',
'cancelled',
'timeout',
'error',
'session_expired',
]);
interface UseWebViewMessagesProps {
// Session management
sessionManagement: {
@@ -372,12 +364,12 @@ export const useWebViewMessages = ({
).toLowerCase();
/**
* Handle different types of stream end reasons that require a full reset:
* - 'user_cancelled' / 'cancelled': user explicitly cancelled
* - 'timeout' / 'error' / 'session_expired': request failed unexpectedly
* For these cases, immediately clear all active states.
* Handle different types of stream end reasons:
* - 'user_cancelled': User explicitly cancelled operation
* - 'cancelled': General cancellation
* For these cases, immediately clear all active states
*/
if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) {
if (reason === 'user_cancelled' || reason === 'cancelled') {
// Clear active execution tool call tracking, reset state
activeExecToolCallsRef.current.clear();
// Clear waiting response state to ensure UI returns to normal
@@ -401,9 +393,6 @@ export const useWebViewMessages = ({
}
case 'error':
handlers.messageHandling.endStreaming();
handlers.messageHandling.clearThinking();
activeExecToolCallsRef.current.clear();
handlers.messageHandling.clearWaitingForResponse();
break;

View File

@@ -43,7 +43,7 @@
/* Composer: form wrapper */
.composer-form {
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200 z-[1];
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200;
background: var(--app-input-secondary-background);
border-color: var(--app-input-border);
color: var(--app-input-foreground);
@@ -51,8 +51,7 @@
.composer-form:focus-within {
/* match existing highlight behavior */
border-color: var(--app-input-highlight);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
}
/* Composer: input editable area */
@@ -67,7 +66,7 @@
The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */
.composer-input:empty:before,
.composer-input[data-empty='true']::before {
.composer-input[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
@@ -81,7 +80,7 @@
outline: none;
}
.composer-input:disabled,
.composer-input[contenteditable='false'] {
.composer-input[contenteditable="false"] {
color: #999;
cursor: not-allowed;
}