diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml
index 69192520..c5e30ee9 100644
--- a/.github/workflows/release-sdk.yml
+++ b/.github/workflows/release-sdk.yml
@@ -121,6 +121,11 @@ 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' }}
@@ -132,13 +137,6 @@ 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' }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0c4ff85a..ffcda3dc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -133,8 +133,8 @@ jobs:
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run preflight
- npm run test:integration:sandbox:none
- npm run test:integration:sandbox:docker
+ npm run test:integration:cli:sandbox:none
+ npm run test:integration:cli:sandbox:docker
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
diff --git a/docs/index.md b/docs/index.md
index 73a33775..ff8a4803 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,7 @@ 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
diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md
index ba3ea3a2..658e4835 100644
--- a/docs/users/configuration/settings.md
+++ b/docs/users/configuration/settings.md
@@ -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`
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)
diff --git a/docs/users/ide-integration/ide-companion-spec.md b/docs/users/ide-integration/ide-companion-spec.md
index 3cf35d75..37b0b833 100644
--- a/docs/users/ide-integration/ide-companion-spec.md
+++ b/docs/users/ide-integration/ide-companion-spec.md
@@ -16,16 +16,15 @@ 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 Port File
+### 2. Discovery Mechanism: The Lock File
-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."
+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.
-- **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.
+- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/.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.
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
- `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.
+ `.lock`
+ - ``: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
```json
@@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
"port": 12345,
"workspacePath": "/path/to/project1:/path/to/project2",
"authToken": "a-very-secret-token",
- "ideInfo": {
- "name": "vscode",
- "displayName": "VS Code"
- }
+ "ppid": 1234,
+ "ideName": "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 ` header on all requests.
- - `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`).
+ - `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`).
- **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.
-- **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.
+- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `.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-.json` or `qwen-code-ide-server-.json`. New integrations should not rely on these legacy files.
## II. The Context Interface
diff --git a/package-lock.json b/package-lock.json
index 6d6dbdcd..442bc8a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.5.1",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
- "version": "0.5.1",
+ "version": "0.6.0",
"workspaces": [
"packages/*"
],
@@ -16085,6 +16085,7 @@
"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"
@@ -17193,7 +17194,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
- "version": "0.5.1",
+ "version": "0.6.0",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17224,7 +17225,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
- "undici": "^7.10.0",
+ "undici": "^6.22.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
"yargs": "^17.7.2",
@@ -17819,9 +17820,18 @@
"node": ">= 0.6"
}
},
+ "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.5.1",
+ "version": "0.6.0",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.30.0",
@@ -17863,7 +17873,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
- "undici": "^7.10.0",
+ "undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
@@ -18440,6 +18450,15 @@
"node": ">= 0.6"
}
},
+ "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/core/node_modules/zod-to-json-schema": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
@@ -18451,10 +18470,11 @@
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
- "version": "0.5.1",
+ "version": "0.6.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
+ "tiktoken": "^1.0.21",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -21270,7 +21290,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
- "version": "0.5.1",
+ "version": "0.6.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21282,7 +21302,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
- "version": "0.5.1",
+ "version": "0.6.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
@@ -21302,7 +21322,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
- "@types/vscode": "^1.99.0",
+ "@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
@@ -21317,7 +21337,7 @@
"vitest": "^3.2.4"
},
"engines": {
- "vscode": "^1.99.0"
+ "vscode": "^1.85.0"
}
},
"packages/vscode-ide-companion/node_modules/@modelcontextprotocol/sdk": {
diff --git a/package.json b/package.json
index d88f52be..c239067f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.5.1",
+ "version": "0.6.0",
"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.5.1"
+ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7eb084c4..f2083fe1 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
- "version": "0.5.1",
+ "version": "0.6.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
- "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
+ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
},
"dependencies": {
"@google/genai": "1.30.0",
@@ -64,7 +64,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
- "undici": "^7.10.0",
+ "undici": "^6.22.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
diff --git a/packages/core/package.json b/packages/core/package.json
index 297c2765..92d220bb 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
- "version": "0.5.1",
+ "version": "0.6.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -62,7 +62,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
- "undici": "^7.10.0",
+ "undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts
index 8e598787..29484db3 100644
--- a/packages/core/src/config/storage.ts
+++ b/packages/core/src/config/storage.ts
@@ -15,6 +15,7 @@ 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;
@@ -59,6 +60,10 @@ 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);
}
diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts
index ca26f78f..72f78089 100644
--- a/packages/core/src/ide/ide-client.test.ts
+++ b/packages/core/src/ide/ide-client.test.ts
@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises,
readFile: vi.fn(),
readdir: vi.fn(),
+ stat: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
command: 'test-ide',
});
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
+ vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports
mockClient = {
@@ -97,19 +99,15 @@ 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
- >
- ).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
- path.join('/tmp', 'qwen-code-ide-server-12345.json'),
+ path.join('/home/test', '.qwen', 'ide', '8080.lock'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -120,16 +118,13 @@ 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
- >
- ).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -142,19 +137,16 @@ 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
- >
- ).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -164,6 +156,7 @@ 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 () => {
@@ -263,7 +256,8 @@ describe('IdeClient', () => {
});
describe('getConnectionConfigFromFile', () => {
- it('should return config from the specific pid file if it exists', async () => {
+ it('should return config from the env port lock file if it exists', async () => {
+ process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -277,18 +271,14 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
- path.join('/tmp', 'qwen-code-ide-server-12345.json'),
+ path.join('/home/test', '.qwen', 'ide', '1234.lock'),
'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
- >
- ).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -300,20 +290,15 @@ describe('IdeClient', () => {
expect(result).toBeUndefined();
});
- 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
- >
- ).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
- vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
- vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
- isValid: true,
- });
+ 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),
+ );
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -324,110 +309,18 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
- path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
+ path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
});
- 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
- >
- ).mockResolvedValue([
- 'qwen-code-ide-server-12345-111.json',
- 'qwen-code-ide-server-12345-222.json',
- ]);
- vi.mocked(fs.promises.readFile)
- .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;
- }
- ).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
- >
- ).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;
- }
- ).getConnectionConfigFromFile();
-
- expect(result).toEqual(config1);
- });
-
- it('should prioritize the config matching the port from the environment variable', async () => {
+ it('should fall back to legacy port file when pid file is missing', 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
- >
- ).mockResolvedValue([
- 'qwen-code-ide-server-12345-111.json',
- 'qwen-code-ide-server-12345-222.json',
- ]);
vi.mocked(fs.promises.readFile)
- .mockResolvedValueOnce(JSON.stringify(config1))
+ .mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/.lock
+ .mockRejectedValueOnce(new Error('not found')) // legacy pid file
.mockResolvedValueOnce(JSON.stringify(config2));
- vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
- isValid: true,
- });
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -437,28 +330,23 @@ 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 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
- >
- ).mockResolvedValue([
- 'qwen-code-ide-server-12345-111.json',
- 'qwen-code-ide-server-12345-222.json',
- ]);
+ 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' };
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
- .mockResolvedValueOnce(JSON.stringify(validConfig));
- vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
- isValid: true,
- });
+ .mockResolvedValueOnce(JSON.stringify(config));
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -467,96 +355,7 @@ describe('IdeClient', () => {
}
).getConnectionConfigFromFile();
- 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;
- }
- ).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
- >
- ).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;
- }
- ).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
- >
- ).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;
- }
- ).getConnectionConfigFromFile();
-
- expect(result).toEqual(config2);
+ expect(result).toEqual(config);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
});
diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts
index b447f46c..b216506f 100644
--- a/packages/core/src/ide/ide-client.ts
+++ b/packages/core/src/ide/ide-client.ts
@@ -8,6 +8,7 @@ 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,
@@ -572,98 +573,103 @@ export class IdeClient {
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
- if (!this.ideProcessInfo) {
- return undefined;
- }
-
- // For backwards compatability
- 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 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 undefined;
- }
-
- if (!portFiles) {
- return undefined;
- }
-
- 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;
+ 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.
}
}
- return validWorkspaces[0];
+ // 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[];
+ try {
+ lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
+ fileRegex.test(file),
+ );
+ } catch (e) {
+ logger.debug('Failed to read IDE connection directory:', e);
+ return [];
+ }
+
+ 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 };
+ }
+ }),
+ );
+
+ return fileContents
+ .filter(({ parsed }) => parsed !== undefined)
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
+ .map(({ parsed }) => parsed);
}
private createProxyAwareFetch() {
diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts
index e6c68f14..a049406d 100644
--- a/packages/core/src/ide/process-utils.test.ts
+++ b/packages/core/src/ide/process-utils.test.ts
@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
});
- it('should return parent process info if grandparent lookup fails', async () => {
+ it('should return shell 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,134 +63,96 @@ describe('getIdeProcessInfo', () => {
});
describe('on Windows', () => {
- it('should traverse up and find the great-grandchild of the root process', async () => {
+ it('should return great-grandparent process using heuristic', async () => {
(os.platform as Mock).mockReturnValue('win32');
- 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));
+
+ 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) });
}
- return Promise.reject(new Error('Invalid command for mock'));
+ return Promise.resolve({ stdout: '' });
});
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 non-existent process gracefully', async () => {
+ it('should handle empty process list gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
- mockedExec
- .mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
- .mockResolvedValueOnce({
- stdout:
- '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
- }); // Fallback call
+ mockedExec.mockResolvedValue({ stdout: '[]' });
const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
+ // Should return current pid and empty command because process not found in map
+ expect(result).toEqual({ pid: 1000, command: '' });
});
it('should handle malformed JSON output gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
- mockedExec
- .mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
- .mockResolvedValueOnce({
- stdout:
- '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
- }); // Fallback call
+ mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
+ expect(result).toEqual({ pid: 1000, command: '' });
});
- it('should handle PowerShell errors without crashing the process chain', async () => {
+ it('should return last ancestor if chain is too short', 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"}',
- },
- ],
- ]);
- // Mock the process.pid to test traversal with missing processes
- Object.defineProperty(process, 'pid', {
- value: 1001,
- configurable: true,
- });
+ const processes = [
+ {
+ ProcessId: 1000,
+ ParentProcessId: 900,
+ Name: 'node.exe',
+ CommandLine: 'node.exe',
+ },
+ {
+ ProcessId: 900,
+ ParentProcessId: 0,
+ Name: 'explorer.exe',
+ CommandLine: 'explorer.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) || { stdout: '' });
+ mockedExec.mockImplementation((file: string, _args: string[]) => {
+ if (file === 'powershell') {
+ return Promise.resolve({ stdout: JSON.stringify(processes) });
}
- return Promise.reject(new Error('Invalid command for mock'));
+ return Promise.resolve({ stdout: '' });
});
const result = await getIdeProcessInfo();
- // 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' });
+ // ancestors = [1000, 900], length = 2 (< 3)
+ // Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
+ expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
});
});
});
diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts
index 170b1df1..617c5650 100644
--- a/packages/core/src/ide/process-utils.ts
+++ b/packages/core/src/ide/process-utils.ts
@@ -4,74 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { exec } from 'node:child_process';
+import { exec, execFile } 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;
}> {
- try {
- 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,
- };
- }
- } catch (_e) {
- console.debug(`Failed to get process info for pid ${pid}:`, _e);
- return { parentPid: 0, name: '', command: '' };
- }
+ // Only used for Unix systems (macOS and Linux)
+ const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`);
+ const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/);
+ const parentPid = parseInt(ppidStr, 10);
+ const command = commandParts.join(' ');
+ return { parentPid, name: path.basename(command), command };
}
-
/**
* Finds the IDE process info on Unix-like systems.
*
@@ -106,15 +60,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
} catch {
// Ignore if getting grandparent fails, we'll just use the parent pid.
}
- const { command } = await getProcessInfo(idePid);
- return { pid: idePid, command };
+ const { command: ideCommand } = await getProcessInfo(idePid);
+ return { pid: idePid, command: ideCommand };
}
if (parentPid <= 1) {
break; // Reached the root
}
currentPid = parentPid;
- } catch {
+ } catch (_e) {
// Process in chain died
break;
}
@@ -124,50 +78,104 @@ 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;
+}
+
/**
- * 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.
+ * Fetches the entire process table on Windows.
*/
+async function getProcessTableWindows(): Promise