Forge
csharp8f5a77c3
1using System.Diagnostics;
2using System.Text.Json;
3using AgentClientProtocol;
4
5namespace CascadeIDE.Services.CursorAcp;
6
7/// <summary>stdio-процесс Cursor agent + <see cref="ClientSideConnection"/>: один сеанс ACP на жизненный цикл подключения.</summary>
8public sealed class CursorAcpChatConnection : IDisposable
9{
10 private readonly CursorAcpIdeClient _client = new();
11 private Action<string>? _appendTerminalUi;
12 private Action? _showTerminalPanel;
13 private Process? _process;
14 private ClientSideConnection? _connection;
15 private string? _sessionId;
16 private string? _cachedWorkspaceRoot;
17 private string? _cachedCmdPath;
18 private string? _cachedExternalMcpJson;
19 private bool _cachedAcpAutoInjectIdeMcp = true;
20 private string? _cachedProcessPathForMcp;
21 private string? _cachedPreferredCursorAcpModelId;
22 private string? _lastAppliedPreferredCursorAcpModelId;
23 private SessionModelState? _lastSessionModels;
24
25 public bool IsDisposed { get; private set; }
26
27 /// <summary>Снимок <c>session/new</c> (модели), обновляется при новой сессии.</summary>
28 public SessionModelState? LastSessionModels => _lastSessionModels;
29
30 /// <summary>Зеркалирование вывода ACP-терминала в нижнюю панель (и опционально показ вкладки Terminal).</summary>
31 public void SetIdeTerminalCallbacks(Action<string>? appendTerminalUi, Action? showTerminalPanel)
32 {
33 _appendTerminalUi = appendTerminalUi;
34 _showTerminalPanel = showTerminalPanel;
35 }
36
37 public async Task PromptAsync(
38 string workspaceRoot,
39 string configuredAgentPath,
40 string externalMcpServersJson,
41 bool acpAutoInjectIdeMcp,
42 string? preferredCursorAcpModelId,
43 string userText,
44 Action<string>? appendMessageChunk,
45 Action<string>? appendThoughtChunk,
46 Action<CursorAcpStreamStage>? onStage,
47 Action<SessionModelState?>? onSessionModels,
48 CancellationToken cancellationToken)
49 {
50 ObjectDisposedException.ThrowIf(IsDisposed, this);
51 if (!CursorAcpAgentPath.TryResolve(configuredAgentPath, out var cmdPath, out var workDir))
52 throw new InvalidOperationException(
53 "Укажи путь к cursor-agent.cmd/каталогу dist-package в настройках или добавь cursor-agent в PATH.");
54
55 if (string.IsNullOrWhiteSpace(workspaceRoot))
56 workspaceRoot = workDir;
57
58 workspaceRoot = CanonicalFilePath.Normalize(workspaceRoot);
59 await EnsureSessionAsync(
60 workspaceRoot,
61 cmdPath,
62 workDir,
63 externalMcpServersJson,
64 acpAutoInjectIdeMcp,
65 preferredCursorAcpModelId,
66 onSessionModels,
67 cancellationToken).ConfigureAwait(false);
68
69 if (_connection is null || string.IsNullOrEmpty(_sessionId))
70 throw new InvalidOperationException("ACP: нет активной сессии.");
71
72 _client.SetChunkHandlers(appendMessageChunk, appendThoughtChunk, onStage);
73 try
74 {
75 await _connection.PromptAsync(new PromptRequest
76 {
77 SessionId = _sessionId,
78 Prompt = [new TextContentBlock { Text = userText }],
79 }, cancellationToken).ConfigureAwait(false);
80 }
81 finally
82 {
83 _client.SetChunkHandlers(null, null, null);
84 }
85 }
86
87 private async Task EnsureSessionAsync(
88 string workspaceRoot,
89 string cmdPath,
90 string agentWorkingDirectory,
91 string externalMcpServersJson,
92 bool acpAutoInjectIdeMcp,
93 string? preferredCursorAcpModelId,
94 Action<SessionModelState?>? onSessionModels,
95 CancellationToken cancellationToken)
96 {
97 var mcpJson = externalMcpServersJson ?? "";
98 var processPath = Environment.ProcessPath ?? "";
99 var pref = preferredCursorAcpModelId?.Trim() ?? "";
100 _cachedPreferredCursorAcpModelId = pref;
101
102 if (_connection is not null
103 && _process is { HasExited: false }
104 && !string.IsNullOrEmpty(_sessionId)
105 && string.Equals(_cachedWorkspaceRoot, workspaceRoot, StringComparison.OrdinalIgnoreCase)
106 && string.Equals(_cachedCmdPath, cmdPath, StringComparison.OrdinalIgnoreCase)
107 && string.Equals(_cachedExternalMcpJson, mcpJson, StringComparison.Ordinal)
108 && _cachedAcpAutoInjectIdeMcp == acpAutoInjectIdeMcp
109 && string.Equals(_cachedProcessPathForMcp, processPath, StringComparison.OrdinalIgnoreCase))
110 {
111 await TryApplyPreferredModelAndNotifyAsync(pref, onSessionModels, cancellationToken).ConfigureAwait(false);
112 return;
113 }
114
115 DisposeProcessAndConnection();
116 _cachedWorkspaceRoot = workspaceRoot;
117 _cachedCmdPath = cmdPath;
118 _cachedExternalMcpJson = mcpJson;
119 _cachedAcpAutoInjectIdeMcp = acpAutoInjectIdeMcp;
120 _cachedProcessPathForMcp = processPath;
121
122 _process = CursorAcpChatAgentProcess.Start(
123 cmdPath,
124 agentWorkingDirectory,
125 line => Debug.WriteLine("[cursor-acp stderr] " + line));
126
127 _connection = new ClientSideConnection(_ => _client, _process.StandardOutput, _process.StandardInput);
128 _connection.Open();
129
130 _client.SetTerminalCallbacks(_appendTerminalUi, _showTerminalPanel, workspaceRoot);
131
132 await _connection.InitializeAsync(new InitializeRequest
133 {
134 ProtocolVersion = 1,
135 ClientInfo = new Implementation { Name = "CascadeIDE", Version = typeof(CursorAcpChatConnection).Assembly.GetName().Version?.ToString() ?? "0" },
136 ClientCapabilities = new ClientCapabilities
137 {
138 Fs = new FileSystemCapability { ReadTextFile = true, WriteTextFile = true },
139 Terminal = true,
140 },
141 }, cancellationToken).ConfigureAwait(false);
142
143 var mcpServers = CascadeAcpMcpServerCatalog.MergeForAcpNewSession(mcpJson, acpAutoInjectIdeMcp);
144 var sessionResult = await _connection.NewSessionAsync(new NewSessionRequest
145 {
146 Cwd = workspaceRoot,
147 McpServers = mcpServers,
148 }, cancellationToken).ConfigureAwait(false);
149
150 _sessionId = sessionResult.SessionId;
151 _lastSessionModels = sessionResult.Models;
152 _client.SetExpectedSessionId(_sessionId);
153 _client.ConfigureWorkspaceRoot(workspaceRoot);
154 await TryApplyPreferredModelAndNotifyAsync(pref, onSessionModels, cancellationToken).ConfigureAwait(false);
155 }
156
157 /// <summary>Выбор модели после <c>session/new</c> (например из UI).</summary>
158 public async Task<bool> TrySetSessionModelAsync(string modelId, CancellationToken cancellationToken = default)
159 {
160 ObjectDisposedException.ThrowIf(IsDisposed, this);
161 if (_connection is null || string.IsNullOrEmpty(_sessionId) || string.IsNullOrWhiteSpace(modelId))
162 return false;
163
164 await _connection.SetSessionModelAsync(
165 new SetSessionModelRequest { SessionId = _sessionId, ModelId = modelId.Trim() },
166 cancellationToken).ConfigureAwait(false);
167 _lastAppliedPreferredCursorAcpModelId = modelId.Trim();
168 return true;
169 }
170
171 private async Task TryApplyPreferredModelAndNotifyAsync(
172 string preferredModelId,
173 Action<SessionModelState?>? onSessionModels,
174 CancellationToken cancellationToken)
175 {
176 if (_connection is null || string.IsNullOrEmpty(_sessionId))
177 return;
178
179 if (string.IsNullOrEmpty(preferredModelId))
180 {
181 onSessionModels?.Invoke(_lastSessionModels);
182 return;
183 }
184
185 if (string.Equals(_lastAppliedPreferredCursorAcpModelId, preferredModelId, StringComparison.Ordinal))
186 {
187 onSessionModels?.Invoke(_lastSessionModels);
188 return;
189 }
190
191 if (_lastSessionModels?.AvailableModels is not { Length: > 0 } list
192 || !list.Any(m => string.Equals(m.ModelId, preferredModelId, StringComparison.Ordinal)))
193 {
194 onSessionModels?.Invoke(_lastSessionModels);
195 return;
196 }
197
198 try
199 {
200 await _connection.SetSessionModelAsync(
201 new SetSessionModelRequest { SessionId = _sessionId, ModelId = preferredModelId },
202 cancellationToken).ConfigureAwait(false);
203 _lastAppliedPreferredCursorAcpModelId = preferredModelId;
204 }
205 catch
206 {
207 // оставляем сессию; UI может сменить модель вручную
208 }
209
210 onSessionModels?.Invoke(_lastSessionModels);
211 }
212
213 private void DisposeProcessAndConnection()
214 {
215 _client.DisposeTerminalSessions();
216 try
217 {
218 _connection?.Dispose();
219 }
220 catch
221 {
222 // ignore
223 }
224
225 _connection = null;
226 _sessionId = null;
227 _cachedExternalMcpJson = null;
228 _lastSessionModels = null;
229 _lastAppliedPreferredCursorAcpModelId = null;
230
231 try
232 {
233 if (_process is { HasExited: false })
234 {
235 _process.Kill(entireProcessTree: true);
236 _process.WaitForExit(5000);
237 }
238 }
239 catch
240 {
241 // ignore
242 }
243
244 try
245 {
246 _process?.Dispose();
247 }
248 catch
249 {
250 // ignore
251 }
252
253 _process = null;
254 }
255
256 public void Dispose()
257 {
258 if (IsDisposed)
259 return;
260 IsDisposed = true;
261 _client.SetChunkHandlers(null, null, null);
262 DisposeProcessAndConnection();
263 }
264}
265
266internal sealed class CursorAcpIdeClient : IAcpClient
267{
268 private string _workspaceRoot = "";
269 private Action<string>? _messageChunk;
270 private Action<string>? _thoughtChunk;
271 private Action<CursorAcpStreamStage>? _onStage;
272 private AcpTerminalHost? _terminalHost;
273
274 public void ConfigureWorkspaceRoot(string workspaceRoot) =>
275 _workspaceRoot = CanonicalFilePath.Normalize(workspaceRoot.Trim());
276
277 public void SetChunkHandlers(
278 Action<string>? messageChunk,
279 Action<string>? thoughtChunk,
280 Action<CursorAcpStreamStage>? onStage)
281 {
282 _messageChunk = messageChunk;
283 _thoughtChunk = thoughtChunk;
284 _onStage = onStage;
285 }
286
287 public void SetTerminalCallbacks(Action<string>? appendUi, Action? showTerminalPanel, string workspaceRoot) =>
288 _terminalHost = new AcpTerminalHost(workspaceRoot, appendUi, showTerminalPanel);
289
290 public void SetExpectedSessionId(string sessionId) =>
291 _terminalHost?.SetExpectedSessionId(sessionId);
292
293 public void DisposeTerminalSessions()
294 {
295 _terminalHost?.DisposeAllSessions();
296 _terminalHost = null;
297 }
298
299 public ValueTask<RequestPermissionResponse> RequestPermissionAsync(
300 RequestPermissionRequest request,
301 CancellationToken cancellationToken = default)
302 {
303 if (request.Options is not { Length: > 0 })
304 {
305 return ValueTask.FromResult(new RequestPermissionResponse
306 {
307 Outcome = new CancelledRequestPermissionOutcome(),
308 });
309 }
310
311 PermissionOption? pick = null;
312 foreach (var o in request.Options)
313 {
314 if (o.Kind is PermissionOptionKind.AllowOnce or PermissionOptionKind.AllowAlways)
315 {
316 pick = o;
317 break;
318 }
319 }
320
321 pick ??= request.Options[0];
322
323 return ValueTask.FromResult(new RequestPermissionResponse
324 {
325 Outcome = new SelectedRequestPermissionOutcome
326 {
327 OptionId = pick.OptionId,
328 },
329 });
330 }
331
332 public ValueTask SessionNotificationAsync(SessionNotification notification, CancellationToken cancellationToken = default)
333 {
334 var update = notification.Update;
335 switch (update)
336 {
337 case AgentMessageChunkSessionUpdate m when m.Content is TextContentBlock text:
338 _onStage?.Invoke(CursorAcpStreamStage.MessageChunk);
339 _messageChunk?.Invoke(text.Text);
340 break;
341 case AgentThoughtChunkSessionUpdate th when th.Content is TextContentBlock tt:
342 _onStage?.Invoke(CursorAcpStreamStage.ThoughtChunk);
343 _thoughtChunk?.Invoke(tt.Text);
344 break;
345 }
346
347 return ValueTask.CompletedTask;
348 }
349
350 public ValueTask<WriteTextFileResponse> WriteTextFileAsync(WriteTextFileRequest request, CancellationToken cancellationToken = default)
351 {
352 CursorAcpWorkspaceFileAccess.WriteTextFileUnderWorkspace(_workspaceRoot, request.Path, request.Content);
353 return ValueTask.FromResult(new WriteTextFileResponse());
354 }
355
356 public ValueTask<ReadTextFileResponse> ReadTextFileAsync(ReadTextFileRequest request, CancellationToken cancellationToken = default)
357 {
358 var text = CursorAcpWorkspaceFileAccess.ReadTextFileOrEmpty(_workspaceRoot, request.Path);
359 return ValueTask.FromResult(new ReadTextFileResponse { Content = text });
360 }
361
362 public ValueTask<CreateTerminalResponse> CreateTerminalAsync(CreateTerminalRequest request, CancellationToken cancellationToken = default)
363 {
364 _onStage?.Invoke(CursorAcpStreamStage.ToolCall);
365 if (_terminalHost is null)
366 throw new InvalidOperationException("ACP terminal: хост не инициализирован.");
367 return ValueTask.FromResult(_terminalHost.Create(request));
368 }
369
370 public ValueTask<TerminalOutputResponse> TerminalOutputAsync(TerminalOutputRequest request, CancellationToken cancellationToken = default)
371 {
372 _onStage?.Invoke(CursorAcpStreamStage.ToolCall);
373 if (_terminalHost is null)
374 {
375 return ValueTask.FromResult(new TerminalOutputResponse
376 {
377 Output = "",
378 Truncated = false,
379 });
380 }
381
382 return ValueTask.FromResult(_terminalHost.ReadOutput(request));
383 }
384
385 public ValueTask<ReleaseTerminalResponse> ReleaseTerminalAsync(ReleaseTerminalRequest request, CancellationToken cancellationToken = default)
386 {
387 if (_terminalHost is null)
388 return ValueTask.FromResult(new ReleaseTerminalResponse());
389 return ValueTask.FromResult(_terminalHost.Release(request));
390 }
391
392 public async ValueTask<WaitForTerminalExitResponse> WaitForTerminalExitAsync(
393 WaitForTerminalExitRequest request,
394 CancellationToken cancellationToken = default)
395 {
396 _onStage?.Invoke(CursorAcpStreamStage.ToolCall);
397 if (_terminalHost is null)
398 return new WaitForTerminalExitResponse();
399 return await _terminalHost.WaitForExitAsync(request, cancellationToken).ConfigureAwait(false);
400 }
401
402 public ValueTask<KillTerminalCommandResponse> KillTerminalCommandAsync(KillTerminalCommandRequest request, CancellationToken cancellationToken = default)
403 {
404 _onStage?.Invoke(CursorAcpStreamStage.ToolCall);
405 if (_terminalHost is null)
406 return ValueTask.FromResult(new KillTerminalCommandResponse());
407 return ValueTask.FromResult(_terminalHost.Kill(request));
408 }
409
410 public ValueTask<JsonElement> ExtMethodAsync(string method, JsonElement request, CancellationToken cancellationToken = default)
411 => ValueTask.FromResult(JsonSerializer.SerializeToElement(new { }));
412
413 public ValueTask ExtNotificationAsync(string method, JsonElement notification, CancellationToken cancellationToken = default)
414 => ValueTask.CompletedTask;
415}
416
417public enum CursorAcpStreamStage
418{
419 ThoughtChunk = 0,
420 MessageChunk = 1,
421 ToolCall = 2
422}
423
View only · write via MCP/CIDE