mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 09:47:47 +00:00
653 lines
17 KiB
JavaScript
Executable File
653 lines
17 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Native Messaging Host for Qwen CLI Bridge
|
|
* This script acts as a bridge between the Chrome extension and Qwen CLI
|
|
* Uses ACP (Agent Communication Protocol) for communication with Qwen CLI
|
|
*/
|
|
|
|
const { spawn } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
// ============================================================================
|
|
// Logging
|
|
// ============================================================================
|
|
|
|
const LOG_FILE = path.join(os.tmpdir(), 'qwen-bridge-host.log');
|
|
|
|
function log(message, level = 'INFO') {
|
|
const timestamp = new Date().toISOString();
|
|
const logLine = `[${timestamp}] [${level}] ${message}\n`;
|
|
fs.appendFileSync(LOG_FILE, logLine);
|
|
}
|
|
|
|
function logError(message) {
|
|
log(message, 'ERROR');
|
|
}
|
|
|
|
function logDebug(message) {
|
|
log(message, 'DEBUG');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Native Messaging Protocol (Chrome Extension <-> Native Host)
|
|
// ============================================================================
|
|
|
|
function sendMessageToExtension(message) {
|
|
const buffer = Buffer.from(JSON.stringify(message));
|
|
const length = Buffer.allocUnsafe(4);
|
|
length.writeUInt32LE(buffer.length, 0);
|
|
|
|
process.stdout.write(length);
|
|
process.stdout.write(buffer);
|
|
}
|
|
|
|
function readMessagesFromExtension() {
|
|
let messageLength = null;
|
|
let chunks = [];
|
|
|
|
// Keep stdin open and in flowing mode
|
|
process.stdin.resume();
|
|
|
|
process.stdin.on('data', (chunk) => {
|
|
log(`Received ${chunk.length} bytes from extension`);
|
|
chunks.push(chunk);
|
|
|
|
while (true) {
|
|
const buffer = Buffer.concat(chunks);
|
|
|
|
// Need at least 4 bytes for length
|
|
if (messageLength === null) {
|
|
if (buffer.length < 4) break;
|
|
messageLength = buffer.readUInt32LE(0);
|
|
chunks = [buffer.slice(4)];
|
|
log(`Message length: ${messageLength}`);
|
|
continue;
|
|
}
|
|
|
|
// Check if we have the full message
|
|
const fullBuffer = Buffer.concat(chunks);
|
|
if (fullBuffer.length < messageLength) break;
|
|
|
|
// Extract and parse message
|
|
const messageBuffer = fullBuffer.slice(0, messageLength);
|
|
try {
|
|
const message = JSON.parse(messageBuffer.toString());
|
|
log(`Received message: ${JSON.stringify(message)}`);
|
|
|
|
// Reset for next message
|
|
chunks = [fullBuffer.slice(messageLength)];
|
|
messageLength = null;
|
|
|
|
// Handle the message
|
|
handleExtensionMessage(message);
|
|
} catch (err) {
|
|
logError(`Failed to parse message: ${err.message}`);
|
|
chunks = [fullBuffer.slice(messageLength)];
|
|
messageLength = null;
|
|
}
|
|
}
|
|
});
|
|
|
|
process.stdin.on('end', () => {
|
|
log('stdin ended');
|
|
cleanup();
|
|
process.exit();
|
|
});
|
|
|
|
process.stdin.on('error', (err) => {
|
|
logError(`stdin error: ${err.message}`);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// ACP Protocol (Native Host <-> Qwen CLI)
|
|
// ============================================================================
|
|
|
|
const ACP_PROTOCOL_VERSION = 1;
|
|
|
|
class AcpConnection {
|
|
constructor() {
|
|
this.process = null;
|
|
this.status = 'disconnected';
|
|
this.sessionId = null;
|
|
this.pendingRequests = new Map();
|
|
this.nextRequestId = 1;
|
|
this.inputBuffer = '';
|
|
}
|
|
|
|
async start(cwd = process.cwd()) {
|
|
if (this.process) {
|
|
return { success: false, error: 'Qwen CLI is already running' };
|
|
}
|
|
|
|
try {
|
|
log(`Starting Qwen CLI with ACP mode in ${cwd}`);
|
|
|
|
this.process = spawn('qwen', ['--experimental-acp'], {
|
|
cwd,
|
|
shell: true,
|
|
windowsHide: true,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
if (!this.process || !this.process.pid) {
|
|
this.process = null;
|
|
this.status = 'stopped';
|
|
return { success: false, error: 'Failed to start Qwen CLI process' };
|
|
}
|
|
|
|
this.status = 'starting';
|
|
|
|
// Handle stdout (ACP messages from Qwen CLI)
|
|
this.process.stdout.on('data', (data) => {
|
|
this.handleAcpData(data.toString());
|
|
});
|
|
|
|
// Handle stderr (logs from Qwen CLI)
|
|
this.process.stderr.on('data', (data) => {
|
|
const message = data.toString().trim();
|
|
if (message) {
|
|
log(`Qwen stderr: ${message}`);
|
|
}
|
|
});
|
|
|
|
// Handle process exit
|
|
this.process.on('close', (code) => {
|
|
log(`Qwen CLI exited with code ${code}`);
|
|
this.process = null;
|
|
this.status = 'stopped';
|
|
this.sessionId = null;
|
|
|
|
// Reject all pending requests
|
|
for (const [id, { reject }] of this.pendingRequests) {
|
|
reject(new Error('Qwen CLI process exited'));
|
|
}
|
|
this.pendingRequests.clear();
|
|
|
|
sendMessageToExtension({
|
|
type: 'event',
|
|
data: { type: 'qwen_stopped', code }
|
|
});
|
|
});
|
|
|
|
this.process.on('error', (err) => {
|
|
logError(`Qwen CLI process error: ${err.message}`);
|
|
this.status = 'error';
|
|
});
|
|
|
|
// Initialize ACP connection
|
|
const initResult = await this.initialize();
|
|
if (!initResult.success) {
|
|
this.stop();
|
|
return initResult;
|
|
}
|
|
|
|
// Create a new session
|
|
const sessionResult = await this.createSession(cwd);
|
|
if (!sessionResult.success) {
|
|
this.stop();
|
|
return sessionResult;
|
|
}
|
|
|
|
this.status = 'running';
|
|
return {
|
|
success: true,
|
|
data: {
|
|
status: 'running',
|
|
pid: this.process.pid,
|
|
sessionId: this.sessionId,
|
|
agentInfo: initResult.data.agentInfo
|
|
}
|
|
};
|
|
} catch (error) {
|
|
logError(`Failed to start Qwen CLI: ${error.message}`);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
handleAcpData(data) {
|
|
this.inputBuffer += data;
|
|
const lines = this.inputBuffer.split('\n');
|
|
this.inputBuffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
try {
|
|
const message = JSON.parse(trimmed);
|
|
this.handleAcpMessage(message);
|
|
} catch (err) {
|
|
logError(`Failed to parse ACP message: ${trimmed}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
handleAcpMessage(message) {
|
|
logDebug(`ACP received: ${JSON.stringify(message)}`);
|
|
|
|
// Handle response to our request
|
|
if ('id' in message && !('method' in message)) {
|
|
const pending = this.pendingRequests.get(message.id);
|
|
if (pending) {
|
|
this.pendingRequests.delete(message.id);
|
|
if ('result' in message) {
|
|
pending.resolve(message.result);
|
|
} else if ('error' in message) {
|
|
pending.reject(new Error(message.error.message || 'ACP error'));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle notification from Qwen CLI
|
|
if ('method' in message && !('id' in message)) {
|
|
this.handleAcpNotification(message.method, message.params);
|
|
return;
|
|
}
|
|
|
|
// Handle request from Qwen CLI (e.g., permission requests)
|
|
if ('method' in message && 'id' in message) {
|
|
this.handleAcpRequest(message.id, message.method, message.params);
|
|
return;
|
|
}
|
|
}
|
|
|
|
handleAcpNotification(method, params) {
|
|
switch (method) {
|
|
case 'session/update':
|
|
// Forward session updates to the extension
|
|
sendMessageToExtension({
|
|
type: 'event',
|
|
data: {
|
|
type: 'session_update',
|
|
sessionId: params.sessionId,
|
|
update: params.update
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'authenticate/update':
|
|
sendMessageToExtension({
|
|
type: 'event',
|
|
data: {
|
|
type: 'auth_update',
|
|
authUri: params._meta?.authUri
|
|
}
|
|
});
|
|
break;
|
|
|
|
default:
|
|
log(`Unknown ACP notification: ${method}`);
|
|
}
|
|
}
|
|
|
|
handleAcpRequest(id, method, params) {
|
|
switch (method) {
|
|
case 'session/request_permission':
|
|
// Forward permission request to extension
|
|
sendMessageToExtension({
|
|
type: 'permission_request',
|
|
requestId: id,
|
|
sessionId: params.sessionId,
|
|
toolCall: params.toolCall,
|
|
options: params.options
|
|
});
|
|
break;
|
|
|
|
case 'fs/read_text_file':
|
|
// Handle file read request
|
|
this.handleFileReadRequest(id, params);
|
|
break;
|
|
|
|
case 'fs/write_text_file':
|
|
// Handle file write request
|
|
this.handleFileWriteRequest(id, params);
|
|
break;
|
|
|
|
default:
|
|
log(`Unknown ACP request: ${method}`);
|
|
this.sendAcpResponse(id, { error: { code: -32601, message: 'Method not found' } });
|
|
}
|
|
}
|
|
|
|
handleFileReadRequest(id, params) {
|
|
try {
|
|
const content = fs.readFileSync(params.path, 'utf-8');
|
|
this.sendAcpResponse(id, { result: { content } });
|
|
} catch (err) {
|
|
this.sendAcpResponse(id, {
|
|
error: { code: -32000, message: `Failed to read file: ${err.message}` }
|
|
});
|
|
}
|
|
}
|
|
|
|
handleFileWriteRequest(id, params) {
|
|
try {
|
|
fs.writeFileSync(params.path, params.content, 'utf-8');
|
|
this.sendAcpResponse(id, { result: null });
|
|
} catch (err) {
|
|
this.sendAcpResponse(id, {
|
|
error: { code: -32000, message: `Failed to write file: ${err.message}` }
|
|
});
|
|
}
|
|
}
|
|
|
|
sendAcpMessage(message) {
|
|
if (!this.process || !this.process.stdin.writable) {
|
|
throw new Error('Qwen CLI is not running');
|
|
}
|
|
|
|
const json = JSON.stringify(message) + '\n';
|
|
logDebug(`ACP send: ${json.trim()}`);
|
|
this.process.stdin.write(json);
|
|
}
|
|
|
|
sendAcpRequest(method, params) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = this.nextRequestId++;
|
|
this.pendingRequests.set(id, { resolve, reject });
|
|
|
|
try {
|
|
this.sendAcpMessage({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
method,
|
|
params
|
|
});
|
|
} catch (err) {
|
|
this.pendingRequests.delete(id);
|
|
reject(err);
|
|
}
|
|
|
|
// Timeout after 30 seconds
|
|
setTimeout(() => {
|
|
if (this.pendingRequests.has(id)) {
|
|
this.pendingRequests.delete(id);
|
|
reject(new Error(`Request ${method} timed out`));
|
|
}
|
|
}, 30000);
|
|
});
|
|
}
|
|
|
|
sendAcpResponse(id, response) {
|
|
this.sendAcpMessage({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
...response
|
|
});
|
|
}
|
|
|
|
sendAcpNotification(method, params) {
|
|
this.sendAcpMessage({
|
|
jsonrpc: '2.0',
|
|
method,
|
|
params
|
|
});
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
const result = await this.sendAcpRequest('initialize', {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
clientCapabilities: {
|
|
fs: {
|
|
readTextFile: true,
|
|
writeTextFile: true
|
|
}
|
|
}
|
|
});
|
|
|
|
log(`Qwen CLI initialized: ${JSON.stringify(result)}`);
|
|
return { success: true, data: result };
|
|
} catch (err) {
|
|
logError(`Failed to initialize Qwen CLI: ${err.message}`);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
async createSession(cwd) {
|
|
try {
|
|
const result = await this.sendAcpRequest('session/new', {
|
|
cwd,
|
|
mcpServers: []
|
|
});
|
|
|
|
this.sessionId = result.sessionId;
|
|
log(`Session created: ${this.sessionId}`);
|
|
return { success: true, data: result };
|
|
} catch (err) {
|
|
logError(`Failed to create session: ${err.message}`);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
async prompt(text) {
|
|
if (!this.sessionId) {
|
|
return { success: false, error: 'No active session' };
|
|
}
|
|
|
|
try {
|
|
const result = await this.sendAcpRequest('session/prompt', {
|
|
sessionId: this.sessionId,
|
|
prompt: [{ type: 'text', text }]
|
|
});
|
|
|
|
return { success: true, data: result };
|
|
} catch (err) {
|
|
logError(`Prompt failed: ${err.message}`);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
async cancel() {
|
|
if (!this.sessionId) {
|
|
return { success: false, error: 'No active session' };
|
|
}
|
|
|
|
try {
|
|
this.sendAcpNotification('session/cancel', {
|
|
sessionId: this.sessionId
|
|
});
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
respondToPermission(requestId, optionId) {
|
|
this.sendAcpResponse(requestId, {
|
|
result: {
|
|
outcome: optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }
|
|
}
|
|
});
|
|
}
|
|
|
|
stop() {
|
|
if (!this.process) {
|
|
return { success: false, error: 'Qwen CLI is not running' };
|
|
}
|
|
|
|
try {
|
|
this.process.kill('SIGTERM');
|
|
this.process = null;
|
|
this.status = 'stopped';
|
|
this.sessionId = null;
|
|
|
|
return { success: true, data: 'Qwen CLI stopped' };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
getStatus() {
|
|
return {
|
|
status: this.status,
|
|
sessionId: this.sessionId,
|
|
pid: this.process?.pid || null
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Global State
|
|
// ============================================================================
|
|
|
|
const acpConnection = new AcpConnection();
|
|
|
|
// Check if Qwen CLI is installed
|
|
async function checkQwenInstallation() {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const checkProcess = spawn('qwen', ['--version'], {
|
|
shell: true,
|
|
windowsHide: true
|
|
});
|
|
|
|
let output = '';
|
|
checkProcess.stdout.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
checkProcess.on('error', () => {
|
|
resolve({ installed: false });
|
|
});
|
|
|
|
checkProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ installed: true, version: output.trim() });
|
|
} else {
|
|
resolve({ installed: false });
|
|
}
|
|
});
|
|
|
|
setTimeout(() => {
|
|
checkProcess.kill();
|
|
resolve({ installed: false });
|
|
}, 5000);
|
|
} catch (error) {
|
|
resolve({ installed: false });
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Message Handlers
|
|
// ============================================================================
|
|
|
|
async function handleExtensionMessage(message) {
|
|
log(`Received from extension: ${JSON.stringify(message)}`);
|
|
let response;
|
|
|
|
switch (message.type) {
|
|
case 'handshake':
|
|
const installInfo = await checkQwenInstallation();
|
|
response = {
|
|
type: 'handshake_response',
|
|
version: '1.0.0',
|
|
qwenInstalled: installInfo.installed,
|
|
qwenVersion: installInfo.version,
|
|
qwenStatus: acpConnection.getStatus().status
|
|
};
|
|
break;
|
|
|
|
case 'start_qwen':
|
|
const cwd = message.cwd || process.cwd();
|
|
const startResult = await acpConnection.start(cwd);
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
...startResult
|
|
};
|
|
break;
|
|
|
|
case 'stop_qwen':
|
|
const stopResult = acpConnection.stop();
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
...stopResult
|
|
};
|
|
break;
|
|
|
|
case 'qwen_prompt':
|
|
const promptResult = await acpConnection.prompt(message.text);
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
...promptResult
|
|
};
|
|
break;
|
|
|
|
case 'qwen_cancel':
|
|
const cancelResult = await acpConnection.cancel();
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
...cancelResult
|
|
};
|
|
break;
|
|
|
|
case 'permission_response':
|
|
acpConnection.respondToPermission(message.requestId, message.optionId);
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
success: true
|
|
};
|
|
break;
|
|
|
|
case 'get_status':
|
|
const status = acpConnection.getStatus();
|
|
const installStatus = await checkQwenInstallation();
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
data: {
|
|
...status,
|
|
qwenInstalled: installStatus.installed,
|
|
qwenVersion: installStatus.version
|
|
}
|
|
};
|
|
break;
|
|
|
|
default:
|
|
response = {
|
|
type: 'response',
|
|
id: message.id,
|
|
error: `Unknown message type: ${message.type}`
|
|
};
|
|
}
|
|
|
|
sendMessageToExtension(response);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cleanup
|
|
// ============================================================================
|
|
|
|
function cleanup() {
|
|
log('Cleaning up...');
|
|
acpConnection.stop();
|
|
}
|
|
|
|
process.on('SIGINT', () => {
|
|
cleanup();
|
|
process.exit();
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
cleanup();
|
|
process.exit();
|
|
});
|
|
|
|
// ============================================================================
|
|
// Main
|
|
// ============================================================================
|
|
|
|
log('Native host started (ACP mode)');
|
|
readMessagesFromExtension();
|