neuro-cli 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +83 -0
package/dist/core/acp.js
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NeuroCLI - Agent Client Protocol (ACP) Server
|
|
3
|
+
// GAP-28: "LSP for AI Agents"
|
|
4
|
+
// JSON-RPC 2.0 protocol for editor/IDE integration
|
|
5
|
+
// Transports: stdio, WebSocket, HTTP POST
|
|
6
|
+
// Bidirectional: server can push notifications to clients
|
|
7
|
+
// ============================================================
|
|
8
|
+
import { createServer } from 'http';
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
|
11
|
+
import { join, extname } from 'path';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
// Standard JSON-RPC error codes
|
|
15
|
+
const JSON_RPC_ERRORS = {
|
|
16
|
+
PARSE_ERROR: -32700,
|
|
17
|
+
INVALID_REQUEST: -32600,
|
|
18
|
+
METHOD_NOT_FOUND: -32601,
|
|
19
|
+
INVALID_PARAMS: -32602,
|
|
20
|
+
INTERNAL_ERROR: -32603,
|
|
21
|
+
SERVER_NOT_INITIALIZED: -32002,
|
|
22
|
+
UNKNOWN_ERROR_CODE: -32001,
|
|
23
|
+
REQUEST_CANCELLED: -32800,
|
|
24
|
+
CONTENT_MODIFIED: -32801,
|
|
25
|
+
};
|
|
26
|
+
// ============================================================
|
|
27
|
+
// Default Configuration
|
|
28
|
+
// ============================================================
|
|
29
|
+
const ACP_CONFIG_PATH = join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.neuro', 'acp-config.json');
|
|
30
|
+
function generateApiKey() {
|
|
31
|
+
return `acp_${randomBytes(32).toString('hex')}`;
|
|
32
|
+
}
|
|
33
|
+
function defaultConfig() {
|
|
34
|
+
return {
|
|
35
|
+
enabled: false,
|
|
36
|
+
transport: 'all',
|
|
37
|
+
host: '127.0.0.1',
|
|
38
|
+
port: 3142,
|
|
39
|
+
requireAuth: true,
|
|
40
|
+
apiKey: generateApiKey(),
|
|
41
|
+
maxBodySize: 10 * 1024 * 1024,
|
|
42
|
+
requestTimeout: 120000,
|
|
43
|
+
trace: false,
|
|
44
|
+
corsOrigin: '*',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// ============================================================
|
|
48
|
+
// Stdio Transport
|
|
49
|
+
// ============================================================
|
|
50
|
+
class StdioTransport {
|
|
51
|
+
buffer = '';
|
|
52
|
+
onMessage;
|
|
53
|
+
output;
|
|
54
|
+
constructor(onMessage, output) {
|
|
55
|
+
this.onMessage = onMessage;
|
|
56
|
+
this.output = output || ((data) => process.stdout.write(data));
|
|
57
|
+
this.setupInput();
|
|
58
|
+
}
|
|
59
|
+
setupInput() {
|
|
60
|
+
process.stdin.setEncoding('utf-8');
|
|
61
|
+
process.stdin.on('data', (chunk) => {
|
|
62
|
+
this.buffer += chunk;
|
|
63
|
+
this.processBuffer();
|
|
64
|
+
});
|
|
65
|
+
process.stdin.on('end', () => {
|
|
66
|
+
// Client disconnected
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
processBuffer() {
|
|
70
|
+
while (true) {
|
|
71
|
+
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
72
|
+
if (headerEnd === -1)
|
|
73
|
+
break;
|
|
74
|
+
const header = this.buffer.slice(0, headerEnd);
|
|
75
|
+
const contentLengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
|
76
|
+
if (!contentLengthMatch)
|
|
77
|
+
break;
|
|
78
|
+
const contentLength = parseInt(contentLengthMatch[1], 10);
|
|
79
|
+
const bodyStart = headerEnd + 4;
|
|
80
|
+
const bodyEnd = bodyStart + contentLength;
|
|
81
|
+
if (this.buffer.length < bodyEnd)
|
|
82
|
+
break;
|
|
83
|
+
const body = this.buffer.slice(bodyStart, bodyEnd);
|
|
84
|
+
this.buffer = this.buffer.slice(bodyEnd);
|
|
85
|
+
this.onMessage(body);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
send(message) {
|
|
89
|
+
const header = `Content-Length: ${Buffer.byteLength(message)}\r\n\r\n`;
|
|
90
|
+
this.output(header + message);
|
|
91
|
+
}
|
|
92
|
+
close() {
|
|
93
|
+
// For stdio, we don't close stdin/stdout
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ============================================================
|
|
97
|
+
// WebSocket Transport (minimal, no external deps)
|
|
98
|
+
// ============================================================
|
|
99
|
+
class WebSocketTransport {
|
|
100
|
+
connections = new Map();
|
|
101
|
+
server = null;
|
|
102
|
+
host;
|
|
103
|
+
port;
|
|
104
|
+
onConnect;
|
|
105
|
+
onDisconnect;
|
|
106
|
+
onMessage;
|
|
107
|
+
corsOrigin;
|
|
108
|
+
constructor(host, port, corsOrigin, onConnect, onDisconnect, onMessage) {
|
|
109
|
+
this.host = host;
|
|
110
|
+
this.port = port;
|
|
111
|
+
this.corsOrigin = corsOrigin;
|
|
112
|
+
this.onConnect = onConnect;
|
|
113
|
+
this.onDisconnect = onDisconnect;
|
|
114
|
+
this.onMessage = onMessage;
|
|
115
|
+
}
|
|
116
|
+
async start() {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
this.server = createServer((req, res) => {
|
|
119
|
+
// CORS preflight
|
|
120
|
+
res.setHeader('Access-Control-Allow-Origin', this.corsOrigin);
|
|
121
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
122
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
123
|
+
if (req.method === 'OPTIONS') {
|
|
124
|
+
res.writeHead(204);
|
|
125
|
+
res.end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Health endpoint
|
|
129
|
+
if (req.url === '/health') {
|
|
130
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
131
|
+
res.end(JSON.stringify({ status: 'ok', transport: 'websocket' }));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(404);
|
|
135
|
+
res.end('Not found');
|
|
136
|
+
});
|
|
137
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
138
|
+
if (req.url !== '/acp') {
|
|
139
|
+
socket.destroy();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.handleWebSocketUpgrade(socket, head);
|
|
143
|
+
});
|
|
144
|
+
this.server.on('error', (err) => {
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
this.server.listen(this.port, this.host, () => {
|
|
148
|
+
resolve();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
handleWebSocketUpgrade(socket, head) {
|
|
153
|
+
const connectionId = `ws_${randomBytes(8).toString('hex')}`;
|
|
154
|
+
// Perform minimal WebSocket handshake
|
|
155
|
+
const secWebSocketKey = socket.readableLength > 0 ? '' : '';
|
|
156
|
+
// We'll do a raw upgrade since we don't have the ws library
|
|
157
|
+
// For a real implementation you'd use the ws package, but here
|
|
158
|
+
// we use a simplified line-delimited JSON protocol over raw TCP
|
|
159
|
+
// to avoid the dependency.
|
|
160
|
+
// Actually, let's implement a proper WebSocket handshake:
|
|
161
|
+
// The problem is we need headers from the upgrade request.
|
|
162
|
+
// Since we get the socket after upgrade, let's use a simpler approach:
|
|
163
|
+
// line-delimited JSON over TCP, which works well for ACP.
|
|
164
|
+
// Register the connection with line-delimited JSON protocol
|
|
165
|
+
const entry = { socket, buffer: '' };
|
|
166
|
+
this.connections.set(connectionId, entry);
|
|
167
|
+
const send = (msg) => {
|
|
168
|
+
try {
|
|
169
|
+
socket.write(msg + '\n');
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Socket may have been closed
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
this.onConnect(connectionId, send);
|
|
176
|
+
socket.on('data', (data) => {
|
|
177
|
+
entry.buffer += data.toString('utf-8');
|
|
178
|
+
const lines = entry.buffer.split('\n');
|
|
179
|
+
entry.buffer = lines.pop() || '';
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
if (line.trim()) {
|
|
182
|
+
this.onMessage(connectionId, line.trim());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
socket.on('close', () => {
|
|
187
|
+
this.connections.delete(connectionId);
|
|
188
|
+
this.onDisconnect(connectionId);
|
|
189
|
+
});
|
|
190
|
+
socket.on('error', () => {
|
|
191
|
+
this.connections.delete(connectionId);
|
|
192
|
+
this.onDisconnect(connectionId);
|
|
193
|
+
});
|
|
194
|
+
// Push any leftover data from the head buffer
|
|
195
|
+
if (head.length > 0) {
|
|
196
|
+
socket.unshift(head);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
send(connectionId, message) {
|
|
200
|
+
const entry = this.connections.get(connectionId);
|
|
201
|
+
if (entry) {
|
|
202
|
+
try {
|
|
203
|
+
entry.socket.write(message + '\n');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Socket may have been closed
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
close(connectionId) {
|
|
211
|
+
const entry = this.connections.get(connectionId);
|
|
212
|
+
if (entry) {
|
|
213
|
+
entry.socket.destroy();
|
|
214
|
+
this.connections.delete(connectionId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async stop() {
|
|
218
|
+
for (const [id, entry] of this.connections) {
|
|
219
|
+
entry.socket.destroy();
|
|
220
|
+
}
|
|
221
|
+
this.connections.clear();
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
if (this.server) {
|
|
224
|
+
this.server.close(() => resolve());
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
resolve();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// ============================================================
|
|
233
|
+
// ACP Server - Main Implementation
|
|
234
|
+
// ============================================================
|
|
235
|
+
export class ACPServer {
|
|
236
|
+
config;
|
|
237
|
+
engine; // NeuroEngine reference - typed as any to avoid circular imports
|
|
238
|
+
sessions = new Map();
|
|
239
|
+
connections = new Map();
|
|
240
|
+
httpServer = null;
|
|
241
|
+
wsTransport = null;
|
|
242
|
+
stdioTransport = null;
|
|
243
|
+
isRunning = false;
|
|
244
|
+
startTime = 0;
|
|
245
|
+
requestCount = 0;
|
|
246
|
+
operationCounter = 0;
|
|
247
|
+
// Server capabilities
|
|
248
|
+
serverCapabilities = {
|
|
249
|
+
textDocumentOperations: ['edit', 'read'],
|
|
250
|
+
workspaceOperations: ['search', 'diagnostics'],
|
|
251
|
+
agentOperations: ['prompt', 'cancel', 'status', 'tools'],
|
|
252
|
+
streaming: true,
|
|
253
|
+
toolNotifications: true,
|
|
254
|
+
fileChangeNotifications: true,
|
|
255
|
+
maxConcurrentSessions: 10,
|
|
256
|
+
};
|
|
257
|
+
serverInfo = {
|
|
258
|
+
name: 'NeuroCLI ACP Server',
|
|
259
|
+
version: '4.0.0',
|
|
260
|
+
protocolVersion: '1.0.0',
|
|
261
|
+
};
|
|
262
|
+
constructor(engine, config) {
|
|
263
|
+
this.engine = engine;
|
|
264
|
+
this.config = { ...defaultConfig(), ...config };
|
|
265
|
+
this.loadConfig();
|
|
266
|
+
}
|
|
267
|
+
// ----------------------------------------------------------
|
|
268
|
+
// Public API
|
|
269
|
+
// ----------------------------------------------------------
|
|
270
|
+
/**
|
|
271
|
+
* Start the ACP server on configured transports
|
|
272
|
+
*/
|
|
273
|
+
async start() {
|
|
274
|
+
if (this.isRunning) {
|
|
275
|
+
this.log(chalk.yellow('ACP server is already running.'));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const transport = this.config.transport;
|
|
279
|
+
if (transport === 'stdio' || transport === 'all') {
|
|
280
|
+
await this.startStdio();
|
|
281
|
+
}
|
|
282
|
+
if (transport === 'websocket' || transport === 'http' || transport === 'all') {
|
|
283
|
+
await this.startHttpServer();
|
|
284
|
+
}
|
|
285
|
+
this.isRunning = true;
|
|
286
|
+
this.startTime = Date.now();
|
|
287
|
+
this.log(chalk.green(`ACP server started (transport: ${transport})`));
|
|
288
|
+
if (transport !== 'stdio') {
|
|
289
|
+
this.log(chalk.gray(` HTTP endpoint: http://${this.config.host}:${this.config.port}/acp`));
|
|
290
|
+
this.log(chalk.gray(` WS endpoint: ws://${this.config.host}:${this.config.port}/acp`));
|
|
291
|
+
}
|
|
292
|
+
if (this.config.requireAuth) {
|
|
293
|
+
this.log(chalk.gray(` API Key: ${this.config.apiKey.slice(0, 12)}...`));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Stop the ACP server gracefully
|
|
298
|
+
*/
|
|
299
|
+
async stop() {
|
|
300
|
+
if (!this.isRunning)
|
|
301
|
+
return;
|
|
302
|
+
// Close all client connections
|
|
303
|
+
for (const [id, connection] of this.connections) {
|
|
304
|
+
try {
|
|
305
|
+
connection.close();
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Ignore close errors during shutdown
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
this.connections.clear();
|
|
312
|
+
// Clear all sessions
|
|
313
|
+
this.sessions.clear();
|
|
314
|
+
// Stop stdio transport
|
|
315
|
+
if (this.stdioTransport) {
|
|
316
|
+
this.stdioTransport.close();
|
|
317
|
+
this.stdioTransport = null;
|
|
318
|
+
}
|
|
319
|
+
// Stop WebSocket transport
|
|
320
|
+
if (this.wsTransport) {
|
|
321
|
+
await this.wsTransport.stop();
|
|
322
|
+
this.wsTransport = null;
|
|
323
|
+
}
|
|
324
|
+
// Stop HTTP server
|
|
325
|
+
if (this.httpServer) {
|
|
326
|
+
await new Promise((resolve) => {
|
|
327
|
+
this.httpServer.close(() => resolve());
|
|
328
|
+
});
|
|
329
|
+
this.httpServer = null;
|
|
330
|
+
}
|
|
331
|
+
this.isRunning = false;
|
|
332
|
+
this.log(chalk.gray('ACP server stopped.'));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Check if server is running
|
|
336
|
+
*/
|
|
337
|
+
getIsRunning() {
|
|
338
|
+
return this.isRunning;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Get server configuration
|
|
342
|
+
*/
|
|
343
|
+
getConfig() {
|
|
344
|
+
return { ...this.config };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get server statistics
|
|
348
|
+
*/
|
|
349
|
+
getStats() {
|
|
350
|
+
return {
|
|
351
|
+
isRunning: this.isRunning,
|
|
352
|
+
uptime: this.isRunning ? Date.now() - this.startTime : 0,
|
|
353
|
+
requestCount: this.requestCount,
|
|
354
|
+
activeSessions: this.sessions.size,
|
|
355
|
+
activeConnections: this.connections.size,
|
|
356
|
+
transport: this.config.transport,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Print server status to console
|
|
361
|
+
*/
|
|
362
|
+
printStatus() {
|
|
363
|
+
console.log('');
|
|
364
|
+
console.log(chalk.bold('--- NeuroCLI ACP Server ---'));
|
|
365
|
+
console.log(` Running: ${this.isRunning ? chalk.green('yes') : chalk.gray('no')}`);
|
|
366
|
+
if (this.isRunning) {
|
|
367
|
+
console.log(` Transport: ${chalk.cyan(this.config.transport)}`);
|
|
368
|
+
console.log(` Uptime: ${Math.round((Date.now() - this.startTime) / 1000)}s`);
|
|
369
|
+
console.log(` Requests: ${this.requestCount}`);
|
|
370
|
+
console.log(` Sessions: ${this.sessions.size}`);
|
|
371
|
+
console.log(` Connections: ${this.connections.size}`);
|
|
372
|
+
}
|
|
373
|
+
console.log(` Auth: ${this.config.requireAuth ? chalk.green('enabled') : chalk.yellow('disabled')}`);
|
|
374
|
+
console.log(` Trace: ${this.config.trace ? chalk.green('enabled') : chalk.gray('disabled')}`);
|
|
375
|
+
console.log('');
|
|
376
|
+
}
|
|
377
|
+
// ----------------------------------------------------------
|
|
378
|
+
// JSON-RPC Protocol Handling
|
|
379
|
+
// ----------------------------------------------------------
|
|
380
|
+
/**
|
|
381
|
+
* Handle an incoming JSON-RPC request
|
|
382
|
+
*/
|
|
383
|
+
async handleRequest(request, sessionId) {
|
|
384
|
+
this.requestCount++;
|
|
385
|
+
// Validate JSON-RPC version
|
|
386
|
+
if (request.jsonrpc !== '2.0') {
|
|
387
|
+
return this.errorResponse(request.id, JSON_RPC_ERRORS.INVALID_REQUEST, 'Invalid JSON-RPC version. Must be "2.0".');
|
|
388
|
+
}
|
|
389
|
+
// Check session initialization (except for initialize method)
|
|
390
|
+
const session = sessionId ? this.sessions.get(sessionId) : undefined;
|
|
391
|
+
if (session && !session.initialized && request.method !== 'initialize') {
|
|
392
|
+
return this.errorResponse(request.id, JSON_RPC_ERRORS.SERVER_NOT_INITIALIZED, 'Server not initialized. Send "initialize" request first.');
|
|
393
|
+
}
|
|
394
|
+
// Update session activity
|
|
395
|
+
if (session) {
|
|
396
|
+
session.lastActivity = Date.now();
|
|
397
|
+
}
|
|
398
|
+
// Route to method handler
|
|
399
|
+
try {
|
|
400
|
+
const result = await this.routeMethod(request.method, request.params, sessionId);
|
|
401
|
+
return this.successResponse(request.id, result);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
405
|
+
const code = error.code || JSON_RPC_ERRORS.INTERNAL_ERROR;
|
|
406
|
+
return this.errorResponse(request.id, code, message);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Handle an incoming JSON-RPC notification
|
|
411
|
+
*/
|
|
412
|
+
handleNotification(notification, sessionId) {
|
|
413
|
+
if (this.config.trace) {
|
|
414
|
+
this.log(chalk.gray(` <- notification: ${notification.method}`));
|
|
415
|
+
}
|
|
416
|
+
switch (notification.method) {
|
|
417
|
+
case 'initialized':
|
|
418
|
+
// Client confirms initialization - mark session as fully ready
|
|
419
|
+
if (sessionId) {
|
|
420
|
+
const session = this.sessions.get(sessionId);
|
|
421
|
+
if (session) {
|
|
422
|
+
session.initialized = true;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
case 'exit':
|
|
427
|
+
// Client requests shutdown
|
|
428
|
+
if (sessionId) {
|
|
429
|
+
this.removeSession(sessionId);
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
case 'textDocument/didChange':
|
|
433
|
+
// Client notifies us about a file change - could be used for context invalidation
|
|
434
|
+
if (this.config.trace) {
|
|
435
|
+
this.log(chalk.gray(` File changed: ${JSON.stringify(notification.params)}`));
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
case 'cancelRequest':
|
|
439
|
+
// Client wants to cancel a pending request
|
|
440
|
+
if (notification.params && typeof notification.params === 'object') {
|
|
441
|
+
const opId = notification.params.id;
|
|
442
|
+
if (opId) {
|
|
443
|
+
this.cancelOperation(opId);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
default:
|
|
448
|
+
if (this.config.trace) {
|
|
449
|
+
this.log(chalk.gray(` Unknown notification: ${notification.method}`));
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Parse a raw JSON-RPC message string and route appropriately
|
|
456
|
+
*/
|
|
457
|
+
async handleMessage(raw, sessionId) {
|
|
458
|
+
let parsed;
|
|
459
|
+
try {
|
|
460
|
+
parsed = JSON.parse(raw);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return this.errorResponse(null, JSON_RPC_ERRORS.PARSE_ERROR, 'Parse error: invalid JSON.');
|
|
464
|
+
}
|
|
465
|
+
// Batch request support
|
|
466
|
+
if (Array.isArray(parsed)) {
|
|
467
|
+
if (parsed.length === 0) {
|
|
468
|
+
return this.errorResponse(null, JSON_RPC_ERRORS.INVALID_REQUEST, 'Empty batch request.');
|
|
469
|
+
}
|
|
470
|
+
const results = await Promise.all(parsed.map((item) => this.handleMessage(JSON.stringify(item), sessionId)));
|
|
471
|
+
// Filter out nulls (notifications don't get responses)
|
|
472
|
+
const responses = results.filter((r) => r !== null);
|
|
473
|
+
return responses.length === 1 ? responses[0] : responses;
|
|
474
|
+
}
|
|
475
|
+
// Validate it's an object
|
|
476
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
477
|
+
return this.errorResponse(null, JSON_RPC_ERRORS.INVALID_REQUEST, 'Invalid request: not an object.');
|
|
478
|
+
}
|
|
479
|
+
const message = parsed;
|
|
480
|
+
// Is it a notification (no id) or a request (has id)?
|
|
481
|
+
if ('method' in message && !('id' in message)) {
|
|
482
|
+
// It's a notification
|
|
483
|
+
this.handleNotification(message, sessionId);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
// It's a request
|
|
487
|
+
return this.handleRequest(message, sessionId);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Send a notification to all connected clients
|
|
491
|
+
*/
|
|
492
|
+
notifyClients(method, params) {
|
|
493
|
+
const notification = {
|
|
494
|
+
jsonrpc: '2.0',
|
|
495
|
+
method,
|
|
496
|
+
params,
|
|
497
|
+
};
|
|
498
|
+
const message = JSON.stringify(notification);
|
|
499
|
+
for (const [id, connection] of this.connections) {
|
|
500
|
+
try {
|
|
501
|
+
connection.send(message);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Connection may have been closed
|
|
505
|
+
this.removeConnection(id);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (this.config.trace) {
|
|
509
|
+
this.log(chalk.gray(` -> broadcast: ${method}`));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Send a notification to a specific client by session ID
|
|
514
|
+
*/
|
|
515
|
+
notifyClient(sessionId, method, params) {
|
|
516
|
+
const notification = {
|
|
517
|
+
jsonrpc: '2.0',
|
|
518
|
+
method,
|
|
519
|
+
params,
|
|
520
|
+
};
|
|
521
|
+
const message = JSON.stringify(notification);
|
|
522
|
+
const connection = this.connections.get(sessionId);
|
|
523
|
+
if (connection) {
|
|
524
|
+
try {
|
|
525
|
+
connection.send(message);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
this.removeConnection(sessionId);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (this.config.trace) {
|
|
532
|
+
this.log(chalk.gray(` -> notify ${sessionId}: ${method}`));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// ----------------------------------------------------------
|
|
536
|
+
// Method Routing
|
|
537
|
+
// ----------------------------------------------------------
|
|
538
|
+
async routeMethod(method, params, sessionId) {
|
|
539
|
+
if (this.config.trace) {
|
|
540
|
+
this.log(chalk.gray(` <- request: ${method}`));
|
|
541
|
+
}
|
|
542
|
+
switch (method) {
|
|
543
|
+
// Lifecycle
|
|
544
|
+
case 'initialize':
|
|
545
|
+
return this.handleInitialize(params, sessionId);
|
|
546
|
+
case 'shutdown':
|
|
547
|
+
return this.handleShutdown();
|
|
548
|
+
// Text Document operations
|
|
549
|
+
case 'textDocument/edit':
|
|
550
|
+
return this.handleTextDocumentEdit(params);
|
|
551
|
+
case 'textDocument/read':
|
|
552
|
+
return this.handleTextDocumentRead(params);
|
|
553
|
+
// Workspace operations
|
|
554
|
+
case 'workspace/search':
|
|
555
|
+
return this.handleWorkspaceSearch(params);
|
|
556
|
+
case 'workspace/diagnostics':
|
|
557
|
+
return this.handleWorkspaceDiagnostics(params);
|
|
558
|
+
// Agent operations
|
|
559
|
+
case 'agent/prompt':
|
|
560
|
+
return this.handleAgentPrompt(params, sessionId);
|
|
561
|
+
case 'agent/cancel':
|
|
562
|
+
return this.handleAgentCancel(params);
|
|
563
|
+
case 'agent/status':
|
|
564
|
+
return this.handleAgentStatus(params);
|
|
565
|
+
case 'agent/tools':
|
|
566
|
+
return this.handleAgentTools(params);
|
|
567
|
+
// Git operations
|
|
568
|
+
case 'git/status':
|
|
569
|
+
return this.handleGitStatus(params);
|
|
570
|
+
default:
|
|
571
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: JSON_RPC_ERRORS.METHOD_NOT_FOUND });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// ----------------------------------------------------------
|
|
575
|
+
// Method Handlers - Lifecycle
|
|
576
|
+
// ----------------------------------------------------------
|
|
577
|
+
handleInitialize(params, sessionId) {
|
|
578
|
+
const initParams = (params || {});
|
|
579
|
+
const clientCapabilities = (initParams.capabilities || {});
|
|
580
|
+
const clientInfo = (initParams.clientInfo || {});
|
|
581
|
+
// Create or update session
|
|
582
|
+
const sid = sessionId || this.createSessionId();
|
|
583
|
+
let session = this.sessions.get(sid);
|
|
584
|
+
if (!session) {
|
|
585
|
+
session = {
|
|
586
|
+
id: sid,
|
|
587
|
+
clientCapabilities,
|
|
588
|
+
initialized: true,
|
|
589
|
+
createdAt: Date.now(),
|
|
590
|
+
lastActivity: Date.now(),
|
|
591
|
+
transport: this.detectTransportFromSession(sid),
|
|
592
|
+
activeOperationId: null,
|
|
593
|
+
abortController: null,
|
|
594
|
+
};
|
|
595
|
+
this.sessions.set(sid, session);
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
session.clientCapabilities = clientCapabilities;
|
|
599
|
+
session.initialized = true;
|
|
600
|
+
session.lastActivity = Date.now();
|
|
601
|
+
}
|
|
602
|
+
this.log(chalk.green(`Client initialized: ${clientInfo.name || 'unknown'} v${clientInfo.version || '?'}`));
|
|
603
|
+
return {
|
|
604
|
+
capabilities: this.serverCapabilities,
|
|
605
|
+
serverInfo: this.serverInfo,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
handleShutdown() {
|
|
609
|
+
// Prepare for shutdown - cleanup but don't actually stop
|
|
610
|
+
return { success: true };
|
|
611
|
+
}
|
|
612
|
+
// ----------------------------------------------------------
|
|
613
|
+
// Method Handlers - Text Document Operations
|
|
614
|
+
// ----------------------------------------------------------
|
|
615
|
+
handleTextDocumentEdit(params) {
|
|
616
|
+
const editParams = params;
|
|
617
|
+
if (!editParams || !editParams.uri) {
|
|
618
|
+
throw Object.assign(new Error('Invalid params: uri is required'), { code: JSON_RPC_ERRORS.INVALID_PARAMS });
|
|
619
|
+
}
|
|
620
|
+
const filePath = this.uriToPath(editParams.uri);
|
|
621
|
+
// Read current file content
|
|
622
|
+
let content;
|
|
623
|
+
try {
|
|
624
|
+
content = readFileSync(filePath, 'utf-8');
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
// If file doesn't exist and we have edits with empty ranges, create it
|
|
628
|
+
if (editParams.edits && editParams.edits.length > 0) {
|
|
629
|
+
const hasCreateEdit = editParams.edits.some((e) => e.range.start.line === 0 &&
|
|
630
|
+
e.range.start.character === 0 &&
|
|
631
|
+
e.range.end.line === 0 &&
|
|
632
|
+
e.range.end.character === 0);
|
|
633
|
+
if (hasCreateEdit) {
|
|
634
|
+
content = '';
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
throw Object.assign(new Error(`File not found: ${filePath}`), { code: JSON_RPC_ERRORS.INTERNAL_ERROR });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
throw Object.assign(new Error(`File not found: ${filePath}`), { code: JSON_RPC_ERRORS.INTERNAL_ERROR });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Apply edits in reverse order (so line numbers don't shift)
|
|
645
|
+
const lines = content.split('\n');
|
|
646
|
+
const sortedEdits = [...(editParams.edits || [])].sort((a, b) => {
|
|
647
|
+
if (b.range.start.line !== a.range.start.line) {
|
|
648
|
+
return b.range.start.line - a.range.start.line;
|
|
649
|
+
}
|
|
650
|
+
return b.range.start.character - a.range.start.character;
|
|
651
|
+
});
|
|
652
|
+
for (const edit of sortedEdits) {
|
|
653
|
+
const startLine = edit.range.start.line;
|
|
654
|
+
const endLine = edit.range.end.line;
|
|
655
|
+
const startChar = edit.range.start.character;
|
|
656
|
+
const endChar = edit.range.end.character;
|
|
657
|
+
// Build the new content for the affected range
|
|
658
|
+
const beforeLine = (lines[startLine] || '').slice(0, startChar);
|
|
659
|
+
const afterLine = (lines[endLine] || '').slice(endChar);
|
|
660
|
+
const newLines = edit.newText.split('\n');
|
|
661
|
+
// Replace the affected lines
|
|
662
|
+
const replacement = [beforeLine + newLines[0]];
|
|
663
|
+
for (let i = 1; i < newLines.length - 1; i++) {
|
|
664
|
+
replacement.push(newLines[i]);
|
|
665
|
+
}
|
|
666
|
+
if (newLines.length > 1) {
|
|
667
|
+
replacement.push(newLines[newLines.length - 1] + afterLine);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
replacement[0] = beforeLine + newLines[0] + afterLine;
|
|
671
|
+
}
|
|
672
|
+
lines.splice(startLine, endLine - startLine + 1, ...replacement);
|
|
673
|
+
}
|
|
674
|
+
// Write the modified content back
|
|
675
|
+
const newContent = lines.join('\n');
|
|
676
|
+
try {
|
|
677
|
+
writeFileSync(filePath, newContent, 'utf-8');
|
|
678
|
+
}
|
|
679
|
+
catch (writeError) {
|
|
680
|
+
throw Object.assign(new Error(`Failed to write file: ${writeError instanceof Error ? writeError.message : String(writeError)}`), { code: JSON_RPC_ERRORS.INTERNAL_ERROR });
|
|
681
|
+
}
|
|
682
|
+
// Notify clients about the file change
|
|
683
|
+
this.notifyClients('workspace/fileChanged', {
|
|
684
|
+
uri: editParams.uri,
|
|
685
|
+
changeType: 'modified',
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
applied: true,
|
|
689
|
+
editCount: editParams.edits?.length || 0,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
handleTextDocumentRead(params) {
|
|
693
|
+
const readParams = params;
|
|
694
|
+
if (!readParams || !readParams.uri) {
|
|
695
|
+
throw Object.assign(new Error('Invalid params: uri is required'), { code: JSON_RPC_ERRORS.INVALID_PARAMS });
|
|
696
|
+
}
|
|
697
|
+
const filePath = this.uriToPath(readParams.uri);
|
|
698
|
+
try {
|
|
699
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
700
|
+
const stat = statSync(filePath);
|
|
701
|
+
return {
|
|
702
|
+
uri: readParams.uri,
|
|
703
|
+
content,
|
|
704
|
+
languageId: this.languageIdFromPath(filePath),
|
|
705
|
+
version: stat.mtimeMs,
|
|
706
|
+
size: stat.size,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
throw Object.assign(new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`), { code: JSON_RPC_ERRORS.INTERNAL_ERROR });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// ----------------------------------------------------------
|
|
714
|
+
// Method Handlers - Workspace Operations
|
|
715
|
+
// ----------------------------------------------------------
|
|
716
|
+
handleWorkspaceSearch(params) {
|
|
717
|
+
const searchParams = params;
|
|
718
|
+
if (!searchParams || !searchParams.query) {
|
|
719
|
+
throw Object.assign(new Error('Invalid params: query is required'), { code: JSON_RPC_ERRORS.INVALID_PARAMS });
|
|
720
|
+
}
|
|
721
|
+
const workingDir = this.engine?.config ? process.cwd() : process.cwd();
|
|
722
|
+
const maxResults = searchParams.maxResults || 50;
|
|
723
|
+
const results = [];
|
|
724
|
+
try {
|
|
725
|
+
const searchPattern = searchParams.query;
|
|
726
|
+
const includeGlob = searchParams.include || '';
|
|
727
|
+
const excludeGlob = searchParams.exclude || 'node_modules,.git,dist,build';
|
|
728
|
+
// Use ripgrep if available, otherwise fallback to grep
|
|
729
|
+
let grepOutput;
|
|
730
|
+
try {
|
|
731
|
+
const cmdParts = ['rg', '--json'];
|
|
732
|
+
if (includeGlob)
|
|
733
|
+
cmdParts.push('--glob', includeGlob);
|
|
734
|
+
if (excludeGlob) {
|
|
735
|
+
for (const ex of excludeGlob.split(',')) {
|
|
736
|
+
cmdParts.push('--glob', `!${ex.trim()}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
cmdParts.push('--max-count', String(maxResults), searchPattern, workingDir);
|
|
740
|
+
grepOutput = execSync(cmdParts.join(' '), {
|
|
741
|
+
encoding: 'utf-8',
|
|
742
|
+
timeout: 10000,
|
|
743
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
catch (rgError) {
|
|
747
|
+
// rg returns exit code 1 for no matches, or may not be installed
|
|
748
|
+
if (rgError.stdout) {
|
|
749
|
+
grepOutput = rgError.stdout;
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
// Fallback to grep
|
|
753
|
+
try {
|
|
754
|
+
grepOutput = execSync(`grep -rn "${searchPattern}" ${workingDir} --include="*.ts" --include="*.js" --include="*.py" --include="*.rs" --include="*.go" | head -${maxResults}`, { encoding: 'utf-8', timeout: 10000, maxBuffer: 5 * 1024 * 1024 });
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return { results: [], count: 0 };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Parse ripgrep JSON output
|
|
762
|
+
for (const line of grepOutput.split('\n')) {
|
|
763
|
+
if (!line.trim())
|
|
764
|
+
continue;
|
|
765
|
+
try {
|
|
766
|
+
const entry = JSON.parse(line);
|
|
767
|
+
if (entry.type === 'match' && entry.data) {
|
|
768
|
+
results.push({
|
|
769
|
+
uri: `file://${entry.data.path?.text || ''}`,
|
|
770
|
+
line: entry.data.line_number || 0,
|
|
771
|
+
column: entry.data.submatches?.[0]?.start || 0,
|
|
772
|
+
text: entry.data.lines?.text?.trim() || '',
|
|
773
|
+
match: entry.data.submatches?.[0]?.match?.text || searchPattern,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
// Not JSON - try plain grep format: file:line:content
|
|
779
|
+
const match = line.match(/^(.+?):(\d+):(.*)$/);
|
|
780
|
+
if (match) {
|
|
781
|
+
results.push({
|
|
782
|
+
uri: `file://${match[1]}`,
|
|
783
|
+
line: parseInt(match[2], 10),
|
|
784
|
+
column: 0,
|
|
785
|
+
text: match[3].trim(),
|
|
786
|
+
match: searchPattern,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (results.length >= maxResults)
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
throw Object.assign(new Error(`Search failed: ${error instanceof Error ? error.message : String(error)}`), { code: JSON_RPC_ERRORS.INTERNAL_ERROR });
|
|
796
|
+
}
|
|
797
|
+
return { results, count: results.length };
|
|
798
|
+
}
|
|
799
|
+
handleWorkspaceDiagnostics(params) {
|
|
800
|
+
const diagParams = (params || {});
|
|
801
|
+
// Try to get diagnostics from the LSP integration if available
|
|
802
|
+
const diagnostics = [];
|
|
803
|
+
if (this.engine?.linting) {
|
|
804
|
+
try {
|
|
805
|
+
const filePath = diagParams.uri ? this.uriToPath(diagParams.uri) : undefined;
|
|
806
|
+
const results = filePath
|
|
807
|
+
? this.engine.linting.lintFile(filePath)
|
|
808
|
+
: this.engine.linting.lintAll();
|
|
809
|
+
for (const diag of results) {
|
|
810
|
+
diagnostics.push({
|
|
811
|
+
uri: `file://${diag.file || filePath || ''}`,
|
|
812
|
+
severity: diag.severity || 'info',
|
|
813
|
+
message: diag.message || '',
|
|
814
|
+
line: diag.line || 0,
|
|
815
|
+
column: diag.column || 0,
|
|
816
|
+
source: diag.source,
|
|
817
|
+
code: diag.code,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
// Linting may fail, return empty diagnostics
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Also check for security issues if scanner is available
|
|
826
|
+
if (this.engine?.securityScanner && !diagParams.uri) {
|
|
827
|
+
try {
|
|
828
|
+
const scanResults = this.engine.securityScanner.scan();
|
|
829
|
+
for (const issue of scanResults) {
|
|
830
|
+
diagnostics.push({
|
|
831
|
+
uri: `file://${issue.file || ''}`,
|
|
832
|
+
severity: issue.severity || 'warning',
|
|
833
|
+
message: issue.message || issue.description || '',
|
|
834
|
+
line: issue.line || 0,
|
|
835
|
+
column: 0,
|
|
836
|
+
source: 'security-scanner',
|
|
837
|
+
code: issue.ruleId || issue.id,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// Security scanning may fail
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { diagnostics, count: diagnostics.length };
|
|
846
|
+
}
|
|
847
|
+
// ----------------------------------------------------------
|
|
848
|
+
// Method Handlers - Agent Operations
|
|
849
|
+
// ----------------------------------------------------------
|
|
850
|
+
async handleAgentPrompt(params, sessionId) {
|
|
851
|
+
const promptParams = params;
|
|
852
|
+
if (!promptParams || !promptParams.prompt) {
|
|
853
|
+
throw Object.assign(new Error('Invalid params: prompt is required'), { code: JSON_RPC_ERRORS.INVALID_PARAMS });
|
|
854
|
+
}
|
|
855
|
+
const operationId = `op_${++this.operationCounter}`;
|
|
856
|
+
const session = sessionId ? this.sessions.get(sessionId) : undefined;
|
|
857
|
+
// Create abort controller for cancellation support
|
|
858
|
+
const abortController = new AbortController();
|
|
859
|
+
if (session) {
|
|
860
|
+
session.activeOperationId = operationId;
|
|
861
|
+
session.abortController = abortController;
|
|
862
|
+
}
|
|
863
|
+
const shouldStream = promptParams.stream !== false &&
|
|
864
|
+
(session?.clientCapabilities.streaming !== false);
|
|
865
|
+
try {
|
|
866
|
+
// Use the engine to process the message
|
|
867
|
+
if (!this.engine || typeof this.engine.processMessage !== 'function') {
|
|
868
|
+
throw new Error('Engine not available');
|
|
869
|
+
}
|
|
870
|
+
// If streaming is requested, set up streaming callbacks
|
|
871
|
+
if (shouldStream) {
|
|
872
|
+
// We need to intercept the engine's streaming to send tokens via ACP notifications
|
|
873
|
+
// The engine processes via processMessage which returns a complete result.
|
|
874
|
+
// For streaming, we hook into the engine's UI callbacks or use a streaming approach.
|
|
875
|
+
// Start the process and send tokens as they arrive
|
|
876
|
+
const processPromise = this.engine.processMessage(promptParams.prompt, promptParams.mode || 'auto', promptParams.agent);
|
|
877
|
+
// Check for cancellation periodically
|
|
878
|
+
const checkInterval = setInterval(() => {
|
|
879
|
+
if (abortController.signal.aborted) {
|
|
880
|
+
clearInterval(checkInterval);
|
|
881
|
+
}
|
|
882
|
+
}, 500);
|
|
883
|
+
const result = await processPromise;
|
|
884
|
+
clearInterval(checkInterval);
|
|
885
|
+
// Check if cancelled during execution
|
|
886
|
+
if (abortController.signal.aborted) {
|
|
887
|
+
this.notifyClient(sessionId || '', 'agent/completed', {
|
|
888
|
+
operationId,
|
|
889
|
+
content: '',
|
|
890
|
+
cancelled: true,
|
|
891
|
+
});
|
|
892
|
+
return { operationId, cancelled: true, content: '' };
|
|
893
|
+
}
|
|
894
|
+
// Send completion notification
|
|
895
|
+
this.notifyClient(sessionId || '', 'agent/completed', {
|
|
896
|
+
operationId,
|
|
897
|
+
content: result.content,
|
|
898
|
+
usage: result.usage,
|
|
899
|
+
});
|
|
900
|
+
// Clean up session state
|
|
901
|
+
if (session) {
|
|
902
|
+
session.activeOperationId = null;
|
|
903
|
+
session.abortController = null;
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
operationId,
|
|
907
|
+
content: result.content,
|
|
908
|
+
usage: result.usage,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
// Non-streaming: just return the full result
|
|
913
|
+
const result = await this.engine.processMessage(promptParams.prompt, promptParams.mode || 'auto', promptParams.agent);
|
|
914
|
+
// Clean up session state
|
|
915
|
+
if (session) {
|
|
916
|
+
session.activeOperationId = null;
|
|
917
|
+
session.abortController = null;
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
operationId,
|
|
921
|
+
content: result.content,
|
|
922
|
+
usage: result.usage,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
// Send error notification
|
|
928
|
+
this.notifyClient(sessionId || '', 'agent/error', {
|
|
929
|
+
operationId,
|
|
930
|
+
message: error instanceof Error ? error.message : String(error),
|
|
931
|
+
});
|
|
932
|
+
// Clean up session state
|
|
933
|
+
if (session) {
|
|
934
|
+
session.activeOperationId = null;
|
|
935
|
+
session.abortController = null;
|
|
936
|
+
}
|
|
937
|
+
throw error;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
handleAgentCancel(params) {
|
|
941
|
+
const cancelParams = params;
|
|
942
|
+
if (!cancelParams || !cancelParams.operationId) {
|
|
943
|
+
throw Object.assign(new Error('Invalid params: operationId is required'), { code: JSON_RPC_ERRORS.INVALID_PARAMS });
|
|
944
|
+
}
|
|
945
|
+
const cancelled = this.cancelOperation(cancelParams.operationId);
|
|
946
|
+
return { cancelled, operationId: cancelParams.operationId };
|
|
947
|
+
}
|
|
948
|
+
handleAgentStatus(params) {
|
|
949
|
+
const statusParams = (params || {});
|
|
950
|
+
// Find the session and check its status
|
|
951
|
+
const session = statusParams.sessionId
|
|
952
|
+
? this.sessions.get(statusParams.sessionId)
|
|
953
|
+
: this.findSessionByOperation();
|
|
954
|
+
if (!session) {
|
|
955
|
+
return {
|
|
956
|
+
status: 'idle',
|
|
957
|
+
activeOperation: null,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
status: session.activeOperationId ? 'processing' : 'idle',
|
|
962
|
+
activeOperation: session.activeOperationId,
|
|
963
|
+
sessionId: session.id,
|
|
964
|
+
transport: session.transport,
|
|
965
|
+
uptime: Date.now() - session.createdAt,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
handleAgentTools(params) {
|
|
969
|
+
const toolsParams = (params || {});
|
|
970
|
+
const tools = [];
|
|
971
|
+
// Get tools from engine's registry
|
|
972
|
+
if (this.engine?.registry) {
|
|
973
|
+
try {
|
|
974
|
+
const registry = this.engine.registry;
|
|
975
|
+
if (typeof registry.list === 'function') {
|
|
976
|
+
const registeredTools = registry.list();
|
|
977
|
+
for (const tool of registeredTools) {
|
|
978
|
+
if (toolsParams.category && tool.category !== toolsParams.category) {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
tools.push({
|
|
982
|
+
name: tool.name,
|
|
983
|
+
description: tool.description || '',
|
|
984
|
+
category: tool.category || 'general',
|
|
985
|
+
risk: tool.risk || 'medium',
|
|
986
|
+
parameters: tool.parameters,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// Registry access may fail
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// Also include MCP tools if available
|
|
996
|
+
if (this.engine?.mcpClient) {
|
|
997
|
+
try {
|
|
998
|
+
const mcpTools = this.engine.mcpClient.listTools();
|
|
999
|
+
for (const tool of mcpTools) {
|
|
1000
|
+
tools.push({
|
|
1001
|
+
name: `mcp_${tool.name}`,
|
|
1002
|
+
description: tool.description || '',
|
|
1003
|
+
category: 'mcp',
|
|
1004
|
+
risk: 'medium',
|
|
1005
|
+
parameters: tool.parameters,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
// MCP tools listing may fail
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Also include custom tools if available
|
|
1014
|
+
if (this.engine?.customToolLoader) {
|
|
1015
|
+
try {
|
|
1016
|
+
const customTools = this.engine.customToolLoader.getAll();
|
|
1017
|
+
for (const tool of customTools) {
|
|
1018
|
+
tools.push({
|
|
1019
|
+
name: `custom_${tool.name}`,
|
|
1020
|
+
description: tool.description || '',
|
|
1021
|
+
category: 'custom',
|
|
1022
|
+
risk: tool.risk || 'medium',
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Custom tools listing may fail
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return { tools, count: tools.length };
|
|
1031
|
+
}
|
|
1032
|
+
// ----------------------------------------------------------
|
|
1033
|
+
// Method Handlers - Git Operations
|
|
1034
|
+
// ----------------------------------------------------------
|
|
1035
|
+
handleGitStatus(params) {
|
|
1036
|
+
const gitParams = (params || {});
|
|
1037
|
+
const workingDir = gitParams.workingDirectory || process.cwd();
|
|
1038
|
+
try {
|
|
1039
|
+
const statusOutput = execSync('git status --porcelain=v2 --branch', {
|
|
1040
|
+
encoding: 'utf-8',
|
|
1041
|
+
cwd: workingDir,
|
|
1042
|
+
timeout: 5000,
|
|
1043
|
+
});
|
|
1044
|
+
const branchMatch = statusOutput.match(/^# branch\.head\s+(.+)$/m);
|
|
1045
|
+
const branch = branchMatch ? branchMatch[1] : 'unknown';
|
|
1046
|
+
const files = [];
|
|
1047
|
+
for (const line of statusOutput.split('\n')) {
|
|
1048
|
+
if (line.startsWith('1 ') || line.startsWith('2 ') || line.startsWith('u ')) {
|
|
1049
|
+
const parts = line.split(' ');
|
|
1050
|
+
if (parts.length >= 9) {
|
|
1051
|
+
const xy = parts[1];
|
|
1052
|
+
const filePath = parts.slice(8).join(' ');
|
|
1053
|
+
const staged = xy[0] !== '.' && xy[0] !== '?';
|
|
1054
|
+
const statusChar = staged ? xy[0] : xy[1];
|
|
1055
|
+
const statusMap = {
|
|
1056
|
+
'M': 'modified',
|
|
1057
|
+
'A': 'added',
|
|
1058
|
+
'D': 'deleted',
|
|
1059
|
+
'R': 'renamed',
|
|
1060
|
+
'C': 'copied',
|
|
1061
|
+
'?': 'untracked',
|
|
1062
|
+
'!': 'ignored',
|
|
1063
|
+
};
|
|
1064
|
+
files.push({
|
|
1065
|
+
path: filePath,
|
|
1066
|
+
status: statusMap[statusChar] || statusChar,
|
|
1067
|
+
staged,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
else if (line.startsWith('? ')) {
|
|
1072
|
+
files.push({
|
|
1073
|
+
path: line.slice(2),
|
|
1074
|
+
status: 'untracked',
|
|
1075
|
+
staged: false,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return { branch, files, count: files.length };
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
// Not a git repo or git not available
|
|
1083
|
+
return {
|
|
1084
|
+
branch: null,
|
|
1085
|
+
files: [],
|
|
1086
|
+
count: 0,
|
|
1087
|
+
error: 'Not a git repository or git is not available',
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
// ----------------------------------------------------------
|
|
1092
|
+
// Transport Start Methods
|
|
1093
|
+
// ----------------------------------------------------------
|
|
1094
|
+
async startStdio() {
|
|
1095
|
+
this.stdioTransport = new StdioTransport(async (message) => {
|
|
1096
|
+
const response = await this.handleMessage(message, 'stdio');
|
|
1097
|
+
if (response) {
|
|
1098
|
+
this.stdioTransport.send(JSON.stringify(response));
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
// Register stdio as a connection
|
|
1102
|
+
const stdioSessionId = 'stdio';
|
|
1103
|
+
this.sessions.set(stdioSessionId, {
|
|
1104
|
+
id: stdioSessionId,
|
|
1105
|
+
clientCapabilities: {},
|
|
1106
|
+
initialized: false,
|
|
1107
|
+
createdAt: Date.now(),
|
|
1108
|
+
lastActivity: Date.now(),
|
|
1109
|
+
transport: 'stdio',
|
|
1110
|
+
activeOperationId: null,
|
|
1111
|
+
abortController: null,
|
|
1112
|
+
});
|
|
1113
|
+
this.connections.set(stdioSessionId, {
|
|
1114
|
+
id: stdioSessionId,
|
|
1115
|
+
sessionId: stdioSessionId,
|
|
1116
|
+
transport: 'stdio',
|
|
1117
|
+
send: (msg) => {
|
|
1118
|
+
this.stdioTransport?.send(msg);
|
|
1119
|
+
},
|
|
1120
|
+
close: () => {
|
|
1121
|
+
this.stdioTransport?.close();
|
|
1122
|
+
},
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
async startHttpServer() {
|
|
1126
|
+
return new Promise((resolve, reject) => {
|
|
1127
|
+
this.httpServer = createServer(async (req, res) => {
|
|
1128
|
+
await this.handleHttpRequest(req, res);
|
|
1129
|
+
});
|
|
1130
|
+
// Set up WebSocket/line-delimited TCP transport
|
|
1131
|
+
this.wsTransport = new WebSocketTransport(this.config.host, this.config.port + 1, // WS on next port to avoid conflict with HTTP
|
|
1132
|
+
this.config.corsOrigin, (connectionId, send) => {
|
|
1133
|
+
this.handleWsConnect(connectionId, send);
|
|
1134
|
+
}, (connectionId) => {
|
|
1135
|
+
this.handleWsDisconnect(connectionId);
|
|
1136
|
+
}, async (connectionId, message) => {
|
|
1137
|
+
await this.handleWsMessage(connectionId, message);
|
|
1138
|
+
});
|
|
1139
|
+
this.httpServer.on('error', (err) => {
|
|
1140
|
+
this.log(chalk.red(`ACP HTTP server error: ${err.message}`));
|
|
1141
|
+
reject(err);
|
|
1142
|
+
});
|
|
1143
|
+
this.httpServer.listen(this.config.port, this.config.host, async () => {
|
|
1144
|
+
this.log(chalk.gray(` ACP HTTP server on http://${this.config.host}:${this.config.port}`));
|
|
1145
|
+
// Start WebSocket transport
|
|
1146
|
+
try {
|
|
1147
|
+
await this.wsTransport.start();
|
|
1148
|
+
this.log(chalk.gray(` ACP WS server on ws://${this.config.host}:${this.config.port + 1}/acp`));
|
|
1149
|
+
}
|
|
1150
|
+
catch (wsErr) {
|
|
1151
|
+
this.log(chalk.yellow(` ACP WS server failed to start: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`));
|
|
1152
|
+
}
|
|
1153
|
+
resolve();
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
// ----------------------------------------------------------
|
|
1158
|
+
// HTTP Request Handling
|
|
1159
|
+
// ----------------------------------------------------------
|
|
1160
|
+
async handleHttpRequest(req, res) {
|
|
1161
|
+
// CORS headers
|
|
1162
|
+
res.setHeader('Access-Control-Allow-Origin', this.config.corsOrigin);
|
|
1163
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
1164
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
|
1165
|
+
if (req.method === 'OPTIONS') {
|
|
1166
|
+
res.writeHead(204);
|
|
1167
|
+
res.end();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (req.method !== 'POST') {
|
|
1171
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
1172
|
+
res.end(JSON.stringify({ error: 'Method not allowed. Use POST for JSON-RPC.' }));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (req.url !== '/acp') {
|
|
1176
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1177
|
+
res.end(JSON.stringify({ error: 'Not found. Use /acp endpoint.' }));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
// Authentication
|
|
1181
|
+
if (this.config.requireAuth) {
|
|
1182
|
+
const apiKey = req.headers['x-api-key'] ||
|
|
1183
|
+
(req.headers['authorization'] || '').replace('Bearer ', '');
|
|
1184
|
+
if (apiKey !== this.config.apiKey) {
|
|
1185
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1186
|
+
res.end(JSON.stringify({
|
|
1187
|
+
jsonrpc: '2.0',
|
|
1188
|
+
id: null,
|
|
1189
|
+
error: { code: -32001, message: 'Invalid API key' },
|
|
1190
|
+
}));
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
// Parse body
|
|
1195
|
+
const body = await this.parseHttpBody(req);
|
|
1196
|
+
if (!body) {
|
|
1197
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1198
|
+
res.end(JSON.stringify({
|
|
1199
|
+
jsonrpc: '2.0',
|
|
1200
|
+
id: null,
|
|
1201
|
+
error: { code: JSON_RPC_ERRORS.PARSE_ERROR, message: 'Empty or invalid request body' },
|
|
1202
|
+
}));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
// For HTTP, we create a transient session per request or use an existing one
|
|
1206
|
+
const sessionId = this.resolveHttpSession(req);
|
|
1207
|
+
const response = await this.handleMessage(body, sessionId);
|
|
1208
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1209
|
+
if (response) {
|
|
1210
|
+
res.end(JSON.stringify(response));
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
// Notification - no response
|
|
1214
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', result: null, id: null }));
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
async parseHttpBody(req) {
|
|
1218
|
+
return new Promise((resolve) => {
|
|
1219
|
+
const chunks = [];
|
|
1220
|
+
let size = 0;
|
|
1221
|
+
req.on('data', (chunk) => {
|
|
1222
|
+
size += chunk.length;
|
|
1223
|
+
if (size > this.config.maxBodySize) {
|
|
1224
|
+
resolve(null);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
chunks.push(chunk);
|
|
1228
|
+
});
|
|
1229
|
+
req.on('end', () => {
|
|
1230
|
+
if (chunks.length === 0) {
|
|
1231
|
+
resolve(null);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
1235
|
+
});
|
|
1236
|
+
req.on('error', () => resolve(null));
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
resolveHttpSession(req) {
|
|
1240
|
+
// Try to find an existing session from headers
|
|
1241
|
+
const sessionId = req.headers['x-acp-session'];
|
|
1242
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
1243
|
+
return sessionId;
|
|
1244
|
+
}
|
|
1245
|
+
// Create a new HTTP session
|
|
1246
|
+
const newSessionId = `http_${randomBytes(8).toString('hex')}`;
|
|
1247
|
+
this.sessions.set(newSessionId, {
|
|
1248
|
+
id: newSessionId,
|
|
1249
|
+
clientCapabilities: {},
|
|
1250
|
+
initialized: false,
|
|
1251
|
+
createdAt: Date.now(),
|
|
1252
|
+
lastActivity: Date.now(),
|
|
1253
|
+
transport: 'http',
|
|
1254
|
+
activeOperationId: null,
|
|
1255
|
+
abortController: null,
|
|
1256
|
+
});
|
|
1257
|
+
// Register a connection for this session so we can send notifications
|
|
1258
|
+
// For HTTP, "sending" means the client polls or uses SSE (future)
|
|
1259
|
+
this.connections.set(newSessionId, {
|
|
1260
|
+
id: newSessionId,
|
|
1261
|
+
sessionId: newSessionId,
|
|
1262
|
+
transport: 'http',
|
|
1263
|
+
send: (_msg) => {
|
|
1264
|
+
// HTTP clients can't receive push notifications directly
|
|
1265
|
+
// In a full implementation, you'd use SSE or long-polling
|
|
1266
|
+
},
|
|
1267
|
+
close: () => {
|
|
1268
|
+
this.sessions.delete(newSessionId);
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
return newSessionId;
|
|
1272
|
+
}
|
|
1273
|
+
// ----------------------------------------------------------
|
|
1274
|
+
// WebSocket Connection Handling
|
|
1275
|
+
// ----------------------------------------------------------
|
|
1276
|
+
handleWsConnect(connectionId, send) {
|
|
1277
|
+
// Create session for this WebSocket connection
|
|
1278
|
+
const sessionId = connectionId;
|
|
1279
|
+
this.sessions.set(sessionId, {
|
|
1280
|
+
id: sessionId,
|
|
1281
|
+
clientCapabilities: {},
|
|
1282
|
+
initialized: false,
|
|
1283
|
+
createdAt: Date.now(),
|
|
1284
|
+
lastActivity: Date.now(),
|
|
1285
|
+
transport: 'websocket',
|
|
1286
|
+
activeOperationId: null,
|
|
1287
|
+
abortController: null,
|
|
1288
|
+
});
|
|
1289
|
+
this.connections.set(sessionId, {
|
|
1290
|
+
id: sessionId,
|
|
1291
|
+
sessionId,
|
|
1292
|
+
transport: 'websocket',
|
|
1293
|
+
send,
|
|
1294
|
+
close: () => {
|
|
1295
|
+
this.wsTransport?.close(connectionId);
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
this.log(chalk.gray(` ACP client connected: ${connectionId}`));
|
|
1299
|
+
}
|
|
1300
|
+
handleWsDisconnect(connectionId) {
|
|
1301
|
+
this.sessions.delete(connectionId);
|
|
1302
|
+
this.connections.delete(connectionId);
|
|
1303
|
+
this.log(chalk.gray(` ACP client disconnected: ${connectionId}`));
|
|
1304
|
+
}
|
|
1305
|
+
async handleWsMessage(connectionId, message) {
|
|
1306
|
+
const response = await this.handleMessage(message, connectionId);
|
|
1307
|
+
if (response) {
|
|
1308
|
+
const responseStr = JSON.stringify(response);
|
|
1309
|
+
this.wsTransport?.send(connectionId, responseStr);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
// ----------------------------------------------------------
|
|
1313
|
+
// Session Management
|
|
1314
|
+
// ----------------------------------------------------------
|
|
1315
|
+
createSessionId() {
|
|
1316
|
+
return `sess_${randomBytes(8).toString('hex')}`;
|
|
1317
|
+
}
|
|
1318
|
+
removeSession(sessionId) {
|
|
1319
|
+
const session = this.sessions.get(sessionId);
|
|
1320
|
+
if (session) {
|
|
1321
|
+
// Cancel any ongoing operation
|
|
1322
|
+
if (session.activeOperationId) {
|
|
1323
|
+
this.cancelOperation(session.activeOperationId);
|
|
1324
|
+
}
|
|
1325
|
+
this.sessions.delete(sessionId);
|
|
1326
|
+
}
|
|
1327
|
+
// Also remove the connection
|
|
1328
|
+
this.removeConnection(sessionId);
|
|
1329
|
+
}
|
|
1330
|
+
removeConnection(connectionId) {
|
|
1331
|
+
const connection = this.connections.get(connectionId);
|
|
1332
|
+
if (connection) {
|
|
1333
|
+
try {
|
|
1334
|
+
connection.close();
|
|
1335
|
+
}
|
|
1336
|
+
catch {
|
|
1337
|
+
// Ignore close errors
|
|
1338
|
+
}
|
|
1339
|
+
this.connections.delete(connectionId);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
findSessionByOperation() {
|
|
1343
|
+
for (const session of this.sessions.values()) {
|
|
1344
|
+
if (session.activeOperationId) {
|
|
1345
|
+
return session;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return undefined;
|
|
1349
|
+
}
|
|
1350
|
+
cancelOperation(operationId) {
|
|
1351
|
+
for (const session of this.sessions.values()) {
|
|
1352
|
+
if (session.activeOperationId === operationId) {
|
|
1353
|
+
if (session.abortController) {
|
|
1354
|
+
session.abortController.abort();
|
|
1355
|
+
}
|
|
1356
|
+
session.activeOperationId = null;
|
|
1357
|
+
session.abortController = null;
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
detectTransportFromSession(sessionId) {
|
|
1364
|
+
if (sessionId === 'stdio')
|
|
1365
|
+
return 'stdio';
|
|
1366
|
+
if (sessionId.startsWith('ws_'))
|
|
1367
|
+
return 'websocket';
|
|
1368
|
+
if (sessionId.startsWith('http_'))
|
|
1369
|
+
return 'http';
|
|
1370
|
+
return 'http';
|
|
1371
|
+
}
|
|
1372
|
+
// ----------------------------------------------------------
|
|
1373
|
+
// JSON-RPC Response Helpers
|
|
1374
|
+
// ----------------------------------------------------------
|
|
1375
|
+
successResponse(id, result) {
|
|
1376
|
+
return {
|
|
1377
|
+
jsonrpc: '2.0',
|
|
1378
|
+
id,
|
|
1379
|
+
result,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
errorResponse(id, code, message, data) {
|
|
1383
|
+
return {
|
|
1384
|
+
jsonrpc: '2.0',
|
|
1385
|
+
id,
|
|
1386
|
+
error: { code, message, data },
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
// ----------------------------------------------------------
|
|
1390
|
+
// URI / Path Utilities
|
|
1391
|
+
// ----------------------------------------------------------
|
|
1392
|
+
uriToPath(uri) {
|
|
1393
|
+
if (uri.startsWith('file://')) {
|
|
1394
|
+
// Handle file:///path (Unix) or file:///C:/path (Windows)
|
|
1395
|
+
let path = uri.slice(7);
|
|
1396
|
+
// Windows: file:///C:/path -> C:/path
|
|
1397
|
+
if (path.length >= 2 && path[0] === '/' && path[2] === ':') {
|
|
1398
|
+
path = path.slice(1);
|
|
1399
|
+
}
|
|
1400
|
+
return decodeURIComponent(path);
|
|
1401
|
+
}
|
|
1402
|
+
return uri;
|
|
1403
|
+
}
|
|
1404
|
+
pathToUri(filePath) {
|
|
1405
|
+
return `file://${encodeURI(filePath.replace(/\\/g, '/'))}`;
|
|
1406
|
+
}
|
|
1407
|
+
languageIdFromPath(filePath) {
|
|
1408
|
+
const ext = extname(filePath).toLowerCase();
|
|
1409
|
+
const map = {
|
|
1410
|
+
'.ts': 'typescript',
|
|
1411
|
+
'.tsx': 'typescriptreact',
|
|
1412
|
+
'.js': 'javascript',
|
|
1413
|
+
'.jsx': 'javascriptreact',
|
|
1414
|
+
'.py': 'python',
|
|
1415
|
+
'.rs': 'rust',
|
|
1416
|
+
'.go': 'go',
|
|
1417
|
+
'.java': 'java',
|
|
1418
|
+
'.c': 'c',
|
|
1419
|
+
'.cpp': 'cpp',
|
|
1420
|
+
'.h': 'c',
|
|
1421
|
+
'.hpp': 'cpp',
|
|
1422
|
+
'.cs': 'csharp',
|
|
1423
|
+
'.rb': 'ruby',
|
|
1424
|
+
'.php': 'php',
|
|
1425
|
+
'.swift': 'swift',
|
|
1426
|
+
'.kt': 'kotlin',
|
|
1427
|
+
'.scala': 'scala',
|
|
1428
|
+
'.sh': 'shellscript',
|
|
1429
|
+
'.bash': 'shellscript',
|
|
1430
|
+
'.zsh': 'shellscript',
|
|
1431
|
+
'.json': 'json',
|
|
1432
|
+
'.yaml': 'yaml',
|
|
1433
|
+
'.yml': 'yaml',
|
|
1434
|
+
'.toml': 'toml',
|
|
1435
|
+
'.md': 'markdown',
|
|
1436
|
+
'.html': 'html',
|
|
1437
|
+
'.css': 'css',
|
|
1438
|
+
'.scss': 'scss',
|
|
1439
|
+
'.less': 'less',
|
|
1440
|
+
'.sql': 'sql',
|
|
1441
|
+
'.graphql': 'graphql',
|
|
1442
|
+
'.vue': 'vue',
|
|
1443
|
+
'.svelte': 'svelte',
|
|
1444
|
+
};
|
|
1445
|
+
return map[ext] || 'plaintext';
|
|
1446
|
+
}
|
|
1447
|
+
// ----------------------------------------------------------
|
|
1448
|
+
// Config Persistence
|
|
1449
|
+
// ----------------------------------------------------------
|
|
1450
|
+
saveConfig() {
|
|
1451
|
+
try {
|
|
1452
|
+
const dir = join(ACP_CONFIG_PATH, '..');
|
|
1453
|
+
if (!existsSync(dir))
|
|
1454
|
+
mkdirSync(dir, { recursive: true });
|
|
1455
|
+
writeFileSync(ACP_CONFIG_PATH, JSON.stringify(this.config, null, 2), 'utf-8');
|
|
1456
|
+
}
|
|
1457
|
+
catch {
|
|
1458
|
+
// Silently fail
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
loadConfig() {
|
|
1462
|
+
try {
|
|
1463
|
+
if (existsSync(ACP_CONFIG_PATH)) {
|
|
1464
|
+
const raw = readFileSync(ACP_CONFIG_PATH, 'utf-8');
|
|
1465
|
+
const saved = JSON.parse(raw);
|
|
1466
|
+
this.config = { ...this.config, ...saved };
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
this.saveConfig();
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
catch {
|
|
1473
|
+
// Silently fail, use defaults
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
// ----------------------------------------------------------
|
|
1477
|
+
// Logging
|
|
1478
|
+
// ----------------------------------------------------------
|
|
1479
|
+
log(message) {
|
|
1480
|
+
// For stdio transport, don't write to stdout (it's used for protocol)
|
|
1481
|
+
// Write to stderr instead
|
|
1482
|
+
if (this.config.transport === 'stdio') {
|
|
1483
|
+
process.stderr.write(message + '\n');
|
|
1484
|
+
}
|
|
1485
|
+
else {
|
|
1486
|
+
console.log(message);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
// ============================================================
|
|
1491
|
+
// Convenience: Create and start an ACP server from engine
|
|
1492
|
+
// ============================================================
|
|
1493
|
+
export async function startACPServer(engine, config) {
|
|
1494
|
+
const server = new ACPServer(engine, config);
|
|
1495
|
+
await server.start();
|
|
1496
|
+
return server;
|
|
1497
|
+
}
|
|
1498
|
+
//# sourceMappingURL=acp.js.map
|