|
| 1 | +// extensions/vscode/src/localcodeBackend.ts |
| 2 | +// Connects to localcode CLI backend via child process and JSON-RPC |
| 3 | + |
| 4 | +import * as vscode from 'vscode'; |
| 5 | +import { ChildProcess, spawn } from 'child_process'; |
| 6 | +import { EventEmitter } from 'events'; |
| 7 | + |
| 8 | +interface JsonRpcRequest { |
| 9 | + jsonrpc: '2.0'; |
| 10 | + id: number; |
| 11 | + method: string; |
| 12 | + params?: Record<string, unknown>; |
| 13 | +} |
| 14 | + |
| 15 | +interface JsonRpcResponse { |
| 16 | + jsonrpc: '2.0'; |
| 17 | + id: number; |
| 18 | + result?: unknown; |
| 19 | + error?: { code: number; message: string; data?: unknown }; |
| 20 | +} |
| 21 | + |
| 22 | +interface JsonRpcNotification { |
| 23 | + jsonrpc: '2.0'; |
| 24 | + method: string; |
| 25 | + params?: Record<string, unknown>; |
| 26 | +} |
| 27 | + |
| 28 | +export class LocalcodeBackend extends EventEmitter { |
| 29 | + private process: ChildProcess | null = null; |
| 30 | + private buffer = ''; |
| 31 | + private requestId = 0; |
| 32 | + private pendingRequests = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>(); |
| 33 | + private status: 'stopped' | 'starting' | 'running' | 'error' = 'stopped'; |
| 34 | + |
| 35 | + constructor(private outputChannel: vscode.OutputChannel) { |
| 36 | + super(); |
| 37 | + } |
| 38 | + |
| 39 | + getStatus(): string { |
| 40 | + return this.status; |
| 41 | + } |
| 42 | + |
| 43 | + async start(): Promise<void> { |
| 44 | + if (this.status === 'running') return; |
| 45 | + this.status = 'starting'; |
| 46 | + |
| 47 | + try { |
| 48 | + this.process = spawn('localcode', ['--headless', '--json'], { |
| 49 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 50 | + env: { ...process.env }, |
| 51 | + }); |
| 52 | + |
| 53 | + this.process.stdout?.on('data', (data: Buffer) => { |
| 54 | + this.buffer += data.toString(); |
| 55 | + this.processBuffer(); |
| 56 | + }); |
| 57 | + |
| 58 | + this.process.stderr?.on('data', (data: Buffer) => { |
| 59 | + const msg = data.toString(); |
| 60 | + this.outputChannel.appendLine(`[localcode stderr] ${msg}`); |
| 61 | + this.emit('stderr', msg); |
| 62 | + }); |
| 63 | + |
| 64 | + this.process.on('close', (code: number | null) => { |
| 65 | + this.outputChannel.appendLine(`[localcode] Process exited with code ${code}`); |
| 66 | + this.status = 'stopped'; |
| 67 | + this.emit('stopped', code); |
| 68 | + }); |
| 69 | + |
| 70 | + this.process.on('error', (err: Error) => { |
| 71 | + this.outputChannel.appendLine(`[localcode] Process error: ${err.message}`); |
| 72 | + this.status = 'error'; |
| 73 | + this.emit('error', err); |
| 74 | + }); |
| 75 | + |
| 76 | + // Wait for ready signal |
| 77 | + await new Promise<void>((resolve, reject) => { |
| 78 | + const timeout = setTimeout(() => reject(new Error('Localcode backend failed to start within 10 seconds')), 10000); |
| 79 | + const onReady = () => { |
| 80 | + clearTimeout(timeout); |
| 81 | + this.status = 'running'; |
| 82 | + this.removeListener('stderr', onStderr); |
| 83 | + resolve(); |
| 84 | + }; |
| 85 | + const onStderr = (msg: string) => { |
| 86 | + if (msg.includes('ready') || msg.includes('started')) { |
| 87 | + onReady(); |
| 88 | + } |
| 89 | + }; |
| 90 | + this.on('stderr', onStderr); |
| 91 | + // Fallback: assume ready after short delay |
| 92 | + setTimeout(() => { |
| 93 | + if (this.status === 'starting') { |
| 94 | + this.status = 'running'; |
| 95 | + this.removeListener('stderr', onStderr); |
| 96 | + resolve(); |
| 97 | + } |
| 98 | + }, 2000); |
| 99 | + }); |
| 100 | + |
| 101 | + this.outputChannel.appendLine('[localcode] Backend started'); |
| 102 | + } catch (err) { |
| 103 | + this.status = 'error'; |
| 104 | + this.outputChannel.appendLine(`[localcode] Failed to start: ${err instanceof Error ? err.message : String(err)}`); |
| 105 | + throw err; |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + async stop(): Promise<void> { |
| 110 | + if (this.process) { |
| 111 | + this.process.kill('SIGTERM'); |
| 112 | + this.process = null; |
| 113 | + } |
| 114 | + this.status = 'stopped'; |
| 115 | + this.buffer = ''; |
| 116 | + for (const [id, { reject }] of this.pendingRequests) { |
| 117 | + reject(new Error('Backend stopped')); |
| 118 | + this.pendingRequests.delete(id); |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + async sendRequest(method: string, params?: Record<string, unknown>): Promise<unknown> { |
| 123 | + if (this.status !== 'running') { |
| 124 | + await this.start(); |
| 125 | + } |
| 126 | + |
| 127 | + const id = ++this.requestId; |
| 128 | + const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; |
| 129 | + |
| 130 | + return new Promise((resolve, reject) => { |
| 131 | + this.pendingRequests.set(id, { resolve, reject }); |
| 132 | + this.write(JSON.stringify(request) + '\n'); |
| 133 | + }); |
| 134 | + } |
| 135 | + |
| 136 | + sendNotification(method: string, params?: Record<string, unknown>): void { |
| 137 | + if (this.status !== 'running') return; |
| 138 | + const notification: JsonRpcNotification = { jsonrpc: '2.0', method, params }; |
| 139 | + this.write(JSON.stringify(notification) + '\n'); |
| 140 | + } |
| 141 | + |
| 142 | + private write(data: string): void { |
| 143 | + if (this.process?.stdin) { |
| 144 | + this.process.stdin.write(data); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + private processBuffer(): void { |
| 149 | + const lines = this.buffer.split('\n'); |
| 150 | + this.buffer = lines.pop() || ''; |
| 151 | + |
| 152 | + for (const line of lines) { |
| 153 | + if (!line.trim()) continue; |
| 154 | + try { |
| 155 | + const msg = JSON.parse(line); |
| 156 | + if (msg.id !== undefined) { |
| 157 | + // Response |
| 158 | + const pending = this.pendingRequests.get(msg.id); |
| 159 | + if (pending) { |
| 160 | + this.pendingRequests.delete(msg.id); |
| 161 | + if (msg.error) { |
| 162 | + pending.reject(new Error(msg.error.message)); |
| 163 | + } else { |
| 164 | + pending.resolve(msg.result); |
| 165 | + } |
| 166 | + } |
| 167 | + } else if (msg.method) { |
| 168 | + // Notification |
| 169 | + this.emit(msg.method, msg.params); |
| 170 | + } |
| 171 | + } catch { |
| 172 | + // Ignore parse errors |
| 173 | + } |
| 174 | + } |
| 175 | + } |
| 176 | +} |
0 commit comments