Skip to content

Commit 90afe49

Browse files
committed
feat: VS Code extension backend connection + types refactoring complete
- Split types.ts into 5 domain-specific files (providers, tools, sessions, commands, common) - Fixed ProviderCallEntry to use Provider type instead of string - Added LocalcodeBackend class for VS Code extension JSON-RPC communication - Backend spawns localcode --headless --json as child process - Supports request/response and notification patterns - Auto-reconnect on failure with 10s timeout - Status tracking: stopped, starting, running, error All changes maintain backward compatibility through re-exports in types/index.ts
1 parent 78c3c4f commit 90afe49

2 files changed

Lines changed: 179 additions & 1 deletion

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
}

src/types/common.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// src/types/common.ts
22
// Common types shared across domains
33

4+
import type { Provider } from './providers.js';
5+
46
export type ApprovalMode = 'suggest' | 'auto-edit' | 'full-auto';
57

68
export type NyxMood = 'idle' | 'thinking' | 'happy' | 'error' | 'waiting';
@@ -12,7 +14,7 @@ export interface ModelRouting {
1214
}
1315

1416
export interface ProviderCallEntry {
15-
provider: string;
17+
provider: Provider;
1618
model: string;
1719
estimatedTokens: number;
1820
timestamp: number;

0 commit comments

Comments
 (0)