Forge
csharpdeeb25a2
1using System.Diagnostics;
2using System.Text;
3using AgentClientProtocol;
4using CascadeIDE.Contracts;
5
6namespace CascadeIDE.Features.CursorAcp.DataAcquisition;
7
8/// <summary>
9/// Процессы ACP <c>terminal/*</c>: вывод в буфер для опроса агентом и зеркалирование в UI нижней панели.
10/// </summary>
11[IoBoundary]
12internal sealed class AcpTerminalHost
13{
14 private readonly object _mapLock = new();
15 private readonly Dictionary<string, AcpTerminalSession> _sessions = [];
16 private readonly string _workspaceRoot;
17 private readonly Action<string>? _appendUi;
18 private readonly Action? _showTerminal;
19 private string? _expectedSessionId;
20
21 public AcpTerminalHost(string workspaceRoot, Action<string>? appendUi, Action? showTerminal)
22 {
23 _workspaceRoot = CanonicalFilePath.Normalize(workspaceRoot.Trim());
24 _appendUi = appendUi;
25 _showTerminal = showTerminal;
26 }
27
28 public void SetExpectedSessionId(string sessionId) =>
29 _expectedSessionId = sessionId;
30
31 public void DisposeAllSessions()
32 {
33 lock (_mapLock)
34 {
35 foreach (var s in _sessions.Values)
36 s.Dispose();
37 _sessions.Clear();
38 }
39 }
40
41 public CreateTerminalResponse Create(CreateTerminalRequest request)
42 {
43 ThrowIfSessionMismatch(request.SessionId);
44
45 var cwd = NormalizeCwdUnderWorkspace(request.Cwd);
46 if (cwd is null)
47 throw new InvalidOperationException("ACP terminal: cwd вне workspace.");
48
49 var id = Guid.NewGuid().ToString("N");
50 var limit = request.OutputByteLimit ?? 8_000_000;
51
52 var psi = new ProcessStartInfo
53 {
54 FileName = request.Command,
55 UseShellExecute = false,
56 RedirectStandardOutput = true,
57 RedirectStandardError = true,
58 CreateNoWindow = true,
59 WorkingDirectory = cwd,
60 StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
61 StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
62 };
63 foreach (var a in request.Args ?? [])
64 psi.ArgumentList.Add(a);
65
66 foreach (var e in request.Env ?? [])
67 {
68 if (!string.IsNullOrEmpty(e.Name))
69 psi.Environment[e.Name] = e.Value ?? "";
70 }
71
72 var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
73 if (!proc.Start())
74 throw new InvalidOperationException("ACP terminal: не удалось запустить процесс.");
75
76 var session = new AcpTerminalSession(id, proc, limit, _appendUi);
77 lock (_mapLock)
78 _sessions[id] = session;
79
80 session.StartPump();
81
82 _appendUi?.Invoke($"\r\n--- ACP terminal {id} ---\r\n{request.Command} {string.Join(" ", request.Args ?? [])}\r\n");
83 _showTerminal?.Invoke();
84
85 return new CreateTerminalResponse { TerminalId = id };
86 }
87
88 public TerminalOutputResponse ReadOutput(TerminalOutputRequest request)
89 {
90 ThrowIfSessionMismatch(request.SessionId);
91 lock (_mapLock)
92 {
93 if (!_sessions.TryGetValue(request.TerminalId, out var s))
94 {
95 return new TerminalOutputResponse
96 {
97 Output = "",
98 Truncated = false,
99 ExitStatus = null,
100 };
101 }
102
103 return s.DequeueOutput();
104 }
105 }
106
107 public ReleaseTerminalResponse Release(ReleaseTerminalRequest request)
108 {
109 ThrowIfSessionMismatch(request.SessionId);
110 AcpTerminalSession? s;
111 lock (_mapLock)
112 {
113 _sessions.Remove(request.TerminalId, out s);
114 }
115
116 s?.Dispose();
117 return new ReleaseTerminalResponse();
118 }
119
120 public async ValueTask<WaitForTerminalExitResponse> WaitForExitAsync(
121 WaitForTerminalExitRequest request,
122 CancellationToken cancellationToken)
123 {
124 ThrowIfSessionMismatch(request.SessionId);
125 AcpTerminalSession? s;
126 lock (_mapLock)
127 _sessions.TryGetValue(request.TerminalId, out s);
128 if (s is null)
129 return new WaitForTerminalExitResponse();
130
131 await s.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
132 return new WaitForTerminalExitResponse
133 {
134 ExitCode = (uint)Math.Max(0, s.ExitCode),
135 };
136 }
137
138 public KillTerminalCommandResponse Kill(KillTerminalCommandRequest request)
139 {
140 ThrowIfSessionMismatch(request.SessionId);
141 AcpTerminalSession? s;
142 lock (_mapLock)
143 _sessions.TryGetValue(request.TerminalId, out s);
144 if (s is null)
145 return new KillTerminalCommandResponse();
146
147 try
148 {
149 if (s is { Process.HasExited: false })
150 s.Process.Kill(entireProcessTree: true);
151 }
152 catch
153 {
154 // ignore
155 }
156
157 return new KillTerminalCommandResponse();
158 }
159
160 private void ThrowIfSessionMismatch(string sessionId)
161 {
162 if (_expectedSessionId is null || !string.Equals(sessionId, _expectedSessionId, StringComparison.Ordinal))
163 throw new InvalidOperationException("ACP terminal: неверный sessionId.");
164 }
165
166 private string? NormalizeCwdUnderWorkspace(string? cwd)
167 {
168 var root = _workspaceRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
169 + Path.DirectorySeparatorChar;
170 if (string.IsNullOrWhiteSpace(cwd))
171 return _workspaceRoot;
172
173 var full = Path.IsPathRooted(cwd)
174 ? CanonicalFilePath.Normalize(cwd)
175 : CanonicalFilePath.Normalize(Path.Combine(_workspaceRoot, cwd));
176 var norm = CanonicalFilePath.Normalize(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
177 + Path.DirectorySeparatorChar;
178 if (!norm.StartsWith(root, StringComparison.OrdinalIgnoreCase))
179 return null;
180 return CanonicalFilePath.Normalize(full);
181 }
182
183 private sealed class AcpTerminalSession : IDisposable
184 {
185 private readonly object _bufLock = new();
186 private readonly StringBuilder _buffer = new();
187 private readonly ulong _byteLimit;
188 private readonly Action<string>? _appendUi;
189 private readonly TaskCompletionSource _exitTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
190
191 private int _sentCharIndex;
192 private ulong _bytesAccepted;
193 private bool _truncated;
194 private bool _disposed;
195 private int _exitCode;
196 private bool _exited;
197
198 public AcpTerminalSession(string id, Process process, ulong byteLimit, Action<string>? appendUi)
199 {
200 Id = id;
201 Process = process;
202 _byteLimit = byteLimit;
203 _appendUi = appendUi;
204 process.Exited += OnExited;
205 }
206
207 public string Id { get; }
208 public Process Process { get; }
209
210 public int ExitCode => _exitCode;
211
212 public void StartPump()
213 {
214 _ = Task.Run(() => PumpAsync(Process.StandardOutput));
215 _ = Task.Run(() => PumpAsync(Process.StandardError));
216 }
217
218 private void OnExited(object? sender, EventArgs e)
219 {
220 lock (_bufLock)
221 {
222 _exited = true;
223 try
224 {
225 _exitCode = Process.ExitCode;
226 }
227 catch
228 {
229 _exitCode = -1;
230 }
231 }
232
233 _exitTcs.TrySetResult();
234 }
235
236 private async Task PumpAsync(StreamReader reader)
237 {
238 var buf = new char[4096];
239 try
240 {
241 while (true)
242 {
243 var n = await reader.ReadAsync(buf.AsMemory(0, buf.Length)).ConfigureAwait(false);
244 if (n <= 0)
245 break;
246 AppendChunk(new string(buf, 0, n));
247 }
248 }
249 catch
250 {
251 // broken pipe / dispose
252 }
253 }
254
255 private void AppendChunk(string chunk)
256 {
257 if (chunk.Length == 0)
258 return;
259
260 lock (_bufLock)
261 {
262 if (_bytesAccepted >= _byteLimit)
263 {
264 _truncated = true;
265 return;
266 }
267
268 var byteCount = (ulong)Encoding.UTF8.GetByteCount(chunk);
269 if (_bytesAccepted + byteCount <= _byteLimit)
270 {
271 _buffer.Append(chunk);
272 _bytesAccepted += byteCount;
273 _appendUi?.Invoke(chunk);
274 return;
275 }
276
277 _truncated = true;
278 var remaining = _byteLimit - _bytesAccepted;
279 var low = 0;
280 var high = chunk.Length;
281 while (low < high)
282 {
283 var mid = (low + high + 1) / 2;
284 var sub = chunk[..mid];
285 if ((ulong)Encoding.UTF8.GetByteCount(sub) <= remaining)
286 low = mid;
287 else
288 high = mid - 1;
289 }
290
291 if (low <= 0)
292 return;
293
294 var fit = chunk[..low];
295 _buffer.Append(fit);
296 _bytesAccepted += (ulong)Encoding.UTF8.GetByteCount(fit);
297 _appendUi?.Invoke(fit);
298 }
299 }
300
301 public TerminalOutputResponse DequeueOutput()
302 {
303 lock (_bufLock)
304 {
305 var tail = _buffer.ToString(_sentCharIndex, _buffer.Length - _sentCharIndex);
306 _sentCharIndex = _buffer.Length;
307
308 TerminalExitStatus? exit = null;
309 if (_exited)
310 {
311 exit = new TerminalExitStatus
312 {
313 ExitCode = (uint)Math.Max(0, _exitCode),
314 };
315 }
316
317 return new TerminalOutputResponse
318 {
319 Output = tail,
320 Truncated = _truncated,
321 ExitStatus = exit,
322 };
323 }
324 }
325
326 public async Task WaitForExitAsync(CancellationToken cancellationToken)
327 {
328 await _exitTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
329 }
330
331 public void Dispose()
332 {
333 if (_disposed)
334 return;
335 _disposed = true;
336 try
337 {
338 if (Process is { HasExited: false })
339 Process.Kill(entireProcessTree: true);
340 }
341 catch
342 {
343 // ignore
344 }
345
346 try
347 {
348 Process.Dispose();
349 }
350 catch
351 {
352 // ignore
353 }
354 }
355 }
356}
357
View only · write via MCP/CIDE