From 4f2b2d0a3e5d469347038a32f9bd8cac98e36d4e Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 12 Dec 2025 15:10:16 +0800 Subject: [PATCH 01/21] fix: optimize windows process tree retrieval to prevent hang --- packages/core/src/ide/process-utils.test.ts | 181 ++++++++------------ packages/core/src/ide/process-utils.ts | 84 ++++++--- 2 files changed, 129 insertions(+), 136 deletions(-) diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index e6c68f14..d4ca1150 100644 --- a/packages/core/src/ide/process-utils.test.ts +++ b/packages/core/src/ide/process-utils.test.ts @@ -65,132 +65,93 @@ describe('getIdeProcessInfo', () => { describe('on Windows', () => { it('should traverse up and find the great-grandchild of the root process', 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')); + // Fallback for getProcessInfo calls if any (should not happen in new logic for Windows traversal) + return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); + // 1000 -> 900 -> 800 -> 700 (root child) + // Great-grandchild of root (700) is 900. + // Wait, logic is: + // 700 (root child) -> 800 (grandchild) -> 900 (great-grandchild) -> 1000 (current) + // The code looks for the grandchild of the root. + // Root is 0 (conceptually). Child of root is 700. Grandchild is 800. + // The code says: + // "We've found the grandchild of the root (`currentPid`). The IDE process is its child, which we've stored in `previousPid`." + + // Let's trace the loop in `getIdeProcessInfoForWindows`: + // currentPid = 1000, previousPid = 1000 + // Loop 1: + // proc = 1000. parentPid = 900. + // parentProc = 900. parentProc.parentPid = 800 != 0. + // previousPid = 1000. currentPid = 900. + // Loop 2: + // proc = 900. parentPid = 800. + // parentProc = 800. parentProc.parentPid = 700 != 0. + // previousPid = 900. currentPid = 800. + // Loop 3: + // proc = 800. parentPid = 700. + // parentProc = 700. parentProc.parentPid = 0. + // MATCH! + // ideProc = processMap.get(previousPid) = processMap.get(900) = powershell.exe + // return { pid: 900, command: '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}' }); // Malformed JSON will throw in JSON.parse + + // If JSON.parse fails, getProcessTreeForWindows returns empty map. + // Then getIdeProcessInfoForWindows returns current pid. + + // Wait, mockedExec throws? No, JSON.parse throws. + // getProcessTreeForWindows catches error and returns empty map. const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); - }); - - 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"}', - }, - ], - ]); - - // Mock the process.pid to test traversal with missing processes - Object.defineProperty(process, 'pid', { - value: 1001, - configurable: true, - }); - - 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.reject(new Error('Invalid command for mock')); - }); - - 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' }); + expect(result).toEqual({ pid: 1000, command: '' }); }); }); }); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 170b1df1..c4138921 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -4,12 +4,13 @@ * 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; @@ -36,7 +37,13 @@ async function getProcessInfo(pid: number): Promise<{ '| ConvertTo-Json', '}', ].join(' '); - const { stdout } = await execAsync(`powershell "${powershellCommand}"`); + + const { stdout } = await execFileAsync('powershell', [ + '-NoProfile', + '-NonInteractive', + '-Command', + powershellCommand, + ]); const output = stdout.trim(); if (!output) return { parentPid: 0, name: '', command: '' }; const { @@ -124,6 +131,37 @@ async function getIdeProcessInfoForUnix(): Promise<{ return { pid: currentPid, command }; } +async function getProcessTreeForWindows(): Promise< + Map +> { + try { + const powershellCommand = + 'Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, CommandLine | ConvertTo-Json -Depth 1'; + const { stdout } = await execFileAsync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', powershellCommand], + { maxBuffer: 10 * 1024 * 1024 }, + ); + + const processes = JSON.parse(stdout); + const map = new Map(); + + const list = Array.isArray(processes) ? processes : [processes]; + + for (const p of list) { + map.set(p.ProcessId, { + parentPid: p.ParentProcessId, + name: p.Name, + command: p.CommandLine || '', + }); + } + return map; + } catch (e) { + console.error('Failed to get process tree:', e); + return new Map(); + } +} + /** * Finds the IDE process info on Windows. * @@ -135,39 +173,33 @@ async function getIdeProcessInfoForWindows(): Promise<{ pid: number; command: string; }> { + const processMap = await getProcessTreeForWindows(); let currentPid = process.pid; let previousPid = process.pid; for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { - try { - const { parentPid } = await getProcessInfo(currentPid); + const proc = processMap.get(currentPid); + if (!proc) break; - 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 - } - } + const parentPid = proc.parentPid; - if (parentPid <= 0) { - break; // Reached the root + if (parentPid > 0) { + const parentProc = processMap.get(parentPid); + if (parentProc && parentProc.parentPid === 0) { + // We've found the grandchild of the root (`currentPid`). The IDE + // process is its child, which we've stored in `previousPid`. + const ideProc = processMap.get(previousPid); + return { pid: previousPid, command: ideProc?.command || '' }; } - previousPid = currentPid; - currentPid = parentPid; - } catch { - // Process in chain died - break; } + + if (parentPid <= 0) break; + previousPid = currentPid; + currentPid = parentPid; } - const { command } = await getProcessInfo(currentPid); - return { pid: currentPid, command }; + + const currentProc = processMap.get(currentPid); + return { pid: currentPid, command: currentProc?.command || '' }; } /** From 59c3d3d0f93cc2ddd7fff91ff92bb46f0579ead9 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 15 Dec 2025 20:15:26 +0800 Subject: [PATCH 02/21] IDE companion discovery: switch to ~/.qwen/ide lock files --- docs/ide-integration/ide-companion-spec.md | 14 +- .../src/ui/commands/languageCommand.test.ts | 25 ++- packages/core/src/config/storage.ts | 5 + .../openaiContentGenerator/converter.test.ts | 10 +- packages/core/src/ide/ide-client.test.ts | 118 +++++++------ packages/core/src/ide/ide-client.ts | 157 +++++++++--------- .../src/ide-server.test.ts | 141 ++++++---------- .../vscode-ide-companion/src/ide-server.ts | 109 ++++++------ .../messages/Assistant/AssistantMessage.css | 2 +- .../toolcalls/shared/LayoutComponents.css | 3 +- .../src/webview/styles/tailwind.css | 7 +- 11 files changed, 297 insertions(+), 294 deletions(-) diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 3cf35d75..af265814 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -16,15 +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 which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "lock file." -- **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 determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then scans `~/.qwen/ide/` for `${PID}-*.lock` files, parses their JSON content, and selects one whose `workspacePath` matches the CLI's current working directory. If `QWEN_CODE_IDE_SERVER_PORT` is set, the CLI will prefer `${PID}-${PORT}.lock`. +- **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. + `${PID}-${PORT}.lock` + - `${PID}`: The process ID of the parent IDE process. - `${PORT}`: The port your MCP server is listening on. - **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: @@ -47,7 +47,7 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i - `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. -- **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. +- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the lock 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 diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 001ccd8e..5a4f395b 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -46,12 +46,15 @@ vi.mock('node:fs', async (importOriginal) => { // Mock Storage from core vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { ...actual, Storage: { getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'), - getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'), + getGlobalSettingsPath: vi + .fn() + .mockReturnValue('/mock/.qwen/settings.json'), }, }; }); @@ -360,7 +363,10 @@ describe('languageCommand', () => { throw new Error('The language command must have an action.'); } - const result = await languageCommand.action(mockContext, 'output Chinese'); + const result = await languageCommand.action( + mockContext, + 'output Chinese', + ); expect(fs.mkdirSync).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith( @@ -371,7 +377,9 @@ describe('languageCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('LLM output language rule file generated'), + content: expect.stringContaining( + 'LLM output language rule file generated', + ), }); }); @@ -380,7 +388,10 @@ describe('languageCommand', () => { throw new Error('The language command must have an action.'); } - const result = await languageCommand.action(mockContext, 'output Japanese'); + const result = await languageCommand.action( + mockContext, + 'output Japanese', + ); expect(result).toEqual({ type: 'message', @@ -514,7 +525,9 @@ describe('languageCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('LLM output language rule file generated'), + content: expect.stringContaining( + 'LLM output language rule file generated', + ), }); }); }); 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/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 54420fbb..5cd6af92 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -338,10 +338,7 @@ describe('OpenAIContentConverter', () => { }); it('should handle tools without functionDeclarations', async () => { - const emptyTools: Tool[] = [ - {} as Tool, - { functionDeclarations: [] }, - ]; + const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }]; const result = await converter.convertGeminiToolsToOpenAI(emptyTools); @@ -489,7 +486,10 @@ describe('OpenAIContentConverter', () => { const result = converter.convertGeminiToolParametersToOpenAI(params); const properties = result?.['properties'] as Record; const nested = properties?.['nested'] as Record; - const nestedProperties = nested?.['properties'] as Record; + const nestedProperties = nested?.['properties'] as Record< + string, + unknown + >; expect(nestedProperties?.['deep']).toEqual({ type: 'integer', diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index ca26f78f..99314c9b 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,6 +99,7 @@ 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)); ( @@ -109,7 +112,7 @@ describe('IdeClient', () => { await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), + path.join('/home/test', '.qwen', 'ide', '12345-8080.lock'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -120,6 +123,7 @@ 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 () => { @@ -263,7 +267,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,9 +282,10 @@ 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', '12345-1234.lock'), 'utf8', ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should return undefined if no config files are found', async () => { @@ -300,17 +306,23 @@ 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 + it('should find and parse a single lock file matching the IDE pid', async () => { + const config = { + port: '5678', + workspacePath: '/test/workspace', + ppid: 12345, + }; ( 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)); + ).mockResolvedValueOnce(['12345-5678.lock']); + vi.mocked(fs.promises.stat).mockResolvedValueOnce({ + mtimeMs: 123, + } as unknown as fs.Stats); + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(config), + ); vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ isValid: true, }); @@ -324,7 +336,7 @@ 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('/home/test', '.qwen', 'ide', '12345-5678.lock'), 'utf8', ); }); @@ -338,25 +350,23 @@ describe('IdeClient', () => { 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', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); 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 }); + .mockImplementation((ideWorkspacePath) => ({ + isValid: ideWorkspacePath === '/test/workspace', + })); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -379,17 +389,15 @@ describe('IdeClient', () => { 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', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan + // Make config1 "newer" so it wins when both are valid. + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config2)); @@ -413,15 +421,15 @@ describe('IdeClient', () => { const config2 = { port: '2222', workspacePath: '/test/workspace2' }; vi.mocked(fs.promises.readFile).mockRejectedValueOnce( new Error('not found'), - ); + ); // For ~/.qwen/ide/-.lock ( 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', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config2)); @@ -442,17 +450,14 @@ describe('IdeClient', () => { 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', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce('invalid json') .mockResolvedValueOnce(JSON.stringify(validConfig)); @@ -474,9 +479,11 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readFile).mockRejectedValueOnce( new Error('not found'), ); - vi.mocked(fs.promises.readdir).mockRejectedValue( - new Error('readdir failed'), - ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan const ideClient = await IdeClient.getInstance(); const result = await ( @@ -490,18 +497,19 @@ describe('IdeClient', () => { 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 + ).mockResolvedValueOnce([ + '12345-3333.lock', // valid 'not-a-config-file.txt', // invalid - 'qwen-code-ide-server-asdf.json', // invalid - ]); + 'asdf.lock', // invalid + '12345-asdf.lock', // invalid + ]); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat).mockResolvedValueOnce({ + mtimeMs: 123, + } as unknown as fs.Stats); vi.mocked(fs.promises.readFile).mockResolvedValueOnce( JSON.stringify(validConfig), ); @@ -518,11 +526,11 @@ describe('IdeClient', () => { expect(result).toEqual(validConfig); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'), + path.join('/home/test', '.qwen', 'ide', '12345-3333.lock'), 'utf8', ); expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'not-a-config-file.txt'), + path.join('/home/test', '.qwen', 'ide', 'not-a-config-file.txt'), 'utf8', ); }); @@ -538,10 +546,10 @@ describe('IdeClient', () => { 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', - ]); + ).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan + vi.mocked(fs.promises.stat) + .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) + .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config2)); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b447f46c..2cb419e0 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, @@ -576,7 +577,85 @@ export class IdeClient { return undefined; } - // For backwards compatability + // Preferred: lock file(s) in global qwen dir (~/.qwen/ide/-.lock) + // 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/-.lock + // 2) Otherwise (or on failure), scan ~/.qwen/ide for -*.lock and select: + // - valid workspace path (validateWorkspacePath) + const ideDir = Storage.getGlobalIdeDir(); + const idePid = this.ideProcessInfo.pid; + const portFromEnv = this.getPortFromEnv(); + if (portFromEnv) { + try { + const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`); + const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + return JSON.parse(lockFileContents); + } catch (_) { + // Fall through to scanning / legacy discovery. + } + } + + try { + const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`); + const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => + fileRegex.test(file), + ); + + 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 }; + } + }), + ); + const validWorkspaces = fileContents + .filter(({ parsed }) => parsed !== undefined) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ parsed }) => parsed) + .filter((content) => { + const { isValid } = IdeClient.validateWorkspacePath( + content.workspacePath, + process.cwd(), + ); + return isValid; + }); + + if (validWorkspaces.length > 0) { + if (validWorkspaces.length === 1) { + return validWorkspaces[0]; + } + + if (validWorkspaces.length > 1 && portFromEnv) { + const matchingPort = validWorkspaces.find( + (content) => String(content.port) === portFromEnv, + ); + if (matchingPort) { + return matchingPort; + } + } + + if (validWorkspaces.length > 1) { + return validWorkspaces[0]; + } + } + } catch (_) { + // Fall through to legacy discovery mechanisms. + } + + // For backwards compatability: single file in system temp dir named by PID. try { const portFile = path.join( os.tmpdir(), @@ -585,85 +664,13 @@ export class IdeClient { const portFileContents = await fs.promises.readFile(portFile, 'utf8'); return JSON.parse(portFileContents); } catch (_) { - // For newer extension versions, the file name matches the pattern + // 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. } - 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; - } - } - - return validWorkspaces[0]; + return undefined; } private createProxyAwareFetch() { diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 1293f487..c2ef4c40 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -27,13 +27,14 @@ 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(); return { ...actual, - tmpdir: vi.fn(() => '/tmp'), + homedir: vi.fn(() => '/home/test'), }; }); @@ -128,13 +129,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -143,15 +142,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should set a single folder path', async () => { @@ -166,13 +160,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -181,15 +173,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should set an empty string if no folders are open', async () => { @@ -204,13 +191,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -219,15 +204,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should update the path when workspace folders change', async () => { @@ -256,13 +236,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -271,15 +249,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); // Simulate removing a folder vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; @@ -296,34 +269,28 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent2, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent2, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); - it('should clear env vars and delete port file on stop', async () => { + it('should clear env vars and delete lock file on stop', async () => { await ideServer.start(mockContext); const replaceMock = mockContext.environmentVariableCollection.replace; const port = getPortFromMock(replaceMock); - const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`); - const ppidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const lockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); - expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String)); - expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String)); + expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String)); await ideServer.stop(); expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); - expect(fs.unlink).toHaveBeenCalledWith(portFile); - expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile); + expect(fs.unlink).toHaveBeenCalledWith(lockFile); }); it.skipIf(process.platform !== 'win32')( @@ -344,13 +311,11 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${process.ppid}-${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), @@ -359,15 +324,10 @@ describe('IDEServer', () => { authToken: 'test-auth-token', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }, ); @@ -379,7 +339,7 @@ describe('IDEServer', () => { port = (ideServer as unknown as { port: number }).port; }); - it('should allow request without auth token for backwards compatibility', async () => { + it('should reject request without auth token', async () => { const response = await fetch(`http://localhost:${port}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -390,7 +350,9 @@ describe('IDEServer', () => { id: 1, }), }); - expect(response.status).not.toBe(401); + expect(response.status).toBe(401); + const body = await response.text(); + expect(body).toBe('Unauthorized'); }); it('should allow request with valid auth token', async () => { @@ -550,6 +512,7 @@ describe('IDEServer HTTP endpoints', () => { headers: { Host: `localhost:${port}`, 'Content-Type': 'application/json', + Authorization: 'Bearer test-auth-token', }, }, JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 69fabbc4..f020eba0 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -38,12 +38,24 @@ 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 { + 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; - portFile: string; - ppidPortFile: string; + lockFile: string; authToken: string; log: (message: string) => void; } @@ -51,8 +63,7 @@ interface WritePortAndWorkspaceArgs { async function writePortAndWorkspace({ context, port, - portFile, - ppidPortFile, + lockFile, authToken, log, }: WritePortAndWorkspaceArgs): Promise { @@ -78,19 +89,15 @@ async function writePortAndWorkspace({ authToken, }); - log(`Writing port file to: ${portFile}`); - log(`Writing ppid port file to: ${ppidPortFile}`); + log(`Writing IDE lock file to: ${lockFile}`); try { - await Promise.all([ - fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)), - fs - .writeFile(ppidPortFile, content) - .then(() => fs.chmod(ppidPortFile, 0o600)), - ]); + await fs.mkdir(path.dirname(lockFile), { recursive: true }); + await fs.writeFile(lockFile, content); + await fs.chmod(lockFile, 0o600); } catch (err) { const message = err instanceof Error ? err.message : String(err); - log(`Failed to write port to file: ${message}`); + log(`Failed to write IDE lock file: ${message}`); } } @@ -121,8 +128,7 @@ export class IDEServer { private server: HTTPServer | undefined; private context: vscode.ExtensionContext | undefined; private log: (message: string) => void; - private portFile: string | undefined; - private ppidPortFile: string | undefined; + private lockFile: string | undefined; private port: number | undefined; private authToken: string | undefined; private transports: { [sessionId: string]: StreamableHTTPServerTransport } = @@ -174,19 +180,24 @@ export class IDEServer { app.use((req, res, next) => { const authHeader = req.headers.authorization; - 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; - } + 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; } next(); }); @@ -327,22 +338,25 @@ export class IDEServer { const address = (this.server as HTTPServer).address(); if (address && typeof address !== 'string') { this.port = address.port; - 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`, - ); + try { + const ideDir = await getGlobalIdeDir(); + // Name the lock file by port to support multiple server instances + // under the same parent process. + this.lockFile = path.join( + ideDir, + `${process.ppid}-${this.port}.lock`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to determine IDE lock directory: ${message}`); + } this.log(`IDE server listening on http://127.0.0.1:${this.port}`); - if (this.authToken) { + if (this.authToken && this.lockFile) { await writePortAndWorkspace({ context, port: this.port, - portFile: this.portFile, - ppidPortFile: this.ppidPortFile, + lockFile: this.lockFile, authToken: this.authToken, log: this.log, }); @@ -371,15 +385,13 @@ export class IDEServer { this.context && this.server && this.port && - this.portFile && - this.ppidPortFile && + this.lockFile && this.authToken ) { await writePortAndWorkspace({ context: this.context, port: this.port, - portFile: this.portFile, - ppidPortFile: this.ppidPortFile, + lockFile: this.lockFile, authToken: this.authToken, log: this.log, }); @@ -405,16 +417,9 @@ export class IDEServer { if (this.context) { this.context.environmentVariableCollection.clear(); } - if (this.portFile) { + if (this.lockFile) { try { - await fs.unlink(this.portFile); - } catch (_err) { - // Ignore errors if the file doesn't exist. - } - } - if (this.ppidPortFile) { - try { - await fs.unlink(this.ppidPortFile); + await fs.unlink(this.lockFile); } catch (_err) { // Ignore errors if the file doesn't exist. } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css index 56946662..67675816 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css @@ -48,5 +48,5 @@ } .assistant-message-container.assistant-message-loading::after { - display: none + display: none; } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css index 39846d77..e5b2cce9 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css @@ -172,7 +172,8 @@ /* Loading animation for toolcall header */ @keyframes toolcallHeaderPulse { - 0%, 100% { + 0%, + 100% { opacity: 1; } 50% { diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index 46d803d5..eec8877b 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -51,7 +51,8 @@ .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 */ @@ -66,7 +67,7 @@ The data attribute is needed because some browsers insert a
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; @@ -80,7 +81,7 @@ outline: none; } .composer-input:disabled, - .composer-input[contenteditable="false"] { + .composer-input[contenteditable='false'] { color: #999; cursor: not-allowed; } From 32c085cf7de2e4cba62e7c27c8bbfc0ac26b77b3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 15 Dec 2025 23:54:26 +0800 Subject: [PATCH 03/21] chore(vscode-ide-companion): update vscode engine version from ^1.99.0 to ^1.85.0 --- package-lock.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29429f81..2b9c64ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20224,7 +20224,7 @@ "vitest": "^3.2.4" }, "engines": { - "vscode": "^1.99.0" + "vscode": "^1.85.0" } }, "packages/vscode-ide-companion/node_modules/@types/react": { diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 8698275b..01d86211 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -11,7 +11,7 @@ "directory": "packages/vscode-ide-companion" }, "engines": { - "vscode": "^1.99.0" + "vscode": "^1.85.0" }, "license": "LICENSE", "preview": true, From 49b3e0dc924a471a5746cfa14512f478b7d81165 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 16 Dec 2025 19:53:17 +0800 Subject: [PATCH 04/21] chore(vscode-ide-companion): update vscode engine version from ^1.99.0 to ^1.85.0 --- package-lock.json | 25 +++++++++++++++++++--- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b9c64ab..c68a2309 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16386,6 +16386,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" @@ -17524,7 +17525,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", @@ -17606,6 +17607,15 @@ "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.5.0", @@ -17651,7 +17661,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" }, @@ -17746,6 +17756,15 @@ "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.5.0", @@ -20209,7 +20228,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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 685c6e90..5c270dbd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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 c8b7b1e7..afebc9bb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,7 +63,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/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 01d86211..c3260a36 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -137,7 +137,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", From 01e62a2120030af3b86fb45c91038a3564222ae5 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Thu, 18 Dec 2025 15:06:01 +0800 Subject: [PATCH 05/21] refactor: remove unused fs import from process-utils.ts --- packages/core/src/ide/process-utils.ts | 236 ++++++++++++++++++++----- 1 file changed, 189 insertions(+), 47 deletions(-) diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index c4138921..6b047c80 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -113,15 +113,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; } @@ -131,41 +131,70 @@ async function getIdeProcessInfoForUnix(): Promise<{ return { pid: currentPid, command }; } -async function getProcessTreeForWindows(): Promise< - Map -> { +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. + */ +async function getProcessTableWindows(): Promise> { + const processMap = new Map(); try { const powershellCommand = - 'Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, CommandLine | ConvertTo-Json -Depth 1'; + '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 }, ); - const processes = JSON.parse(stdout); - const map = new Map(); - - const list = Array.isArray(processes) ? processes : [processes]; - - for (const p of list) { - map.set(p.ProcessId, { - parentPid: p.ParentProcessId, - name: p.Name, - command: p.CommandLine || '', - }); + if (!stdout.trim()) { + return processMap; } - return map; - } catch (e) { - console.error('Failed to get process tree:', e); - return new Map(); + + 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; } /** - * Finds the IDE process info on Windows. + * Finds the IDE process info on Windows using a snapshot approach. * - * The strategy is to find the great-grandchild of the root process. + * The strategy is to find the IDE process by looking for known IDE executables + * in the process chain, with fallback to heuristics. * * @returns A promise that resolves to the PID and command of the IDE process. */ @@ -173,33 +202,146 @@ async function getIdeProcessInfoForWindows(): Promise<{ pid: number; command: string; }> { - const processMap = await getProcessTreeForWindows(); - let currentPid = process.pid; - let previousPid = process.pid; + // Fetch the entire process table in one go. + const processMap = await getProcessTableWindows(); - for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { - const proc = processMap.get(currentPid); - if (!proc) break; + const myPid = process.pid; + const myProc = processMap.get(myPid); - const parentPid = proc.parentPid; - - if (parentPid > 0) { - const parentProc = processMap.get(parentPid); - if (parentProc && parentProc.parentPid === 0) { - // We've found the grandchild of the root (`currentPid`). The IDE - // process is its child, which we've stored in `previousPid`. - const ideProc = processMap.get(previousPid); - return { pid: previousPid, command: ideProc?.command || '' }; - } - } - - if (parentPid <= 0) break; - previousPid = currentPid; - currentPid = parentPid; + if (!myProc) { + // Fallback: return current process info if snapshot fails + return { pid: myPid, command: '' }; } - const currentProc = processMap.get(currentPid); - return { pid: currentPid, command: currentProc?.command || '' }; + // Known IDE process names (lowercase for case-insensitive comparison) + const ideProcessNames = [ + 'code.exe', // VS Code + 'code - insiders.exe', // VS Code Insiders + 'cursor.exe', // Cursor + 'windsurf.exe', // Windsurf + 'devenv.exe', // Visual Studio + 'rider64.exe', // JetBrains Rider + 'idea64.exe', // IntelliJ IDEA + 'pycharm64.exe', // PyCharm + 'webstorm64.exe', // WebStorm + ]; + + // 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)) { + // Try to get info about the missing parent + if (curr.parentPid !== 0) { + try { + const parentInfo = await getProcessInfo(curr.parentPid); + if (parentInfo.name) { + ancestors.push({ + pid: curr.parentPid, + parentPid: parentInfo.parentPid, + name: parentInfo.name, + command: parentInfo.command, + }); + } + } catch (_e) { + // Ignore if query fails + } + } + break; + } + curr = processMap.get(curr.parentPid); + } + + // Strategy 1: Look for known IDE process names in the chain + for (let i = ancestors.length - 1; i >= 0; i--) { + const proc = ancestors[i]; + const nameLower = proc.name.toLowerCase(); + + if ( + ideProcessNames.some((ideName) => nameLower === ideName.toLowerCase()) + ) { + return { pid: proc.pid, command: proc.command }; + } + } + + // Strategy 2: Special handling for Git Bash (sh.exe/bash.exe) with missing parent + // Check this first before general shell handling + const gitBashNames = ['sh.exe', 'bash.exe']; + const gitBashProc = ancestors.find((p) => + gitBashNames.some((name) => p.name.toLowerCase() === name.toLowerCase()), + ); + + if (gitBashProc) { + // Check if parent exists in process table + const parentExists = + gitBashProc.parentPid !== 0 && processMap.has(gitBashProc.parentPid); + + if (!parentExists && gitBashProc.parentPid !== 0) { + // Look for IDE processes in the entire process table + const ideProcesses: ProcessInfo[] = []; + for (const [, proc] of processMap) { + const nameLower = proc.name.toLowerCase(); + if ( + ideProcessNames.some((ideName) => nameLower === ideName.toLowerCase()) + ) { + ideProcesses.push(proc); + } + } + + if (ideProcesses.length > 0) { + // Prefer main process (without --type= parameter) over utility processes + const mainProcesses = ideProcesses.filter( + (p) => !p.command.includes('--type='), + ); + const targetProcesses = + mainProcesses.length > 0 ? mainProcesses : ideProcesses; + + // Sort by PID and pick the one with lowest PID + targetProcesses.sort((a, b) => a.pid - b.pid); + return { + pid: targetProcesses[0].pid, + command: targetProcesses[0].command, + }; + } + } else if (parentExists) { + // Git Bash parent exists, use it + const gitBashIndex = ancestors.indexOf(gitBashProc); + if (gitBashIndex >= 0 && gitBashIndex + 1 < ancestors.length) { + const parentProc = ancestors[gitBashIndex + 1]; + return { pid: parentProc.pid, command: parentProc.command }; + } + } + } + + // Strategy 3: Look for other shell processes (cmd.exe, powershell.exe, etc.) and use their parent + const otherShellNames = ['cmd.exe', 'powershell.exe', 'pwsh.exe']; + for (let i = 0; i < ancestors.length; i++) { + const proc = ancestors[i]; + const nameLower = proc.name.toLowerCase(); + + if (otherShellNames.some((shell) => nameLower === shell.toLowerCase())) { + // The parent of the shell is likely closer to the IDE + if (i + 1 < ancestors.length) { + const parentProc = ancestors[i + 1]; + return { pid: parentProc.pid, command: parentProc.command }; + } + break; + } + } + + // Strategy 4: Use ancestors[length-3] as fallback (original logic) + 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 }; } /** From cb59b5a9dc1c94d3c9d063b7b50620d8ab9d14c9 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Thu, 18 Dec 2025 16:24:40 +0800 Subject: [PATCH 06/21] refactor(core): optimize Windows process detection and remove debug logging - Replace execFileAsync with execAsync for complex PowerShell commands in getProcessInfo - Remove unnecessary getProcessInfo retry logic when parent not in processMap - Remove all debug logging code (writeDebugLog function and fs import) - Improve performance by ~1.6-2.6 seconds per detection - Keep execFileAsync for simple commands in getProcessTableWindows --- packages/core/src/ide/process-utils.ts | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 6b047c80..7f0b3e8e 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -38,12 +38,9 @@ async function getProcessInfo(pid: number): Promise<{ '}', ].join(' '); - const { stdout } = await execFileAsync('powershell', [ - '-NoProfile', - '-NonInteractive', - '-Command', - powershellCommand, - ]); + const { stdout } = await execAsync( + `powershell -NoProfile -NonInteractive -Command "${powershellCommand.replace(/"/g, '\\"')}"`, + ); const output = stdout.trim(); if (!output) return { parentPid: 0, name: '', command: '' }; const { @@ -234,22 +231,7 @@ async function getIdeProcessInfoForWindows(): Promise<{ ancestors.push(curr); if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) { - // Try to get info about the missing parent - if (curr.parentPid !== 0) { - try { - const parentInfo = await getProcessInfo(curr.parentPid); - if (parentInfo.name) { - ancestors.push({ - pid: curr.parentPid, - parentPid: parentInfo.parentPid, - name: parentInfo.name, - command: parentInfo.command, - }); - } - } catch (_e) { - // Ignore if query fails - } - } + // Parent process not in map, stop traversal break; } curr = processMap.get(curr.parentPid); From 27bf42b4f55a546cf1f03dd3f2ee1841760bb157 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Thu, 18 Dec 2025 17:28:56 +0800 Subject: [PATCH 07/21] test: sync process-utils.test.ts with implementation logic - Update Windows test cases to match multi-strategy IDE detection - Add test for Strategy 1: known IDE process detection (code.exe) - Add test for Strategy 3: shell parent detection (cmd.exe) - Add test for Strategy 2: Git Bash with missing parent handling - Fix test expectations to align with actual implementation behavior - All 7 test cases now pass successfully --- packages/core/src/ide/process-utils.test.ts | 118 ++++++++++++++------ 1 file changed, 83 insertions(+), 35 deletions(-) diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index d4ca1150..88433b00 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,7 +63,7 @@ describe('getIdeProcessInfo', () => { }); describe('on Windows', () => { - it('should traverse up and find the great-grandchild of the root process', async () => { + it('should find known IDE process in the chain', async () => { (os.platform as Mock).mockReturnValue('win32'); const processes = [ @@ -97,38 +97,92 @@ describe('getIdeProcessInfo', () => { if (file === 'powershell') { return Promise.resolve({ stdout: JSON.stringify(processes) }); } - // Fallback for getProcessInfo calls if any (should not happen in new logic for Windows traversal) return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); - // 1000 -> 900 -> 800 -> 700 (root child) - // Great-grandchild of root (700) is 900. - // Wait, logic is: - // 700 (root child) -> 800 (grandchild) -> 900 (great-grandchild) -> 1000 (current) - // The code looks for the grandchild of the root. - // Root is 0 (conceptually). Child of root is 700. Grandchild is 800. - // The code says: - // "We've found the grandchild of the root (`currentPid`). The IDE process is its child, which we've stored in `previousPid`." + // Strategy 1: Should find code.exe (known IDE process) in the chain + // Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe) + // The function should identify code.exe as the IDE process + expect(result).toEqual({ pid: 800, command: 'code.exe' }); + }); - // Let's trace the loop in `getIdeProcessInfoForWindows`: - // currentPid = 1000, previousPid = 1000 - // Loop 1: - // proc = 1000. parentPid = 900. - // parentProc = 900. parentProc.parentPid = 800 != 0. - // previousPid = 1000. currentPid = 900. - // Loop 2: - // proc = 900. parentPid = 800. - // parentProc = 800. parentProc.parentPid = 700 != 0. - // previousPid = 900. currentPid = 800. - // Loop 3: - // proc = 800. parentPid = 700. - // parentProc = 700. parentProc.parentPid = 0. - // MATCH! - // ideProc = processMap.get(previousPid) = processMap.get(900) = powershell.exe - // return { pid: 900, command: 'powershell.exe' } + it('should find shell parent when shell exists in chain', async () => { + (os.platform as Mock).mockReturnValue('win32'); - expect(result).toEqual({ pid: 900, command: 'powershell.exe' }); + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 800, + Name: 'cmd.exe', + CommandLine: 'cmd.exe', + }, + { + ProcessId: 800, + ParentProcessId: 700, + Name: 'explorer.exe', + CommandLine: 'explorer.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.resolve({ stdout: '' }); + }); + + const result = await getIdeProcessInfo(); + // Strategy 3: Should find cmd.exe and return its parent (explorer.exe) + expect(result).toEqual({ pid: 800, command: 'explorer.exe' }); + }); + + it('should handle Git Bash with missing parent by finding IDE in process table', async () => { + (os.platform as Mock).mockReturnValue('win32'); + + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 12345, // Parent doesn't exist in process table + Name: 'bash.exe', + CommandLine: 'bash.exe', + }, + { + ProcessId: 800, + ParentProcessId: 0, + Name: 'code.exe', + CommandLine: 'code.exe', + }, + ]; + + mockedExec.mockImplementation((file: string, _args: string[]) => { + if (file === 'powershell') { + return Promise.resolve({ stdout: JSON.stringify(processes) }); + } + return Promise.resolve({ stdout: '' }); + }); + + const result = await getIdeProcessInfo(); + // Strategy 2: Git Bash with missing parent should find code.exe in process table + expect(result).toEqual({ pid: 800, command: 'code.exe' }); }); it('should handle empty process list gracefully', async () => { @@ -142,13 +196,7 @@ describe('getIdeProcessInfo', () => { it('should handle malformed JSON output gracefully', async () => { (os.platform as Mock).mockReturnValue('win32'); - mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' }); // Malformed JSON will throw in JSON.parse - - // If JSON.parse fails, getProcessTreeForWindows returns empty map. - // Then getIdeProcessInfoForWindows returns current pid. - - // Wait, mockedExec throws? No, JSON.parse throws. - // getProcessTreeForWindows catches error and returns empty map. + mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' }); const result = await getIdeProcessInfo(); expect(result).toEqual({ pid: 1000, command: '' }); From c2b59038aeb15d33447be76a6040b725820df115 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Thu, 18 Dec 2025 17:32:11 +0800 Subject: [PATCH 08/21] fix: escape backslashes in PowerShell command strings (CodeQL security fix) Fixes CodeQL security alert: Incomplete string escaping or encoding - Add escapeForPowerShellDoubleQuotes() helper function - Properly escape both backslashes and double quotes in correct order - Prevents command injection vulnerabilities in Windows process detection - All existing tests pass --- packages/core/src/ide/process-utils.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 7f0b3e8e..d4673f1b 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -14,6 +14,18 @@ const execFileAsync = promisify(execFile); const MAX_TRAVERSAL_DEPTH = 32; +/** + * Escapes a string for safe use inside PowerShell double-quoted strings. + * Must escape backslashes first, then double quotes. + * + * @param str The string to escape. + * @returns The escaped string safe for PowerShell double-quoted context. + */ +function escapeForPowerShellDoubleQuotes(str: string): string { + // Order matters: escape backslashes first, then double quotes + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + /** * Fetches the parent process ID, name, and command for a given process ID. * @@ -39,7 +51,7 @@ async function getProcessInfo(pid: number): Promise<{ ].join(' '); const { stdout } = await execAsync( - `powershell -NoProfile -NonInteractive -Command "${powershellCommand.replace(/"/g, '\\"')}"`, + `powershell -NoProfile -NonInteractive -Command "${escapeForPowerShellDoubleQuotes(powershellCommand)}"`, ); const output = stdout.trim(); if (!output) return { parentPid: 0, name: '', command: '' }; From ec32a2450878c3a6f3a8fad79fe86c1d95ca40ba Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 19 Dec 2025 11:36:05 +0800 Subject: [PATCH 09/21] fix: update ide-client tests to match new config file naming scheme - Update config file naming from qwen-code-ide-server-{pid}-{timestamp}.json to qwen-code-ide-server-{port}.json - Add readdir mock to return config file list - Add validateWorkspacePath mock for workspace validation - Add workspacePath field to all config objects in tests - Remove getIdeProcessInfo dependency from tests - All 23 tests now passing --- packages/core/src/ide/detect-ide.ts | 6 +- packages/core/src/ide/ide-client.test.ts | 172 +++++++++++----------- packages/core/src/ide/ide-client.ts | 30 +--- packages/core/src/ide/process-utils.ts | 174 +---------------------- 4 files changed, 99 insertions(+), 283 deletions(-) diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index c00d9a62..f7ef2979 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -52,7 +52,7 @@ export function detectIdeFromEnv(): IdeInfo { function verifyVSCode( ide: IdeInfo, - ideProcessInfo: { + ideProcessInfo?: { pid: number; command: string; }, @@ -61,7 +61,7 @@ function verifyVSCode( return ide; } if ( - ideProcessInfo.command && + ideProcessInfo?.command && ideProcessInfo.command.toLowerCase().includes('code') ) { return IDE_DEFINITIONS.vscode; @@ -70,7 +70,7 @@ function verifyVSCode( } export function detectIde( - ideProcessInfo: { + ideProcessInfo?: { pid: number; command: string; }, diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index ca26f78f..f41b229a 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -22,7 +22,6 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { detectIde, IDE_DEFINITIONS } from './detect-ide.js'; import * as os from 'node:os'; -import * as path from 'node:path'; vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -97,21 +96,20 @@ describe('IdeClient', () => { describe('connect', () => { it('should connect using HTTP when port is provided in config file', async () => { - const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { port: '8080', workspacePath: '/test/workspace' }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), - 'utf8', - ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( new URL('http://127.0.0.1:8080/mcp'), expect.any(Object), @@ -123,13 +121,19 @@ describe('IdeClient', () => { }); it('should connect using stdio when stdio config is provided in file', async () => { - const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { + stdio: { command: 'test-cmd', args: ['--foo'] }, + workspacePath: '/test/workspace', + }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-12345.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -148,13 +152,17 @@ describe('IdeClient', () => { const config = { port: '8080', stdio: { command: 'test-cmd', args: ['--foo'] }, + workspacePath: '/test/workspace', }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -217,13 +225,16 @@ describe('IdeClient', () => { }); it('should prioritize file config over environment variables', async () => { - const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { port: '8080', workspacePath: '/test/workspace' }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090'; const ideClient = await IdeClient.getInstance(); @@ -265,7 +276,15 @@ describe('IdeClient', () => { describe('getConnectionConfigFromFile', () => { it('should return config from the specific pid file if it exists', async () => { const config = { port: '1234', workspacePath: '/test/workspace' }; + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['qwen-code-ide-server-1234.json']); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); const ideClient = await IdeClient.getInstance(); // In tests, the private method can be accessed like this. @@ -276,10 +295,6 @@ describe('IdeClient', () => { ).getConnectionConfigFromFile(); expect(result).toEqual(config); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), - 'utf8', - ); }); it('should return undefined if no config files are found', async () => { @@ -302,14 +317,11 @@ describe('IdeClient', () => { 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']); + ).mockResolvedValue(['qwen-code-ide-server-5678.json']); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ isValid: true, @@ -323,10 +335,6 @@ describe('IdeClient', () => { ).getConnectionConfigFromFile(); expect(result).toEqual(config); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), - 'utf8', - ); }); it('should filter out configs with invalid workspace paths', async () => { @@ -338,16 +346,13 @@ describe('IdeClient', () => { 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', + 'qwen-code-ide-server-1111.json', + 'qwen-code-ide-server-5678.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(invalidConfig)) @@ -379,16 +384,13 @@ describe('IdeClient', () => { 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', + 'qwen-code-ide-server-1111.json', + 'qwen-code-ide-server-2222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) @@ -411,16 +413,13 @@ describe('IdeClient', () => { 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', + 'qwen-code-ide-server-1111.json', + 'qwen-code-ide-server-2222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) @@ -442,16 +441,13 @@ describe('IdeClient', () => { 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', + 'qwen-code-ide-server-1111.json', + 'qwen-code-ide-server-2222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce('invalid json') @@ -471,12 +467,11 @@ describe('IdeClient', () => { }); 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'), - ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockRejectedValue(new Error('readdir failed')); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -490,15 +485,12 @@ describe('IdeClient', () => { 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 + 'qwen-code-ide-server-3333.json', // valid 'not-a-config-file.txt', // invalid 'qwen-code-ide-server-asdf.json', // invalid ]); @@ -517,30 +509,19 @@ describe('IdeClient', () => { ).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', + 'qwen-code-ide-server-1111.json', + 'qwen-code-ide-server-3333.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) @@ -568,13 +549,16 @@ describe('IdeClient', () => { }); it('should return false if tool discovery fails', async () => { - const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { port: '8080', workspacePath: '/test/workspace' }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); mockClient.request.mockRejectedValue(new Error('Method not found')); const ideClient = await IdeClient.getInstance(); @@ -587,13 +571,16 @@ describe('IdeClient', () => { }); it('should return false if diffing tools are not available', async () => { - const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { port: '8080', workspacePath: '/test/workspace' }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); mockClient.request.mockResolvedValue({ tools: [{ name: 'someOtherTool' }], }); @@ -608,13 +595,16 @@ describe('IdeClient', () => { }); it('should return false if only openDiff tool is available', async () => { - const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { port: '8080', workspacePath: '/test/workspace' }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); mockClient.request.mockResolvedValue({ tools: [{ name: 'openDiff' }], }); @@ -629,13 +619,16 @@ describe('IdeClient', () => { }); it('should return true if connected and diffing tools are available', async () => { - const config = { port: '8080' }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { port: '8080', workspacePath: '/test/workspace' }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); mockClient.request.mockResolvedValue({ tools: [{ name: 'openDiff' }, { name: 'closeDiff' }], }); @@ -653,13 +646,20 @@ describe('IdeClient', () => { describe('authentication', () => { it('should connect with an auth token if provided in the discovery file', async () => { const authToken = 'test-auth-token'; - const config = { port: '8080', authToken }; - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + const config = { + port: '8080', + authToken, + workspacePath: '/test/workspace', + }; ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue([]); + ).mockResolvedValue(['qwen-code-ide-server-8080.json']); + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ + isValid: true, + }); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b447f46c..b401c9f7 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -14,7 +14,6 @@ import { IdeDiffClosedNotificationSchema, IdeDiffRejectedNotificationSchema, } from './types.js'; -import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -86,7 +85,6 @@ export class IdeClient { 'IDE integration is currently disabled. To enable it, run /ide enable.', }; private currentIde: IdeInfo | undefined; - private ideProcessInfo: { pid: number; command: string } | undefined; private connectionConfig: | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined; @@ -108,10 +106,9 @@ export class IdeClient { if (!IdeClient.instancePromise) { IdeClient.instancePromise = (async () => { const client = new IdeClient(); - client.ideProcessInfo = await getIdeProcessInfo(); client.connectionConfig = await client.getConnectionConfigFromFile(); client.currentIde = detectIde( - client.ideProcessInfo, + undefined, client.connectionConfig?.ideInfo, ); return client; @@ -572,26 +569,7 @@ 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'); + const portFileDir = os.tmpdir(); let portFiles; try { portFiles = await fs.promises.readdir(portFileDir); @@ -604,9 +582,7 @@ export class IdeClient { return undefined; } - const fileRegex = new RegExp( - `^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`, - ); + const fileRegex = /^qwen-code-ide-server-\d+\.json$/; const matchingFiles = portFiles .filter((file) => fileRegex.test(file)) .sort(); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index d4673f1b..617c5650 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -14,80 +14,18 @@ const execFileAsync = promisify(execFile); const MAX_TRAVERSAL_DEPTH = 32; -/** - * Escapes a string for safe use inside PowerShell double-quoted strings. - * Must escape backslashes first, then double quotes. - * - * @param str The string to escape. - * @returns The escaped string safe for PowerShell double-quoted context. - */ -function escapeForPowerShellDoubleQuotes(str: string): string { - // Order matters: escape backslashes first, then double quotes - return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -/** - * 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 -NoProfile -NonInteractive -Command "${escapeForPowerShellDoubleQuotes(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. * @@ -199,14 +137,6 @@ async function getProcessTableWindows(): Promise> { return processMap; } -/** - * Finds the IDE process info on Windows using a snapshot approach. - * - * The strategy is to find the IDE process by looking for known IDE executables - * in the process chain, with fallback to heuristics. - * - * @returns A promise that resolves to the PID and command of the IDE process. - */ async function getIdeProcessInfoForWindows(): Promise<{ pid: number; command: string; @@ -222,19 +152,6 @@ async function getIdeProcessInfoForWindows(): Promise<{ return { pid: myPid, command: '' }; } - // Known IDE process names (lowercase for case-insensitive comparison) - const ideProcessNames = [ - 'code.exe', // VS Code - 'code - insiders.exe', // VS Code Insiders - 'cursor.exe', // Cursor - 'windsurf.exe', // Windsurf - 'devenv.exe', // Visual Studio - 'rider64.exe', // JetBrains Rider - 'idea64.exe', // IntelliJ IDEA - 'pycharm64.exe', // PyCharm - 'webstorm64.exe', // WebStorm - ]; - // Perform tree traversal in memory const ancestors: ProcessInfo[] = []; let curr: ProcessInfo | undefined = myProc; @@ -249,84 +166,7 @@ async function getIdeProcessInfoForWindows(): Promise<{ curr = processMap.get(curr.parentPid); } - // Strategy 1: Look for known IDE process names in the chain - for (let i = ancestors.length - 1; i >= 0; i--) { - const proc = ancestors[i]; - const nameLower = proc.name.toLowerCase(); - - if ( - ideProcessNames.some((ideName) => nameLower === ideName.toLowerCase()) - ) { - return { pid: proc.pid, command: proc.command }; - } - } - - // Strategy 2: Special handling for Git Bash (sh.exe/bash.exe) with missing parent - // Check this first before general shell handling - const gitBashNames = ['sh.exe', 'bash.exe']; - const gitBashProc = ancestors.find((p) => - gitBashNames.some((name) => p.name.toLowerCase() === name.toLowerCase()), - ); - - if (gitBashProc) { - // Check if parent exists in process table - const parentExists = - gitBashProc.parentPid !== 0 && processMap.has(gitBashProc.parentPid); - - if (!parentExists && gitBashProc.parentPid !== 0) { - // Look for IDE processes in the entire process table - const ideProcesses: ProcessInfo[] = []; - for (const [, proc] of processMap) { - const nameLower = proc.name.toLowerCase(); - if ( - ideProcessNames.some((ideName) => nameLower === ideName.toLowerCase()) - ) { - ideProcesses.push(proc); - } - } - - if (ideProcesses.length > 0) { - // Prefer main process (without --type= parameter) over utility processes - const mainProcesses = ideProcesses.filter( - (p) => !p.command.includes('--type='), - ); - const targetProcesses = - mainProcesses.length > 0 ? mainProcesses : ideProcesses; - - // Sort by PID and pick the one with lowest PID - targetProcesses.sort((a, b) => a.pid - b.pid); - return { - pid: targetProcesses[0].pid, - command: targetProcesses[0].command, - }; - } - } else if (parentExists) { - // Git Bash parent exists, use it - const gitBashIndex = ancestors.indexOf(gitBashProc); - if (gitBashIndex >= 0 && gitBashIndex + 1 < ancestors.length) { - const parentProc = ancestors[gitBashIndex + 1]; - return { pid: parentProc.pid, command: parentProc.command }; - } - } - } - - // Strategy 3: Look for other shell processes (cmd.exe, powershell.exe, etc.) and use their parent - const otherShellNames = ['cmd.exe', 'powershell.exe', 'pwsh.exe']; - for (let i = 0; i < ancestors.length; i++) { - const proc = ancestors[i]; - const nameLower = proc.name.toLowerCase(); - - if (otherShellNames.some((shell) => nameLower === shell.toLowerCase())) { - // The parent of the shell is likely closer to the IDE - if (i + 1 < ancestors.length) { - const parentProc = ancestors[i + 1]; - return { pid: parentProc.pid, command: parentProc.command }; - } - break; - } - } - - // Strategy 4: Use ancestors[length-3] as fallback (original logic) + // 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 }; From d9422509057d4045c4c100001275a48b2e16219e Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 19 Dec 2025 13:24:19 +0800 Subject: [PATCH 10/21] test: sync test files with code changes for IDE detection - Update detect-ide.test.ts to remove ideProcessInfo parameter (now optional) - Update process-utils.test.ts to match simplified Windows process detection logic - Remove tests for removed IDE detection strategies (Strategy 1-4) - All tests now passing (13 tests in detect-ide.test.ts, 6 tests in process-utils.test.ts) --- packages/core/src/ide/detect-ide.test.ts | 43 +++---- packages/core/src/ide/process-utils.test.ts | 117 ++++++-------------- 2 files changed, 51 insertions(+), 109 deletions(-) diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts index 9bf1eb12..81ccdce5 100644 --- a/packages/core/src/ide/detect-ide.test.ts +++ b/packages/core/src/ide/detect-ide.test.ts @@ -8,9 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { detectIde, IDE_DEFINITIONS } from './detect-ide.js'; describe('detectIde', () => { - const ideProcessInfo = { pid: 123, command: 'some/path/to/code' }; - const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' }; - // Clear all IDE-related environment variables before each test beforeEach(() => { vi.stubEnv('__COG_BASHRC_SOURCED', ''); @@ -29,73 +26,65 @@ describe('detectIde', () => { it('should return undefined if TERM_PROGRAM is not vscode', () => { vi.stubEnv('TERM_PROGRAM', ''); - expect(detectIde(ideProcessInfo)).toBeUndefined(); + expect(detectIde()).toBeUndefined(); }); it('should detect Devin', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('__COG_BASHRC_SOURCED', '1'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin); + expect(detectIde()).toBe(IDE_DEFINITIONS.devin); }); it('should detect Replit', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('REPLIT_USER', 'testuser'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit); + expect(detectIde()).toBe(IDE_DEFINITIONS.replit); }); it('should detect Cursor', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('CURSOR_TRACE_ID', 'some-id'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor); + expect(detectIde()).toBe(IDE_DEFINITIONS.cursor); }); it('should detect Codespaces', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('CODESPACES', 'true'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces); + expect(detectIde()).toBe(IDE_DEFINITIONS.codespaces); }); it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell); + expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell); }); it('should detect Cloud Shell via CLOUD_SHELL', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('CLOUD_SHELL', 'true'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell); + expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell); }); it('should detect Trae', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('TERM_PRODUCT', 'Trae'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae); + expect(detectIde()).toBe(IDE_DEFINITIONS.trae); }); it('should detect Firebase Studio via MONOSPACE_ENV', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('MONOSPACE_ENV', 'true'); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio); + expect(detectIde()).toBe(IDE_DEFINITIONS.firebasestudio); }); - it('should detect VSCode when no other IDE is detected and command includes "code"', () => { + it('should detect VSCodeFork when no other IDE is detected and no process info provided', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('MONOSPACE_ENV', ''); - expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode); - }); - - it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => { - vi.stubEnv('TERM_PROGRAM', 'vscode'); - vi.stubEnv('MONOSPACE_ENV', ''); - expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork); + expect(detectIde()).toBe(IDE_DEFINITIONS.vscodefork); }); }); describe('detectIde with ideInfoFromFile', () => { - const ideProcessInfo = { pid: 123, command: 'some/path/to/code' }; - beforeEach(() => { vi.stubEnv('__COG_BASHRC_SOURCED', ''); vi.stubEnv('REPLIT_USER', ''); @@ -116,22 +105,22 @@ describe('detectIde with ideInfoFromFile', () => { name: 'custom-ide', displayName: 'Custom IDE', }; - expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile); + expect(detectIde(undefined, ideInfoFromFile)).toEqual(ideInfoFromFile); }); it('should fall back to env detection if name is missing', () => { const ideInfoFromFile = { displayName: 'Custom IDE' }; vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe( - IDE_DEFINITIONS.vscode, + expect(detectIde(undefined, ideInfoFromFile)).toBe( + IDE_DEFINITIONS.vscodefork, ); }); it('should fall back to env detection if displayName is missing', () => { const ideInfoFromFile = { name: 'custom-ide' }; vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe( - IDE_DEFINITIONS.vscode, + expect(detectIde(undefined, ideInfoFromFile)).toBe( + IDE_DEFINITIONS.vscodefork, ); }); }); diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index 88433b00..a049406d 100644 --- a/packages/core/src/ide/process-utils.test.ts +++ b/packages/core/src/ide/process-utils.test.ts @@ -63,7 +63,7 @@ describe('getIdeProcessInfo', () => { }); describe('on Windows', () => { - it('should find known IDE process in the chain', async () => { + it('should return great-grandparent process using heuristic', async () => { (os.platform as Mock).mockReturnValue('win32'); const processes = [ @@ -101,88 +101,10 @@ describe('getIdeProcessInfo', () => { }); const result = await getIdeProcessInfo(); - // Strategy 1: Should find code.exe (known IDE process) in the chain // Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe) - // The function should identify code.exe as the IDE process - expect(result).toEqual({ pid: 800, command: 'code.exe' }); - }); - - it('should find shell parent when shell exists in chain', async () => { - (os.platform as Mock).mockReturnValue('win32'); - - const processes = [ - { - ProcessId: 1000, - ParentProcessId: 900, - Name: 'node.exe', - CommandLine: 'node.exe', - }, - { - ProcessId: 900, - ParentProcessId: 800, - Name: 'cmd.exe', - CommandLine: 'cmd.exe', - }, - { - ProcessId: 800, - ParentProcessId: 700, - Name: 'explorer.exe', - CommandLine: 'explorer.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.resolve({ stdout: '' }); - }); - - const result = await getIdeProcessInfo(); - // Strategy 3: Should find cmd.exe and return its parent (explorer.exe) - expect(result).toEqual({ pid: 800, command: 'explorer.exe' }); - }); - - it('should handle Git Bash with missing parent by finding IDE in process table', async () => { - (os.platform as Mock).mockReturnValue('win32'); - - const processes = [ - { - ProcessId: 1000, - ParentProcessId: 900, - Name: 'node.exe', - CommandLine: 'node.exe', - }, - { - ProcessId: 900, - ParentProcessId: 12345, // Parent doesn't exist in process table - Name: 'bash.exe', - CommandLine: 'bash.exe', - }, - { - ProcessId: 800, - ParentProcessId: 0, - Name: 'code.exe', - CommandLine: 'code.exe', - }, - ]; - - mockedExec.mockImplementation((file: string, _args: string[]) => { - if (file === 'powershell') { - return Promise.resolve({ stdout: JSON.stringify(processes) }); - } - return Promise.resolve({ stdout: '' }); - }); - - const result = await getIdeProcessInfo(); - // Strategy 2: Git Bash with missing parent should find code.exe in process table - expect(result).toEqual({ pid: 800, command: 'code.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 () => { @@ -201,5 +123,36 @@ describe('getIdeProcessInfo', () => { const result = await getIdeProcessInfo(); expect(result).toEqual({ pid: 1000, command: '' }); }); + + it('should return last ancestor if chain is too short', async () => { + (os.platform as Mock).mockReturnValue('win32'); + + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 0, + Name: 'explorer.exe', + CommandLine: 'explorer.exe', + }, + ]; + + mockedExec.mockImplementation((file: string, _args: string[]) => { + if (file === 'powershell') { + return Promise.resolve({ stdout: JSON.stringify(processes) }); + } + return Promise.resolve({ stdout: '' }); + }); + + 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' }); + }); }); }); From 1386fba27886ce7bd1161b61c80f39157c9bf152 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 19 Dec 2025 15:01:26 +0800 Subject: [PATCH 11/21] Revert other files to main, keep only process-utils changes --- packages/core/src/ide/detect-ide.test.ts | 43 +++--- packages/core/src/ide/detect-ide.ts | 6 +- packages/core/src/ide/ide-client.test.ts | 172 +++++++++++------------ packages/core/src/ide/ide-client.ts | 30 +++- 4 files changed, 143 insertions(+), 108 deletions(-) diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts index 81ccdce5..9bf1eb12 100644 --- a/packages/core/src/ide/detect-ide.test.ts +++ b/packages/core/src/ide/detect-ide.test.ts @@ -8,6 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { detectIde, IDE_DEFINITIONS } from './detect-ide.js'; describe('detectIde', () => { + const ideProcessInfo = { pid: 123, command: 'some/path/to/code' }; + const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' }; + // Clear all IDE-related environment variables before each test beforeEach(() => { vi.stubEnv('__COG_BASHRC_SOURCED', ''); @@ -26,65 +29,73 @@ describe('detectIde', () => { it('should return undefined if TERM_PROGRAM is not vscode', () => { vi.stubEnv('TERM_PROGRAM', ''); - expect(detectIde()).toBeUndefined(); + expect(detectIde(ideProcessInfo)).toBeUndefined(); }); it('should detect Devin', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('__COG_BASHRC_SOURCED', '1'); - expect(detectIde()).toBe(IDE_DEFINITIONS.devin); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin); }); it('should detect Replit', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('REPLIT_USER', 'testuser'); - expect(detectIde()).toBe(IDE_DEFINITIONS.replit); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit); }); it('should detect Cursor', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('CURSOR_TRACE_ID', 'some-id'); - expect(detectIde()).toBe(IDE_DEFINITIONS.cursor); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor); }); it('should detect Codespaces', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('CODESPACES', 'true'); - expect(detectIde()).toBe(IDE_DEFINITIONS.codespaces); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces); }); it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true'); - expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell); }); it('should detect Cloud Shell via CLOUD_SHELL', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('CLOUD_SHELL', 'true'); - expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell); }); it('should detect Trae', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('TERM_PRODUCT', 'Trae'); - expect(detectIde()).toBe(IDE_DEFINITIONS.trae); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae); }); it('should detect Firebase Studio via MONOSPACE_ENV', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('MONOSPACE_ENV', 'true'); - expect(detectIde()).toBe(IDE_DEFINITIONS.firebasestudio); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio); }); - it('should detect VSCodeFork when no other IDE is detected and no process info provided', () => { + it('should detect VSCode when no other IDE is detected and command includes "code"', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('MONOSPACE_ENV', ''); - expect(detectIde()).toBe(IDE_DEFINITIONS.vscodefork); + expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode); + }); + + it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('MONOSPACE_ENV', ''); + expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork); }); }); describe('detectIde with ideInfoFromFile', () => { + const ideProcessInfo = { pid: 123, command: 'some/path/to/code' }; + beforeEach(() => { vi.stubEnv('__COG_BASHRC_SOURCED', ''); vi.stubEnv('REPLIT_USER', ''); @@ -105,22 +116,22 @@ describe('detectIde with ideInfoFromFile', () => { name: 'custom-ide', displayName: 'Custom IDE', }; - expect(detectIde(undefined, ideInfoFromFile)).toEqual(ideInfoFromFile); + expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile); }); it('should fall back to env detection if name is missing', () => { const ideInfoFromFile = { displayName: 'Custom IDE' }; vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(detectIde(undefined, ideInfoFromFile)).toBe( - IDE_DEFINITIONS.vscodefork, + expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe( + IDE_DEFINITIONS.vscode, ); }); it('should fall back to env detection if displayName is missing', () => { const ideInfoFromFile = { name: 'custom-ide' }; vi.stubEnv('TERM_PROGRAM', 'vscode'); - expect(detectIde(undefined, ideInfoFromFile)).toBe( - IDE_DEFINITIONS.vscodefork, + expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe( + IDE_DEFINITIONS.vscode, ); }); }); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index f7ef2979..c00d9a62 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -52,7 +52,7 @@ export function detectIdeFromEnv(): IdeInfo { function verifyVSCode( ide: IdeInfo, - ideProcessInfo?: { + ideProcessInfo: { pid: number; command: string; }, @@ -61,7 +61,7 @@ function verifyVSCode( return ide; } if ( - ideProcessInfo?.command && + ideProcessInfo.command && ideProcessInfo.command.toLowerCase().includes('code') ) { return IDE_DEFINITIONS.vscode; @@ -70,7 +70,7 @@ function verifyVSCode( } export function detectIde( - ideProcessInfo?: { + ideProcessInfo: { pid: number; command: string; }, diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index f41b229a..ca26f78f 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -22,6 +22,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { detectIde, IDE_DEFINITIONS } from './detect-ide.js'; import * as os from 'node:os'; +import * as path from 'node:path'; vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -96,20 +97,21 @@ describe('IdeClient', () => { describe('connect', () => { it('should connect using HTTP when port is provided in config file', async () => { - const config = { port: '8080', workspacePath: '/test/workspace' }; + 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(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'qwen-code-ide-server-12345.json'), + 'utf8', + ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( new URL('http://127.0.0.1:8080/mcp'), expect.any(Object), @@ -121,19 +123,13 @@ describe('IdeClient', () => { }); it('should connect using stdio when stdio config is provided in file', async () => { - const config = { - stdio: { command: 'test-cmd', args: ['--foo'] }, - workspacePath: '/test/workspace', - }; + 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(['qwen-code-ide-server-12345.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -152,17 +148,13 @@ describe('IdeClient', () => { const config = { port: '8080', stdio: { command: 'test-cmd', args: ['--foo'] }, - workspacePath: '/test/workspace', }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -225,16 +217,13 @@ describe('IdeClient', () => { }); it('should prioritize file config over environment variables', async () => { - const config = { port: '8080', workspacePath: '/test/workspace' }; + 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(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090'; const ideClient = await IdeClient.getInstance(); @@ -276,15 +265,7 @@ describe('IdeClient', () => { describe('getConnectionConfigFromFile', () => { it('should return config from the specific pid file if it exists', async () => { const config = { port: '1234', workspacePath: '/test/workspace' }; - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue(['qwen-code-ide-server-1234.json']); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); const ideClient = await IdeClient.getInstance(); // In tests, the private method can be accessed like this. @@ -295,6 +276,10 @@ describe('IdeClient', () => { ).getConnectionConfigFromFile(); expect(result).toEqual(config); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'qwen-code-ide-server-12345.json'), + 'utf8', + ); }); it('should return undefined if no config files are found', async () => { @@ -317,11 +302,14 @@ describe('IdeClient', () => { 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-5678.json']); + ).mockResolvedValue(['qwen-code-ide-server-12345-123.json']); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ isValid: true, @@ -335,6 +323,10 @@ describe('IdeClient', () => { ).getConnectionConfigFromFile(); expect(result).toEqual(config); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), + 'utf8', + ); }); it('should filter out configs with invalid workspace paths', async () => { @@ -346,13 +338,16 @@ describe('IdeClient', () => { 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-1111.json', - 'qwen-code-ide-server-5678.json', + 'qwen-code-ide-server-12345-111.json', + 'qwen-code-ide-server-12345-222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(invalidConfig)) @@ -384,13 +379,16 @@ describe('IdeClient', () => { 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-1111.json', - 'qwen-code-ide-server-2222.json', + 'qwen-code-ide-server-12345-111.json', + 'qwen-code-ide-server-12345-222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) @@ -413,13 +411,16 @@ describe('IdeClient', () => { 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-1111.json', - 'qwen-code-ide-server-2222.json', + 'qwen-code-ide-server-12345-111.json', + 'qwen-code-ide-server-12345-222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) @@ -441,13 +442,16 @@ describe('IdeClient', () => { 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-1111.json', - 'qwen-code-ide-server-2222.json', + 'qwen-code-ide-server-12345-111.json', + 'qwen-code-ide-server-12345-222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce('invalid json') @@ -467,11 +471,12 @@ describe('IdeClient', () => { }); it('should return undefined if readdir throws an error', async () => { - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockRejectedValue(new Error('readdir failed')); + 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 ( @@ -485,12 +490,15 @@ describe('IdeClient', () => { 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-3333.json', // valid + 'qwen-code-ide-server-12345-111.json', // valid 'not-a-config-file.txt', // invalid 'qwen-code-ide-server-asdf.json', // invalid ]); @@ -509,19 +517,30 @@ describe('IdeClient', () => { ).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-1111.json', - 'qwen-code-ide-server-3333.json', + 'qwen-code-ide-server-12345-111.json', + 'qwen-code-ide-server-12345-222.json', ]); vi.mocked(fs.promises.readFile) .mockResolvedValueOnce(JSON.stringify(config1)) @@ -549,16 +568,13 @@ describe('IdeClient', () => { }); it('should return false if tool discovery fails', async () => { - const config = { port: '8080', workspacePath: '/test/workspace' }; + 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(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); mockClient.request.mockRejectedValue(new Error('Method not found')); const ideClient = await IdeClient.getInstance(); @@ -571,16 +587,13 @@ describe('IdeClient', () => { }); it('should return false if diffing tools are not available', async () => { - const config = { port: '8080', workspacePath: '/test/workspace' }; + 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(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); mockClient.request.mockResolvedValue({ tools: [{ name: 'someOtherTool' }], }); @@ -595,16 +608,13 @@ describe('IdeClient', () => { }); it('should return false if only openDiff tool is available', async () => { - const config = { port: '8080', workspacePath: '/test/workspace' }; + 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(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); mockClient.request.mockResolvedValue({ tools: [{ name: 'openDiff' }], }); @@ -619,16 +629,13 @@ describe('IdeClient', () => { }); it('should return true if connected and diffing tools are available', async () => { - const config = { port: '8080', workspacePath: '/test/workspace' }; + 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(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); mockClient.request.mockResolvedValue({ tools: [{ name: 'openDiff' }, { name: 'closeDiff' }], }); @@ -646,20 +653,13 @@ describe('IdeClient', () => { describe('authentication', () => { it('should connect with an auth token if provided in the discovery file', async () => { const authToken = 'test-auth-token'; - const config = { - port: '8080', - authToken, - workspacePath: '/test/workspace', - }; + const config = { port: '8080', authToken }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); ( vi.mocked(fs.promises.readdir) as Mock< (path: fs.PathLike) => Promise > - ).mockResolvedValue(['qwen-code-ide-server-8080.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b401c9f7..b447f46c 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -14,6 +14,7 @@ import { IdeDiffClosedNotificationSchema, IdeDiffRejectedNotificationSchema, } from './types.js'; +import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -85,6 +86,7 @@ export class IdeClient { 'IDE integration is currently disabled. To enable it, run /ide enable.', }; private currentIde: IdeInfo | undefined; + private ideProcessInfo: { pid: number; command: string } | undefined; private connectionConfig: | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined; @@ -106,9 +108,10 @@ export class IdeClient { if (!IdeClient.instancePromise) { IdeClient.instancePromise = (async () => { const client = new IdeClient(); + client.ideProcessInfo = await getIdeProcessInfo(); client.connectionConfig = await client.getConnectionConfigFromFile(); client.currentIde = detectIde( - undefined, + client.ideProcessInfo, client.connectionConfig?.ideInfo, ); return client; @@ -569,7 +572,26 @@ export class IdeClient { | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined > { - const portFileDir = os.tmpdir(); + 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); @@ -582,7 +604,9 @@ export class IdeClient { return undefined; } - const fileRegex = /^qwen-code-ide-server-\d+\.json$/; + const fileRegex = new RegExp( + `^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`, + ); const matchingFiles = portFiles .filter((file) => fileRegex.test(file)) .sort(); From 156134d3d4ef671512fe0c43856c9decb5fe5478 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 16 Dec 2025 10:46:28 +0800 Subject: [PATCH 12/21] chore(sdk): bundle CLI into SDK package and inherit the dependencies --- packages/sdk-typescript/package.json | 3 +- packages/sdk-typescript/scripts/build.js | 32 ++++++++++++++++++++ packages/sdk-typescript/src/utils/cliPath.ts | 29 +++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index b071b8a3..6de4ff97 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -45,7 +45,8 @@ "node": ">=18.0.0" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4" + "@modelcontextprotocol/sdk": "^1.0.4", + "tiktoken": "^1.0.21" }, "devDependencies": { "@types/node": "^20.14.0", diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index beda8b0e..ae3a21e8 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -91,3 +91,35 @@ 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'); diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index 2d919413..99d604f1 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -20,6 +20,7 @@ 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 @@ -40,11 +41,37 @@ 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 = [ - // 1. Environment variable (highest priority) + // 1. Environment variable process.env['QWEN_CODE_CLI_PATH'], // 2. Volta bin From 00a8c6a92439470130cd74370f863c2b28263aac Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 16 Dec 2025 11:04:03 +0800 Subject: [PATCH 13/21] chore: separate CLI and SDK integration test --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 }}' From ba87cf63f6e014a8ff79bc8ea08d282fbfc01faa Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 16 Dec 2025 14:47:23 +0800 Subject: [PATCH 14/21] chore: build and bundle CLI for SDK release --- .github/workflows/release-sdk.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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' }} From d40447cee443fed9630bd19e032a0eb0be669225 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 16 Dec 2025 17:20:20 +0800 Subject: [PATCH 15/21] fix: failed test cases --- .../sdk-typescript/test/unit/cliPath.test.ts | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 43f50dec..3cabb9e5 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -38,6 +38,8 @@ describe('CLI Path Utilities', () => { mockFs.statSync.mockReturnValue({ isFile: () => true, } as ReturnType); + // Default: return true for existsSync (can be overridden in specific tests) + mockFs.existsSync.mockReturnValue(true); }); afterEach(() => { @@ -54,7 +56,17 @@ describe('CLI Path Utilities', () => { // 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); + // Mock existsSync to return false for bundled CLI, true for env var path + mockFs.existsSync.mockImplementation((p) => { + const pathStr = p.toString(); + if ( + pathStr.includes('cli/cli.js') || + pathStr.includes('cli\\cli.js') + ) { + return false; + } + return pathStr.includes('/usr/local/bin/qwen'); + }); const result = parseExecutableSpec(); @@ -365,6 +377,17 @@ describe('CLI Path Utilities', () => { // Mock environment variable const originalEnv = process.env['QWEN_CODE_CLI_PATH']; process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + // Mock existsSync to return false for bundled CLI, true for env var path + mockFs.existsSync.mockImplementation((p) => { + const pathStr = p.toString(); + if ( + pathStr.includes('cli/cli.js') || + pathStr.includes('cli\\cli.js') + ) { + return false; + } + return pathStr.includes('/usr/local/bin/qwen'); + }); const result = prepareSpawnInfo(); @@ -385,7 +408,14 @@ describe('CLI Path Utilities', () => { 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); + // Mock existsSync to return false for bundled CLI, true for env var path + mockFs.existsSync.mockImplementation((p) => { + const pathStr = p.toString(); + if (pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')) { + return false; + } + return pathStr.includes('/custom/path/to/qwen'); + }); const result = findNativeCliPath(); From 3354b56a05338cd08b9d2f39bec1cbc95b883cee Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 19 Dec 2025 15:36:45 +0800 Subject: [PATCH 16/21] docs(sdk): update sdk docs --- packages/sdk-typescript/README.md | 20 ++++++++++++++++++-- packages/sdk-typescript/package.json | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index bc3ef6aa..a9699b02 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -13,9 +13,8 @@ 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 -> **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. +> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed. ## Quick Start @@ -372,6 +371,23 @@ 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. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 6de4ff97..f80c61c4 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.5.1", + "version": "0.1.1", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", From 90489933fd96e0d432d8519577217e20fb2c218a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 19 Dec 2025 15:52:11 +0800 Subject: [PATCH 17/21] fix: lint issues --- docs/index.md | 1 + docs/users/configuration/settings.md | 58 +++++++++---------- .../messages/Assistant/AssistantMessage.css | 2 +- .../toolcalls/shared/LayoutComponents.css | 3 +- .../src/webview/styles/tailwind.css | 7 ++- 5 files changed, 37 insertions(+), 34 deletions(-) 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/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css index 56946662..67675816 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css @@ -48,5 +48,5 @@ } .assistant-message-container.assistant-message-loading::after { - display: none + display: none; } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css index 39846d77..e5b2cce9 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css @@ -172,7 +172,8 @@ /* Loading animation for toolcall header */ @keyframes toolcallHeaderPulse { - 0%, 100% { + 0%, + 100% { opacity: 1; } 50% { diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index a48c172f..5c9955b3 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -51,7 +51,8 @@ .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 */ @@ -66,7 +67,7 @@ The data attribute is needed because some browsers insert a
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; @@ -80,7 +81,7 @@ outline: none; } .composer-input:disabled, - .composer-input[contenteditable="false"] { + .composer-input[contenteditable='false'] { color: #999; cursor: not-allowed; } From fa7d8579459564d80b20c5114d99f71bac28b846 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 19 Dec 2025 16:18:22 +0800 Subject: [PATCH 18/21] fix: remove unused cli finding code --- packages/sdk-typescript/src/utils/cliPath.ts | 56 +-------- .../sdk-typescript/test/unit/cliPath.test.ts | 110 +++++------------- 2 files changed, 33 insertions(+), 133 deletions(-) diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index 99d604f1..4f031963 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -2,19 +2,10 @@ * CLI path auto-detection and subprocess spawning utilities * * Supports multiple execution modes: - * 1. Native binary: 'qwen' (production) - * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 1. Bundled CLI: Node.js bundle included in the SDK package (default) + * 2. Node.js bundle: 'node /path/to/cli.js' (custom path) * 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'; @@ -68,48 +59,11 @@ export function findNativeCliPath(): string { return bundledCli; } - const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; - - const candidates: Array = [ - // 1. Environment variable - 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( - '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' + + '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' + ' • 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" })', ); } diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 3cabb9e5..c4253175 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -52,38 +52,26 @@ describe('CLI Path Utilities', () => { describe('parseExecutableSpec', () => { describe('auto-detection (no spec provided)', () => { - 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'; - // Mock existsSync to return false for bundled CLI, true for env var path + 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(); - if ( - pathStr.includes('cli/cli.js') || - pathStr.includes('cli\\cli.js') - ) { - return false; - } - return pathStr.includes('/usr/local/bin/qwen'); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); }); const result = parseExecutableSpec(); - expect(result).toEqual({ - executablePath: path.resolve('/usr/local/bin/qwen'), - isExplicitRuntime: false, - }); - - // Restore env - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(result.executablePath).toContain('cli.js'); + expect(result.isExplicitRuntime).toBe(false); }); - it('should throw when auto-detection fails', () => { + it('should throw when bundled CLI not found', () => { mockFs.existsSync.mockReturnValue(false); expect(() => parseExecutableSpec()).toThrow( - 'qwen CLI not found. Please:', + 'Bundled qwen CLI not found', ); }); }); @@ -373,83 +361,44 @@ describe('CLI Path Utilities', () => { }); describe('auto-detection fallback', () => { - 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'; - // Mock existsSync to return false for bundled CLI, true for env var path + 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(); - if ( - pathStr.includes('cli/cli.js') || - pathStr.includes('cli\\cli.js') - ) { - return false; - } - return pathStr.includes('/usr/local/bin/qwen'); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); }); const result = prepareSpawnInfo(); - expect(result).toEqual({ - command: path.resolve('/usr/local/bin/qwen'), - args: [], - type: 'native', - originalInput: '', - }); - - // Restore env - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(result.command).toBe(process.execPath); + expect(result.args[0]).toContain('cli.js'); + expect(result.type).toBe('node'); + expect(result.originalInput).toBe(''); }); }); }); describe('findNativeCliPath', () => { - 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'; - // Mock existsSync to return false for bundled CLI, true for env var path + it('should find bundled CLI', () => { + // Mock existsSync to return true for bundled CLI mockFs.existsSync.mockImplementation((p) => { const pathStr = p.toString(); - if (pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')) { - return false; - } - return pathStr.includes('/custom/path/to/qwen'); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); }); const result = findNativeCliPath(); - expect(result).toBe(path.resolve('/custom/path/to/qwen')); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(result).toContain('cli.js'); }); - 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) => { - return p.toString().includes(voltaBinPath); - }); - - const result = findNativeCliPath(); - - expect(result).toContain(voltaBinPath); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; - }); - - it('should throw descriptive error when CLI not found', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - delete process.env['QWEN_CODE_CLI_PATH']; + it('should throw descriptive error when bundled CLI not found', () => { mockFs.existsSync.mockReturnValue(false); - expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found'); }); }); @@ -664,13 +613,10 @@ describe('CLI Path Utilities', () => { mockFs.existsSync.mockReturnValue(false); expect(() => parseExecutableSpec('/missing/file')).toThrow( - 'Set QWEN_CODE_CLI_PATH environment variable', + 'Executable file not found at', ); expect(() => parseExecutableSpec('/missing/file')).toThrow( - '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', + 'Please check the file path and ensure the file exists', ); }); }); From c5c556a326b03ec76af8a79a217b00034bd20eb5 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 19 Dec 2025 18:12:04 +0800 Subject: [PATCH 19/21] remove pid from lockfile name of ide connection file --- package-lock.json | 48 ++-- packages/core/src/ide/ide-client.test.ts | 257 ++---------------- packages/core/src/ide/ide-client.ts | 163 ++++++----- .../src/ide-server.test.ts | 23 +- .../vscode-ide-companion/src/ide-server.ts | 11 +- 5 files changed, 142 insertions(+), 360 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85a56a7d..7911e73d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -568,7 +568,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -592,7 +591,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2157,7 +2155,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3671,7 +3668,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4142,7 +4138,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4153,7 +4148,6 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4359,7 +4353,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5135,7 +5128,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5530,7 +5522,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -6865,6 +6858,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7982,7 +7976,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8518,6 +8511,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8579,6 +8573,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8588,6 +8583,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8597,6 +8593,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -8763,6 +8760,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8781,6 +8779,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8789,13 +8788,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9909,7 +9910,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -11864,6 +11864,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -13162,7 +13163,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -13821,7 +13823,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13832,7 +13833,6 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13866,7 +13866,6 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15932,7 +15931,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16112,8 +16110,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16121,7 +16118,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16316,7 +16312,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16624,6 +16619,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16679,7 +16675,6 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16793,7 +16788,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16807,7 +16801,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17486,7 +17479,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17757,7 +17749,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17776,10 +17767,11 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.5.1", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4" + "@modelcontextprotocol/sdk": "^1.0.4", + "tiktoken": "^1.0.21" }, "devDependencies": { "@types/node": "^20.14.0", diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 99314c9b..72f78089 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -102,17 +102,12 @@ describe('IdeClient', () => { 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('/home/test', '.qwen', 'ide', '12345-8080.lock'), + path.join('/home/test', '.qwen', 'ide', '8080.lock'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -127,13 +122,9 @@ describe('IdeClient', () => { }); 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(); @@ -146,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(); @@ -168,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 () => { @@ -282,7 +271,7 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/home/test', '.qwen', 'ide', '12345-1234.lock'), + path.join('/home/test', '.qwen', 'ide', '1234.lock'), 'utf8', ); delete process.env['QWEN_CODE_IDE_SERVER_PORT']; @@ -290,11 +279,6 @@ describe('IdeClient', () => { 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 ( @@ -306,26 +290,15 @@ describe('IdeClient', () => { expect(result).toBeUndefined(); }); - it('should find and parse a single lock file matching the IDE pid', async () => { + it('should read legacy pid config when available', async () => { const config = { port: '5678', workspacePath: '/test/workspace', ppid: 12345, }; - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValueOnce(['12345-5678.lock']); - vi.mocked(fs.promises.stat).mockResolvedValueOnce({ - mtimeMs: 123, - } as unknown as fs.Stats); vi.mocked(fs.promises.readFile).mockResolvedValueOnce( JSON.stringify(config), ); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -336,106 +309,18 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/home/test', '.qwen', 'ide', '12345-5678.lock'), + 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.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan - vi.mocked(fs.promises.stat) - .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) - .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(invalidConfig)) - .mockResolvedValueOnce(JSON.stringify(validConfig)); - - const validateSpy = vi - .spyOn(IdeClient, 'validateWorkspacePath') - .mockImplementation((ideWorkspacePath) => ({ - isValid: ideWorkspacePath === '/test/workspace', - })); - - 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.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan - // Make config1 "newer" so it wins when both are valid. - vi.mocked(fs.promises.stat) - .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats) - .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats); - 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'), - ); // For ~/.qwen/ide/-.lock - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan - vi.mocked(fs.promises.stat) - .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) - .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); 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 ( @@ -445,25 +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.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan - vi.mocked(fs.promises.stat) - .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats) - .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats); + 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 ( @@ -472,99 +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) as Mock< - (path: fs.PathLike) => Promise - > - ).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan - - 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.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValueOnce([ - '12345-3333.lock', // valid - 'not-a-config-file.txt', // invalid - 'asdf.lock', // invalid - '12345-asdf.lock', // invalid - ]); // ~/.qwen/ide scan - vi.mocked(fs.promises.stat).mockResolvedValueOnce({ - mtimeMs: 123, - } as unknown as fs.Stats); - 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('/home/test', '.qwen', 'ide', '12345-3333.lock'), - 'utf8', - ); - expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/home/test', '.qwen', '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 - > - ).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan - vi.mocked(fs.promises.stat) - .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats) - .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats); - 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 2cb419e0..b216506f 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -573,106 +573,105 @@ export class IdeClient { | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined > { - if (!this.ideProcessInfo) { - return undefined; - } - - // Preferred: lock file(s) in global qwen dir (~/.qwen/ide/-.lock) - // 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/-.lock - // 2) Otherwise (or on failure), scan ~/.qwen/ide for -*.lock and select: - // - valid workspace path (validateWorkspacePath) - const ideDir = Storage.getGlobalIdeDir(); - const idePid = this.ideProcessInfo.pid; const portFromEnv = this.getPortFromEnv(); if (portFromEnv) { try { - const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`); + 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 scanning / legacy discovery. + // Fall through to legacy discovery. } } - try { - const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`); - const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => - fileRegex.test(file), - ); + // Legacy discovery for VSCode extension < v0.5.1. + return this.getLegacyConnectionConfig(portFromEnv); + } - 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 }; - } - }), - ); - const validWorkspaces = fileContents - .filter(({ parsed }) => parsed !== undefined) - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .map(({ parsed }) => parsed) - .filter((content) => { - const { isValid } = IdeClient.validateWorkspacePath( - content.workspacePath, - process.cwd(), - ); - return isValid; - }); - - if (validWorkspaces.length > 0) { - if (validWorkspaces.length === 1) { - return validWorkspaces[0]; - } - - if (validWorkspaces.length > 1 && portFromEnv) { - const matchingPort = validWorkspaces.find( - (content) => String(content.port) === portFromEnv, - ); - if (matchingPort) { - return matchingPort; - } - } - - if (validWorkspaces.length > 1) { - return validWorkspaces[0]; - } + // 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. } - } catch (_) { - // Fall through to legacy discovery mechanisms. } - // For backwards compatability: single file in system temp dir named by PID. - 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() { // ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server const existingNoProxy = process.env['NO_PROXY'] || ''; diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index c2ef4c40..8268efe6 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -133,13 +133,14 @@ describe('IDEServer', () => { '/home/test', '.qwen', 'ide', - `${process.ppid}-${port}.lock`, + `${port}.lock`, ); 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, @@ -164,13 +165,14 @@ describe('IDEServer', () => { '/home/test', '.qwen', 'ide', - `${process.ppid}-${port}.lock`, + `${port}.lock`, ); 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, @@ -195,13 +197,14 @@ describe('IDEServer', () => { '/home/test', '.qwen', 'ide', - `${process.ppid}-${port}.lock`, + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: '', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( expectedLockFile, @@ -240,13 +243,14 @@ describe('IDEServer', () => { '/home/test', '.qwen', 'ide', - `${process.ppid}-${port}.lock`, + `${port}.lock`, ); 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, @@ -267,6 +271,7 @@ describe('IDEServer', () => { workspacePath: '/baz/qux', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( expectedLockFile, @@ -279,12 +284,7 @@ describe('IDEServer', () => { await ideServer.start(mockContext); const replaceMock = mockContext.environmentVariableCollection.replace; const port = getPortFromMock(replaceMock); - const lockFile = path.join( - '/home/test', - '.qwen', - 'ide', - `${process.ppid}-${port}.lock`, - ); + const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`); expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String)); await ideServer.stop(); @@ -315,13 +315,14 @@ describe('IDEServer', () => { '/home/test', '.qwen', 'ide', - `${process.ppid}-${port}.lock`, + `${port}.lock`, ); 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, diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index f020eba0..f7712399 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -10,6 +10,7 @@ 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'; @@ -82,11 +83,13 @@ 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}`); @@ -340,12 +343,8 @@ export class IDEServer { this.port = address.port; try { const ideDir = await getGlobalIdeDir(); - // Name the lock file by port to support multiple server instances - // under the same parent process. - this.lockFile = path.join( - ideDir, - `${process.ppid}-${this.port}.lock`, - ); + // 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}`); From 52cd1da4a73a74bc0f97130a8da1640e47a5610c Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 19 Dec 2025 18:16:59 +0800 Subject: [PATCH 20/21] update documentation --- .../ide-integration/ide-companion-spec.md | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/users/ide-integration/ide-companion-spec.md b/docs/users/ide-integration/ide-companion-spec.md index af265814..37b0b833 100644 --- a/docs/users/ide-integration/ide-companion-spec.md +++ b/docs/users/ide-integration/ide-companion-spec.md @@ -18,14 +18,13 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context ### 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 "lock 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 scans `~/.qwen/ide/` for `${PID}-*.lock` files, parses their JSON content, and selects one whose `workspacePath` matches the CLI's current working directory. If `QWEN_CODE_IDE_SERVER_PORT` is set, the CLI will prefer `${PID}-${PORT}.lock`. +- **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: - `${PID}-${PORT}.lock` - - `${PID}`: The process ID of the parent IDE process. - - `${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 lock 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 From 9cc5c3ed8f827163d0d65689139bebae2a0f545f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 22 Dec 2025 09:35:30 +0800 Subject: [PATCH 21/21] pump version to 0.6.0 --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/sdk-typescript/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7911e73d..696e1660 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/*" ], @@ -17494,7 +17494,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -17618,7 +17618,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.5.1", + "version": "0.6.0", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -17767,7 +17767,7 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.1.1", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", @@ -20197,7 +20197,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": { @@ -20209,7 +20209,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.15.1", diff --git a/package.json b/package.json index e5ed33bd..e654c56f 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 0f1e6854..f49af8a8 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.16.0", diff --git a/packages/core/package.json b/packages/core/package.json index 9a9cc9d3..64b1789e 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", diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index f80c61c4..4ff4d6da 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.1", + "version": "0.6.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index d7d32ac9..a1310056 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.5.1", + "version": "0.6.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index a5328582..ea6f024c 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.5.1", + "version": "0.6.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": {