IDE companion discovery: switch to ~/.qwen/ide lock files

This commit is contained in:
tanzhenxin
2025-12-15 20:15:26 +08:00
parent 4cbb57a793
commit 59c3d3d0f9
11 changed files with 297 additions and 294 deletions

View File

@@ -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. - **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`). - **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. - **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: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist. - **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: - **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
`qwen-code-ide-server-${PID}-${PORT}.json` `${PID}-${PORT}.lock`
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename. - `${PID}`: The process ID of the parent IDE process.
- `${PORT}`: The port your MCP server is listening on. - `${PORT}`: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: - **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`). - `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. - **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 ## II. The Context Interface

View File

@@ -46,12 +46,15 @@ vi.mock('node:fs', async (importOriginal) => {
// Mock Storage from core // Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>(); const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return { return {
...actual, ...actual,
Storage: { Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'), 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.'); 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.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith( expect(fs.writeFileSync).toHaveBeenCalledWith(
@@ -371,7 +377,9 @@ describe('languageCommand', () => {
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',
messageType: 'info', 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.'); 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({ expect(result).toEqual({
type: 'message', type: 'message',
@@ -514,7 +525,9 @@ describe('languageCommand', () => {
expect(result).toEqual({ expect(result).toEqual({
type: 'message', type: 'message',
messageType: 'info', messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'), content: expect.stringContaining(
'LLM output language rule file generated',
),
}); });
}); });
}); });

View File

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

View File

@@ -338,10 +338,7 @@ describe('OpenAIContentConverter', () => {
}); });
it('should handle tools without functionDeclarations', async () => { it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [ const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }];
{} as Tool,
{ functionDeclarations: [] },
];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools); const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
@@ -489,7 +486,10 @@ describe('OpenAIContentConverter', () => {
const result = converter.convertGeminiToolParametersToOpenAI(params); const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>; const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>; const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>; const nestedProperties = nested?.['properties'] as Record<
string,
unknown
>;
expect(nestedProperties?.['deep']).toEqual({ expect(nestedProperties?.['deep']).toEqual({
type: 'integer', type: 'integer',

View File

@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises, ...actual.promises,
readFile: vi.fn(), readFile: vi.fn(),
readdir: vi.fn(), readdir: vi.fn(),
stat: vi.fn(),
}, },
realpathSync: (p: string) => p, realpathSync: (p: string) => p,
existsSync: () => false, existsSync: () => false,
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
command: 'test-ide', command: 'test-ide',
}); });
vi.mocked(os.tmpdir).mockReturnValue('/tmp'); vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports // Mock MCP client and transports
mockClient = { mockClient = {
@@ -97,6 +99,7 @@ describe('IdeClient', () => {
describe('connect', () => { describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => { 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' }; const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
( (
@@ -109,7 +112,7 @@ describe('IdeClient', () => {
await ideClient.connect(); await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'), path.join('/home/test', '.qwen', 'ide', '12345-8080.lock'),
'utf8', 'utf8',
); );
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -120,6 +123,7 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe( expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected, IDEConnectionStatus.Connected,
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should connect using stdio when stdio config is provided in file', async () => { it('should connect using stdio when stdio config is provided in file', async () => {
@@ -263,7 +267,8 @@ describe('IdeClient', () => {
}); });
describe('getConnectionConfigFromFile', () => { 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' }; const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -277,9 +282,10 @@ describe('IdeClient', () => {
expect(result).toEqual(config); expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith( expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'), path.join('/home/test', '.qwen', 'ide', '12345-1234.lock'),
'utf8', 'utf8',
); );
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
}); });
it('should return undefined if no config files are found', async () => { it('should return undefined if no config files are found', async () => {
@@ -300,17 +306,23 @@ describe('IdeClient', () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should find and parse a single config file with the new naming scheme', async () => { it('should find and parse a single lock file matching the IDE pid', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' }; const config = {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( port: '5678',
new Error('not found'), workspacePath: '/test/workspace',
); // For old path ppid: 12345,
};
( (
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']); ).mockResolvedValueOnce(['12345-5678.lock']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); 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({ vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true, isValid: true,
}); });
@@ -324,7 +336,7 @@ describe('IdeClient', () => {
expect(result).toEqual(config); expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith( 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', 'utf8',
); );
}); });
@@ -338,25 +350,23 @@ describe('IdeClient', () => {
port: '1111', port: '1111',
workspacePath: '/invalid/workspace', workspacePath: '/invalid/workspace',
}; };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
( (
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue([ ).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan
'qwen-code-ide-server-12345-111.json', vi.mocked(fs.promises.stat)
'qwen-code-ide-server-12345-222.json', .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
]); .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(invalidConfig)) .mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig)); .mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath') .spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false }) .mockImplementation((ideWorkspacePath) => ({
.mockReturnValueOnce({ isValid: true }); isValid: ideWorkspacePath === '/test/workspace',
}));
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -379,17 +389,15 @@ describe('IdeClient', () => {
it('should return the first valid config when multiple workspaces are valid', async () => { it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' }; const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' }; const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
( (
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue([ ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
'qwen-code-ide-server-12345-111.json', // Make config1 "newer" so it wins when both are valid.
'qwen-code-ide-server-12345-222.json', 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) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2)); .mockResolvedValueOnce(JSON.stringify(config2));
@@ -413,15 +421,15 @@ describe('IdeClient', () => {
const config2 = { port: '2222', workspacePath: '/test/workspace2' }; const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'), new Error('not found'),
); ); // For ~/.qwen/ide/<pid>-<port>.lock
( (
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue([ ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
'qwen-code-ide-server-12345-111.json', vi.mocked(fs.promises.stat)
'qwen-code-ide-server-12345-222.json', .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
]); .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2)); .mockResolvedValueOnce(JSON.stringify(config2));
@@ -442,17 +450,14 @@ describe('IdeClient', () => {
it('should handle invalid JSON in one of the config files', async () => { it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' }; const validConfig = { port: '2222', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
( (
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue([ ).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
'qwen-code-ide-server-12345-111.json', vi.mocked(fs.promises.stat)
'qwen-code-ide-server-12345-222.json', .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
]); .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json') .mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig)); .mockResolvedValueOnce(JSON.stringify(validConfig));
@@ -474,9 +479,11 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce( vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'), 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<string[]>
>
).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const result = await ( const result = await (
@@ -490,18 +497,19 @@ describe('IdeClient', () => {
it('should ignore files with invalid names', async () => { it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' }; const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
( (
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue([ ).mockResolvedValueOnce([
'qwen-code-ide-server-12345-111.json', // valid '12345-3333.lock', // valid
'not-a-config-file.txt', // invalid '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( vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig), JSON.stringify(validConfig),
); );
@@ -518,11 +526,11 @@ describe('IdeClient', () => {
expect(result).toEqual(validConfig); expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith( 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', 'utf8',
); );
expect(fs.promises.readFile).not.toHaveBeenCalledWith( 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', 'utf8',
); );
}); });
@@ -538,10 +546,10 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readdir) as Mock< vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]> (path: fs.PathLike) => Promise<string[]>
> >
).mockResolvedValue([ ).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan
'qwen-code-ide-server-12345-111.json', vi.mocked(fs.promises.stat)
'qwen-code-ide-server-12345-222.json', .mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
]); .mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile) vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1)) .mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2)); .mockResolvedValueOnce(JSON.stringify(config2));

View File

@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js'; import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js'; import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import { import {
IdeContextNotificationSchema, IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema, IdeDiffAcceptedNotificationSchema,
@@ -576,68 +577,55 @@ export class IdeClient {
return undefined; return undefined;
} }
// For backwards compatability // Preferred: lock file(s) in global qwen dir (~/.qwen/ide/<pid>-<port>.lock)
// 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/<pid>-<port>.lock
// 2) Otherwise (or on failure), scan ~/.qwen/ide for <pid>-*.lock and select:
// - valid workspace path (validateWorkspacePath)
const ideDir = Storage.getGlobalIdeDir();
const idePid = this.ideProcessInfo.pid;
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
try { try {
const portFile = path.join( const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`);
os.tmpdir(), const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`, return JSON.parse(lockFileContents);
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) { } catch (_) {
// For newer extension versions, the file name matches the pattern // Fall through to scanning / legacy discovery.
// /^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 { try {
portFiles = await fs.promises.readdir(portFileDir); const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`);
} catch (e) { const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
logger.debug('Failed to read IDE connection directory:', e); fileRegex.test(file),
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[]; const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try { try {
fileContents = await Promise.all( const stat = await fs.promises.stat(fullPath);
matchingFiles.map((file) => const content = await fs.promises.readFile(fullPath, 'utf8');
fs.promises.readFile(path.join(portFileDir, file), '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 };
}
}),
); );
} catch (e) { const validWorkspaces = fileContents
logger.debug('Failed to read IDE connection config file(s):', e); .filter(({ parsed }) => parsed !== undefined)
return undefined; .sort((a, b) => b.mtimeMs - a.mtimeMs)
} .map(({ parsed }) => parsed)
const parsedContents = fileContents.map((content) => { .filter((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( const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath, content.workspacePath,
process.cwd(), process.cwd(),
@@ -645,16 +633,12 @@ export class IdeClient {
return isValid; return isValid;
}); });
if (validWorkspaces.length === 0) { if (validWorkspaces.length > 0) {
return undefined;
}
if (validWorkspaces.length === 1) { if (validWorkspaces.length === 1) {
return validWorkspaces[0]; return validWorkspaces[0];
} }
const portFromEnv = this.getPortFromEnv(); if (validWorkspaces.length > 1 && portFromEnv) {
if (portFromEnv) {
const matchingPort = validWorkspaces.find( const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv, (content) => String(content.port) === portFromEnv,
); );
@@ -663,8 +647,31 @@ export class IdeClient {
} }
} }
if (validWorkspaces.length > 1) {
return validWorkspaces[0]; 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(),
`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.
}
return undefined;
}
private createProxyAwareFetch() { private createProxyAwareFetch() {
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server // ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server

View File

@@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve(undefined)), writeFile: vi.fn(() => Promise.resolve(undefined)),
unlink: vi.fn(() => Promise.resolve(undefined)), unlink: vi.fn(() => Promise.resolve(undefined)),
chmod: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)),
mkdir: vi.fn(() => Promise.resolve(undefined)),
})); }));
vi.mock('node:os', async (importOriginal) => { vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>(); const actual = await importOriginal<typeof os>();
return { return {
...actual, ...actual,
tmpdir: vi.fn(() => '/tmp'), homedir: vi.fn(() => '/home/test'),
}; };
}); });
@@ -128,13 +129,11 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${process.ppid}-${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
@@ -143,15 +142,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token', authToken: 'test-auth-token',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should set a single folder path', async () => { it('should set a single folder path', async () => {
@@ -166,13 +160,11 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${process.ppid}-${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
@@ -181,15 +173,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token', authToken: 'test-auth-token',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should set an empty string if no folders are open', async () => { it('should set an empty string if no folders are open', async () => {
@@ -204,13 +191,11 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${process.ppid}-${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
@@ -219,15 +204,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token', authToken: 'test-auth-token',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}); });
it('should update the path when workspace folders change', async () => { it('should update the path when workspace folders change', async () => {
@@ -256,13 +236,11 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${process.ppid}-${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
@@ -271,15 +249,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token', authToken: 'test-auth-token',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
// Simulate removing a folder // Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
@@ -296,34 +269,28 @@ describe('IDEServer', () => {
authToken: 'test-auth-token', authToken: 'test-auth-token',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent2, expectedContent2,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 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); await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace; const replaceMock = mockContext.environmentVariableCollection.replace;
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`); const lockFile = path.join(
const ppidPortFile = path.join( '/home/test',
'/tmp', '.qwen',
`qwen-code-ide-server-${process.ppid}.json`, 'ide',
`${process.ppid}-${port}.lock`,
); );
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String)); expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
await ideServer.stop(); await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(portFile); expect(fs.unlink).toHaveBeenCalledWith(lockFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
}); });
it.skipIf(process.platform !== 'win32')( it.skipIf(process.platform !== 'win32')(
@@ -344,13 +311,11 @@ describe('IDEServer', () => {
); );
const port = getPortFromMock(replaceMock); const port = getPortFromMock(replaceMock);
const expectedPortFile = path.join( const expectedLockFile = path.join(
'/tmp', '/home/test',
`qwen-code-ide-server-${port}.json`, '.qwen',
); 'ide',
const expectedPpidPortFile = path.join( `${process.ppid}-${port}.lock`,
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
); );
const expectedContent = JSON.stringify({ const expectedContent = JSON.stringify({
port: parseInt(port, 10), port: parseInt(port, 10),
@@ -359,15 +324,10 @@ describe('IDEServer', () => {
authToken: 'test-auth-token', authToken: 'test-auth-token',
}); });
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.writeFile).toHaveBeenCalledWith(
expectedPortFile, expectedLockFile,
expectedContent, expectedContent,
); );
expect(fs.writeFile).toHaveBeenCalledWith( expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
}, },
); );
@@ -379,7 +339,7 @@ describe('IDEServer', () => {
port = (ideServer as unknown as { port: number }).port; 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`, { const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -390,7 +350,9 @@ describe('IDEServer', () => {
id: 1, 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 () => { it('should allow request with valid auth token', async () => {
@@ -550,6 +512,7 @@ describe('IDEServer HTTP endpoints', () => {
headers: { headers: {
Host: `localhost:${port}`, Host: `localhost:${port}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer test-auth-token',
}, },
}, },
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),

View File

@@ -38,12 +38,24 @@ class CORSError extends Error {
const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const MCP_SESSION_ID_HEADER = 'mcp-session-id';
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
const QWEN_DIR = '.qwen';
const IDE_DIR = 'ide';
async function getGlobalIdeDir(): Promise<string> {
const homeDir = os.homedir();
// Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior).
const baseDir = homeDir
? path.join(homeDir, QWEN_DIR)
: path.join(os.tmpdir(), QWEN_DIR);
const ideDir = path.join(baseDir, IDE_DIR);
await fs.mkdir(ideDir, { recursive: true });
return ideDir;
}
interface WritePortAndWorkspaceArgs { interface WritePortAndWorkspaceArgs {
context: vscode.ExtensionContext; context: vscode.ExtensionContext;
port: number; port: number;
portFile: string; lockFile: string;
ppidPortFile: string;
authToken: string; authToken: string;
log: (message: string) => void; log: (message: string) => void;
} }
@@ -51,8 +63,7 @@ interface WritePortAndWorkspaceArgs {
async function writePortAndWorkspace({ async function writePortAndWorkspace({
context, context,
port, port,
portFile, lockFile,
ppidPortFile,
authToken, authToken,
log, log,
}: WritePortAndWorkspaceArgs): Promise<void> { }: WritePortAndWorkspaceArgs): Promise<void> {
@@ -78,19 +89,15 @@ async function writePortAndWorkspace({
authToken, authToken,
}); });
log(`Writing port file to: ${portFile}`); log(`Writing IDE lock file to: ${lockFile}`);
log(`Writing ppid port file to: ${ppidPortFile}`);
try { try {
await Promise.all([ await fs.mkdir(path.dirname(lockFile), { recursive: true });
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)), await fs.writeFile(lockFile, content);
fs await fs.chmod(lockFile, 0o600);
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(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 server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined; private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void; private log: (message: string) => void;
private portFile: string | undefined; private lockFile: string | undefined;
private ppidPortFile: string | undefined;
private port: number | undefined; private port: number | undefined;
private authToken: string | undefined; private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } = private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
@@ -174,20 +180,25 @@ export class IDEServer {
app.use((req, res, next) => { app.use((req, res, next) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader) { if (!authHeader) {
this.log('Missing Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const parts = authHeader.split(' '); const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') { if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.'); this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
return; return;
} }
const token = parts[1]; const token = parts[1];
if (token !== this.authToken) { if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.'); this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
return; return;
} }
}
next(); next();
}); });
@@ -327,22 +338,25 @@ export class IDEServer {
const address = (this.server as HTTPServer).address(); const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') { if (address && typeof address !== 'string') {
this.port = address.port; this.port = address.port;
this.portFile = path.join( try {
os.tmpdir(), const ideDir = await getGlobalIdeDir();
`qwen-code-ide-server-${this.port}.json`, // Name the lock file by port to support multiple server instances
); // under the same parent process.
this.ppidPortFile = path.join( this.lockFile = path.join(
os.tmpdir(), ideDir,
`qwen-code-ide-server-${process.ppid}.json`, `${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}`); this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken) { if (this.authToken && this.lockFile) {
await writePortAndWorkspace({ await writePortAndWorkspace({
context, context,
port: this.port, port: this.port,
portFile: this.portFile, lockFile: this.lockFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken, authToken: this.authToken,
log: this.log, log: this.log,
}); });
@@ -371,15 +385,13 @@ export class IDEServer {
this.context && this.context &&
this.server && this.server &&
this.port && this.port &&
this.portFile && this.lockFile &&
this.ppidPortFile &&
this.authToken this.authToken
) { ) {
await writePortAndWorkspace({ await writePortAndWorkspace({
context: this.context, context: this.context,
port: this.port, port: this.port,
portFile: this.portFile, lockFile: this.lockFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken, authToken: this.authToken,
log: this.log, log: this.log,
}); });
@@ -405,16 +417,9 @@ export class IDEServer {
if (this.context) { if (this.context) {
this.context.environmentVariableCollection.clear(); this.context.environmentVariableCollection.clear();
} }
if (this.portFile) { if (this.lockFile) {
try { try {
await fs.unlink(this.portFile); await fs.unlink(this.lockFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
} catch (_err) { } catch (_err) {
// Ignore errors if the file doesn't exist. // Ignore errors if the file doesn't exist.
} }

View File

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

View File

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

View File

@@ -51,7 +51,8 @@
.composer-form:focus-within { .composer-form:focus-within {
/* match existing highlight behavior */ /* match existing highlight behavior */
border-color: var(--app-input-highlight); 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 */ /* Composer: input editable area */
@@ -66,7 +67,7 @@
The data attribute is needed because some browsers insert a <br> in The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */ contentEditable, which breaks :empty matching. */
.composer-input:empty:before, .composer-input:empty:before,
.composer-input[data-empty="true"]::before { .composer-input[data-empty='true']::before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground); color: var(--app-input-placeholder-foreground);
pointer-events: none; pointer-events: none;
@@ -80,7 +81,7 @@
outline: none; outline: none;
} }
.composer-input:disabled, .composer-input:disabled,
.composer-input[contenteditable="false"] { .composer-input[contenteditable='false'] {
color: #999; color: #999;
cursor: not-allowed; cursor: not-allowed;
} }