diff --git a/OneWare.slnx b/OneWare.slnx
index c86871a8..dd839722 100644
--- a/OneWare.slnx
+++ b/OneWare.slnx
@@ -33,6 +33,7 @@
+
diff --git a/build/props/DotAcp.Client.props b/build/props/DotAcp.Client.props
new file mode 100644
index 00000000..76b973e4
--- /dev/null
+++ b/build/props/DotAcp.Client.props
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/OneWare.Acp/AcpModule.cs b/src/OneWare.Acp/AcpModule.cs
new file mode 100644
index 00000000..b390323b
--- /dev/null
+++ b/src/OneWare.Acp/AcpModule.cs
@@ -0,0 +1,112 @@
+using Microsoft.Extensions.DependencyInjection;
+using OneWare.Acp.Services;
+using OneWare.Essentials.Helpers;
+using OneWare.Essentials.Models;
+using OneWare.Essentials.PackageManager;
+using OneWare.Essentials.Services;
+
+namespace OneWare.Acp;
+
+public class AcpModule : OneWareModuleBase
+{
+ private const string CodexAcpVersion = "0.16.0";
+ private const string CodexAcpTag = "v0.16.0";
+ private const string BaseUrl = $"https://github.com/zed-industries/codex-acp/releases/download/{CodexAcpTag}";
+
+ public static readonly Package CodexPackage = new()
+ {
+ Category = "Binaries",
+ Id = "codexacp",
+ Type = "NativeTool",
+ Name = "Codex ACP",
+ Description = "ACP adapter for OpenAI Codex CLI, enabling Codex in ACP-compatible editors (by Zed Industries)",
+ License = "Apache-2.0",
+ IconUrl = "https://raw.githubusercontent.com/lobehub/lobe-icons/refs/heads/master/packages/static-png/dark/openai.png",
+ Links =
+ [
+ new PackageLink { Name = "GitHub", Url = "https://github.com/zed-industries/codex-acp" },
+ new PackageLink { Name = "OpenAI Codex", Url = "https://github.com/openai/codex" }
+ ],
+ Versions =
+ [
+ new PackageVersion
+ {
+ Version = CodexAcpVersion,
+ Targets =
+ [
+ new PackageTarget
+ {
+ Target = "win-x64",
+ Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-x86_64-pc-windows-msvc.zip",
+ AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp.exe", SettingKey = CodexChatService.CodexPathKey }]
+ },
+ new PackageTarget
+ {
+ Target = "win-arm64",
+ Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-aarch64-pc-windows-msvc.zip",
+ AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp.exe", SettingKey = CodexChatService.CodexPathKey }]
+ },
+ new PackageTarget
+ {
+ Target = "linux-x64",
+ Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-x86_64-unknown-linux-musl.tar.gz",
+ AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp", SettingKey = CodexChatService.CodexPathKey }]
+ },
+ new PackageTarget
+ {
+ Target = "linux-arm64",
+ Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-aarch64-unknown-linux-musl.tar.gz",
+ AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp", SettingKey = CodexChatService.CodexPathKey }]
+ },
+ new PackageTarget
+ {
+ Target = "osx-x64",
+ Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-x86_64-apple-darwin.tar.gz",
+ AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp", SettingKey = CodexChatService.CodexPathKey }]
+ },
+ new PackageTarget
+ {
+ Target = "osx-arm64",
+ Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-aarch64-apple-darwin.tar.gz",
+ AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp", SettingKey = CodexChatService.CodexPathKey }]
+ }
+ ]
+ }
+ ]
+ };
+
+ public override void RegisterServices(IServiceCollection services)
+ {
+ services.AddTransient();
+ }
+
+ public override void Initialize(IServiceProvider serviceProvider)
+ {
+ serviceProvider.Resolve().RegisterPackage(CodexPackage);
+
+ var settings = serviceProvider.Resolve();
+
+ settings.RegisterSetting(
+ "AI Chat", "Codex", CodexChatService.CodexPathKey,
+ new FilePathSetting(
+ "Codex ACP Path", "", null,
+ serviceProvider.Resolve().NativeToolsDirectory,
+ PlatformHelper.Exists,
+ PlatformHelper.ExeFile)
+ {
+ HoverDescription = "Path to the codex-acp binary. Install it automatically via the Package Manager."
+ });
+
+ settings.RegisterSetting(
+ "AI Chat", "Codex", CodexChatService.CodexApiKeySettingKey,
+ new TextBoxSetting("OpenAI API Key", "", null)
+ {
+ HoverDescription = "Your OpenAI API key (sk-...). Required to authenticate Codex with the OpenAI API."
+ });
+
+ var codex = serviceProvider.Resolve();
+ serviceProvider.Resolve().RegisterChatService(codex);
+
+ _ = codex.CheckForUpdateAsync();
+ }
+}
diff --git a/src/OneWare.Acp/OneWare.Acp.csproj b/src/OneWare.Acp/OneWare.Acp.csproj
new file mode 100644
index 00000000..5880cc0d
--- /dev/null
+++ b/src/OneWare.Acp/OneWare.Acp.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OneWare.Acp/Services/AcpChatService.cs b/src/OneWare.Acp/Services/AcpChatService.cs
new file mode 100644
index 00000000..43bacd67
--- /dev/null
+++ b/src/OneWare.Acp/Services/AcpChatService.cs
@@ -0,0 +1,642 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using Avalonia.Controls;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using dotacp.client.unstable;
+using dotacp.protocol.unstable;
+using Microsoft.Extensions.Logging;
+using OneWare.Essentials.Models;
+using OneWare.Essentials.Services;
+
+namespace OneWare.Acp.Services;
+
+///
+/// Abstract base for any ACP-over-stdio chat service.
+/// Subclasses supply the binary path, startup arguments, and install UX.
+///
+public abstract class AcpChatService(IPaths paths, ILogger logger)
+ : ObservableObject, IChatService, IAcpClient
+{
+ // ── Abstract surface ──────────────────────────────────────────────────────
+
+ public abstract string Name { get; }
+
+ /// Return the full path to the agent binary, or when not installed.
+ protected abstract string? ResolveAgentPath();
+
+ /// Command-line arguments for the agent. Override to add e.g. ["--acp"].
+ protected virtual IEnumerable GetAgentArguments() => [];
+
+ ///
+ /// Environment variables to inject into the agent process.
+ /// Override to supply e.g. OPENAI_API_KEY from a setting.
+ ///
+ protected virtual IReadOnlyDictionary GetEnvironmentVariables() =>
+ new Dictionary();
+
+ /// Called when the binary could not be found. Fire an install button via EventReceived.
+ protected abstract void OnAgentNotFound();
+
+ ///
+ /// Called when the agent requires env-var credentials that are not yet configured.
+ /// Default implementation emits a listing the missing variables.
+ ///
+ protected virtual void OnAuthRequired(AuthMethodEnvVar method, IReadOnlyList missingVars)
+ {
+ var varNames = string.Join(", ", missingVars.Select(v => v.Label ?? v.Name));
+ var link = method.Link;
+ var msg = link != null
+ ? $"Authentication required. Please configure: {varNames}\n\nGet your API key: {link}"
+ : $"Authentication required. Please configure: {varNames}";
+ EventReceived?.Invoke(this, new ChatErrorEvent(msg));
+ }
+
+ ///
+ /// Called when the agent requires interactive terminal authentication (e.g. ChatGPT OAuth).
+ /// Override to show a login button or otherwise guide the user.
+ ///
+ protected virtual void OnTerminalAuthRequired(AuthMethodTerminal method)
+ {
+ var name = method.Name ?? "interactive login";
+ EventReceived?.Invoke(this, new ChatErrorEvent(
+ $"Authentication requires {name}. " +
+ "Please authenticate via a terminal before connecting."));
+ }
+
+ ///
+ /// Called when the agent advertises an agent-managed auth method (e.g. browser-based
+ /// ChatGPT OAuth via AuthMethodAgent). The connection is kept alive so that
+ /// can be called from the button handler.
+ /// Override to surface a login button. Default implementation emits a
+ /// with the method description.
+ ///
+ protected virtual void OnAgentAuthRequired(AuthMethodAgent method)
+ {
+ var name = method.Description ?? method.Name ?? "agent-managed login";
+ EventReceived?.Invoke(this, new ChatErrorEvent(
+ $"Authentication required: {name}. " +
+ "Please complete the login flow before connecting."));
+ }
+
+ ///
+ /// Authenticates using the given on the current live connection
+ /// and then opens a new session. Call this from an
+ /// override button handler. If the connection has been lost in the meantime, falls back
+ /// to a full reconnect.
+ ///
+ protected async Task PerformAgentAuthAndOpenSessionAsync(string methodId)
+ {
+ await _initLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ if (_connection == null)
+ {
+ // Connection was lost while the user was reading the prompt; reconnect from scratch.
+ await ConnectAsync().ConfigureAwait(false);
+ return;
+ }
+
+ await _connection.AuthenticateAsync(
+ new AuthenticateRequest { MethodId = methodId }).ConfigureAwait(false);
+
+ var sessionResponse = await _connection.NewSessionAsync(
+ new NewSessionRequest { Cwd = paths.ProjectsDirectory, McpServers = [] })
+ .ConfigureAwait(false);
+
+ _sessionId = sessionResponse.SessionId.ToString();
+ StatusChanged?.Invoke(this, new StatusEvent(true, $"{Name} ready"));
+ SessionReset?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Agent auth failed for method {MethodId}", methodId);
+ await DisposeConnectionAsync().ConfigureAwait(false);
+ EventReceived?.Invoke(this, new ChatErrorEvent($"Authentication failed: {ex.Message}"));
+ }
+ finally
+ {
+ _initLock.Release();
+ }
+ }
+
+ // ── State ─────────────────────────────────────────────────────────────────
+
+ private Process? _agentProcess;
+ private Connection? _connection;
+ private string? _sessionId;
+ private readonly StringBuilder _stderrBuffer = new();
+
+ private CancellationTokenSource? _promptCts;
+ private string? _currentMessageId;
+
+ private readonly SemaphoreSlim _initLock = new(1, 1);
+ private readonly ConcurrentDictionary _terminals = new();
+
+ // ── IChatService ──────────────────────────────────────────────────────────
+
+ public Control? BottomUiExtension => null;
+
+ public event EventHandler? SessionReset;
+ public event EventHandler? EventReceived;
+ public event EventHandler? StatusChanged;
+
+ // Protected helpers so subclasses can raise events without reflection tricks
+ protected void RaiseSessionReset() => SessionReset?.Invoke(this, EventArgs.Empty);
+ protected void RaiseEventReceived(ChatEvent e) => EventReceived?.Invoke(this, e);
+ protected void RaiseStatusChanged(StatusEvent e) => StatusChanged?.Invoke(this, e);
+
+ public async Task InitializeAsync()
+ {
+ await _initLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ await DisposeConnectionAsync().ConfigureAwait(false);
+ return await ConnectAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ _initLock.Release();
+ }
+ }
+
+ public async Task SendAsync(string prompt)
+ {
+ if (_connection == null || _sessionId == null)
+ {
+ EventReceived?.Invoke(this, new ChatErrorEvent("Agent is not connected."));
+ return;
+ }
+
+ _currentMessageId = Guid.NewGuid().ToString("N");
+ _promptCts?.Dispose();
+ _promptCts = new CancellationTokenSource();
+
+ try
+ {
+ await _connection.PromptAsync(
+ new PromptRequest
+ {
+ SessionId = new SessionId(_sessionId),
+ Prompt = [new TextContent { Text = prompt }]
+ },
+ _promptCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "ACP PromptAsync error");
+ EventReceived?.Invoke(this, new ChatErrorEvent(ex.Message));
+ }
+ finally
+ {
+ _currentMessageId = null;
+ EventReceived?.Invoke(this, new ChatIdleEvent());
+ }
+ }
+
+ public async Task AbortAsync()
+ {
+ if (_promptCts is { } cts)
+ {
+ try
+ {
+ await cts.CancelAsync().ConfigureAwait(false);
+ if (_connection != null && _sessionId != null)
+ await _connection.CancelAsync(
+ new CancelNotification { SessionId = new SessionId(_sessionId) })
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex) { logger.LogWarning(ex, "ACP abort failed"); }
+ }
+ }
+
+ public async Task NewChatAsync()
+ {
+ if (_connection == null) return;
+
+ try
+ {
+ if (_sessionId != null)
+ await _connection.CloseAsync(
+ new CloseSessionRequest { SessionId = new SessionId(_sessionId) })
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex) { logger.LogWarning(ex, "ACP close session failed"); }
+
+ _sessionId = null;
+
+ try
+ {
+ var resp = await _connection.NewSessionAsync(
+ new NewSessionRequest { Cwd = paths.ProjectsDirectory, McpServers = [] })
+ .ConfigureAwait(false);
+ _sessionId = resp.SessionId.ToString();
+ SessionReset?.Invoke(this, EventArgs.Empty);
+ StatusChanged?.Invoke(this, new StatusEvent(true, $"{Name} ready"));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "ACP new session failed");
+ EventReceived?.Invoke(this, new ChatErrorEvent($"Failed to create session: {ex.Message}"));
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeConnectionAsync().ConfigureAwait(false);
+ }
+
+ // ── Connection lifecycle ──────────────────────────────────────────────────
+
+ private async Task ConnectAsync()
+ {
+ var agentPath = ResolveAgentPath();
+
+ if (agentPath == null)
+ {
+ OnAgentNotFound();
+ return false;
+ }
+
+ try
+ {
+ StatusChanged?.Invoke(this, new StatusEvent(false, $"Starting {Name}…"));
+
+ _stderrBuffer.Clear();
+ var psi = new ProcessStartInfo(agentPath)
+ {
+ WorkingDirectory = paths.ProjectsDirectory,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ // Use Arguments (raw string) instead of ArgumentList to avoid the
+ // "-- " quoting artifact that occurs when the binary is a .cmd wrapper on Windows.
+ var extraArgs = GetAgentArguments().ToList();
+ if (extraArgs.Count > 0)
+ psi.Arguments = string.Join(" ", extraArgs);
+
+ // Inject credentials and other env vars supplied by the subclass
+ var envVars = GetEnvironmentVariables();
+ foreach (var (k, v) in envVars)
+ psi.EnvironmentVariables[k] = v;
+
+ _agentProcess = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ _agentProcess.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data == null) return;
+ lock (_stderrBuffer) _stderrBuffer.AppendLine(e.Data);
+ logger.LogDebug("[{Name} stderr] {Line}", Name, e.Data);
+ };
+ _agentProcess.Exited += OnAgentProcessExited;
+
+ if (!_agentProcess.Start())
+ {
+ StatusChanged?.Invoke(this, new StatusEvent(false, "Failed to start agent"));
+ return false;
+ }
+
+ _agentProcess.BeginErrorReadLine();
+
+ _connection = Connection.RunClient(
+ this,
+ _agentProcess.StandardInput.BaseStream,
+ _agentProcess.StandardOutput.BaseStream);
+
+ if (_connection == null)
+ {
+ StatusChanged?.Invoke(this, new StatusEvent(false, "Connection failed"));
+ return false;
+ }
+
+ var initResponse = await _connection.InitializeAsync(new InitializeRequest
+ {
+ ProtocolVersion = ProtocolMeta.Version,
+ ClientCapabilities = new ClientCapabilities
+ {
+ Fs = new FileSystemCapabilities { ReadTextFile = true, WriteTextFile = true },
+ Terminal = true
+ },
+ ClientInfo = new Implementation { Name = "OneWare Studio", Version = "1.0" }
+ }).ConfigureAwait(false);
+
+ if (initResponse.AuthMethods is { Length: > 0 } methods)
+ {
+ // Prefer env-var auth (e.g. OPENAI_API_KEY for codex-acp)
+ var envVarMethod = methods.OfType().FirstOrDefault();
+ var termMethod = methods.OfType().FirstOrDefault();
+ var agentMethod = methods.OfType().FirstOrDefault();
+
+ if (envVarMethod != null)
+ {
+ var missingVars = (envVarMethod.Vars ?? [])
+ .Where(v => !v.Optional &&
+ string.IsNullOrEmpty(Environment.GetEnvironmentVariable(v.Name)) &&
+ !envVars.ContainsKey(v.Name))
+ .ToList();
+
+ if (missingVars.Count > 0)
+ {
+ // Prefer interactive login when env-var credentials are missing.
+ // Agent auth (e.g. ChatGPT OAuth) keeps the connection alive so that
+ // PerformAgentAuthAndOpenSessionAsync can call AuthenticateAsync on it.
+ if (termMethod != null)
+ {
+ await DisposeConnectionAsync().ConfigureAwait(false);
+ OnTerminalAuthRequired(termMethod);
+ }
+ else if (agentMethod != null)
+ {
+ // Keep connection alive — PerformAgentAuthAndOpenSessionAsync needs it.
+ OnAgentAuthRequired(agentMethod);
+ }
+ else
+ {
+ await DisposeConnectionAsync().ConfigureAwait(false);
+ OnAuthRequired(envVarMethod, missingVars);
+ }
+ return false;
+ }
+
+ await _connection.AuthenticateAsync(
+ new AuthenticateRequest { MethodId = envVarMethod.Id })
+ .ConfigureAwait(false);
+ }
+ else if (termMethod != null)
+ {
+ // No env-var method — only terminal auth available
+ await DisposeConnectionAsync().ConfigureAwait(false);
+ OnTerminalAuthRequired(termMethod);
+ return false;
+ }
+ else if (agentMethod != null)
+ {
+ // Agent handles auth itself (e.g. browser-based ChatGPT OAuth).
+ // Keep the connection alive and surface a button so the user can
+ // consciously start the login flow.
+ OnAgentAuthRequired(agentMethod);
+ return false;
+ }
+ }
+
+ var sessionResponse = await _connection.NewSessionAsync(
+ new NewSessionRequest { Cwd = paths.ProjectsDirectory, McpServers = [] })
+ .ConfigureAwait(false);
+
+ _sessionId = sessionResponse.SessionId.ToString();
+ StatusChanged?.Invoke(this, new StatusEvent(true, $"{Name} ready"));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ var stderr = GetStderr();
+ logger.LogError(ex, "ACP connect failed. stderr={Stderr}", stderr);
+ StatusChanged?.Invoke(this, new StatusEvent(false, "Agent unavailable"));
+ var msg = stderr is { Length: > 0 }
+ ? $"{ex.Message}\n\nAgent output:\n{stderr}"
+ : ex.Message;
+ EventReceived?.Invoke(this, new ChatErrorEvent(msg));
+ return false;
+ }
+ }
+
+ private async Task DisposeConnectionAsync()
+ {
+ if (_promptCts != null)
+ {
+ await _promptCts.CancelAsync().ConfigureAwait(false);
+ _promptCts.Dispose();
+ _promptCts = null;
+ }
+
+ foreach (var (_, entry) in _terminals) KillTerminalEntry(entry);
+ _terminals.Clear();
+
+ var conn = _connection;
+ _connection = null;
+ _sessionId = null;
+
+ if (conn != null)
+ {
+ try { conn.Dispose(); }
+ catch (Exception ex) { logger.LogWarning(ex, "ACP connection dispose error"); }
+ }
+
+ var proc = _agentProcess;
+ _agentProcess = null;
+
+ if (proc != null)
+ {
+ proc.Exited -= OnAgentProcessExited;
+ try
+ {
+ if (!proc.HasExited)
+ {
+ proc.Kill(entireProcessTree: true);
+ await proc.WaitForExitAsync().ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex) { logger.LogWarning(ex, "ACP agent process kill error"); }
+ proc.Dispose();
+ }
+ }
+
+ private void OnAgentProcessExited(object? sender, EventArgs e)
+ {
+ if (sender is not Process proc) return;
+ var exitCode = proc.ExitCode;
+ var stderr = GetStderr();
+ logger.LogWarning("ACP agent exited: code={Code}, stderr={Stderr}", exitCode, stderr);
+ StatusChanged?.Invoke(this, new StatusEvent(false, $"Disconnected (exit {exitCode})"));
+ if (stderr is { Length: > 0 })
+ EventReceived?.Invoke(this, new ChatErrorEvent($"Agent exited (code {exitCode}):\n{stderr}"));
+ }
+
+ private string? GetStderr()
+ {
+ lock (_stderrBuffer)
+ return _stderrBuffer.Length > 0 ? _stderrBuffer.ToString().Trim() : null;
+ }
+
+ // ── IAcpClient — session updates ──────────────────────────────────────────
+
+ public Task SessionUpdateAsync(SessionNotification notification, CancellationToken cancellationToken)
+ {
+ switch (notification.Update)
+ {
+ case SessionUpdateAgentMessageChunk chunk when chunk.Content is TextContent tc:
+ EventReceived?.Invoke(this, new ChatMessageDeltaEvent(tc.Text ?? string.Empty, _currentMessageId));
+ break;
+ case SessionUpdateAgentThoughtChunk thought when thought.Content is TextContent tc:
+ EventReceived?.Invoke(this, new ChatReasoningDeltaEvent(tc.Text ?? string.Empty, _currentMessageId));
+ break;
+ case SessionUpdateToolCallUpdate toolCall when toolCall.Status == ToolCallStatus.InProgress:
+ EventReceived?.Invoke(this, new ChatToolExecutionStartEvent(toolCall.Title ?? toolCall.Kind.ToString()));
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── IAcpClient — permission ───────────────────────────────────────────────
+
+ public Task RequestPermissionAsync(
+ RequestPermissionRequest request, CancellationToken cancellationToken)
+ {
+ var options = request.Options ?? [];
+ var allowOption = options.FirstOrDefault(o => o.Kind is PermissionOptionKind.AllowOnce or PermissionOptionKind.AllowAlways);
+ var denyOption = options.FirstOrDefault(o => o.Kind is PermissionOptionKind.RejectOnce or PermissionOptionKind.RejectAlways);
+ var allowAlwaysOption = options.FirstOrDefault(o => o.Kind == PermissionOptionKind.AllowAlways);
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ cancellationToken.Register(() =>
+ tcs.TrySetResult(new RequestPermissionResponse { Outcome = new RequestPermissionOutcomeCancelled() }));
+
+ var allowCmd = new RelayCommand(_ =>
+ {
+ var id = allowOption?.OptionId ?? (options.Length > 0 ? options[0].OptionId : default);
+ tcs.TrySetResult(new RequestPermissionResponse { Outcome = new SelectedPermissionOutcome { OptionId = id } });
+ });
+ var denyCmd = new RelayCommand(_ =>
+ tcs.TrySetResult(new RequestPermissionResponse { Outcome = new RequestPermissionOutcomeCancelled() }));
+ RelayCommand? allowAlwaysCmd = allowAlwaysOption == null ? null :
+ new RelayCommand(_ =>
+ tcs.TrySetResult(new RequestPermissionResponse
+ { Outcome = new SelectedPermissionOutcome { OptionId = allowAlwaysOption.OptionId } }));
+
+ EventReceived?.Invoke(this, new ChatPermissionRequestEvent(
+ $"**The agent wants to perform:** {request.ToolCall?.Title ?? "an action"}",
+ allowOption?.Name ?? "Allow", denyOption?.Name ?? "Deny",
+ allowCmd, denyCmd, allowAlwaysOption?.Name, allowAlwaysCmd));
+
+ return tcs.Task;
+ }
+
+ // ── IAcpClient — elicitation ──────────────────────────────────────────────
+
+ public Task CreateAsync(
+ CreateElicitationRequest request, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("ACP elicitation/create: {Message}", request.Message);
+ // Decline all elicitations — subclasses can override to show a form UI
+ return Task.FromResult(new CreateElicitationResponseDecline());
+ }
+
+ public Task CompleteAsync(
+ CompleteElicitationNotification notification, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("ACP elicitation/complete: {Id}", notification.ElicitationId);
+ return Task.CompletedTask;
+ }
+
+ // ── IAcpClient — file system ──────────────────────────────────────────────
+
+ public async Task ReadTextFileAsync(ReadTextFileRequest request, CancellationToken cancellationToken)
+ {
+ var content = await File.ReadAllTextAsync(request.Path, cancellationToken).ConfigureAwait(false);
+ return new ReadTextFileResponse { Content = content };
+ }
+
+ public async Task WriteTextFileAsync(WriteTextFileRequest request, CancellationToken cancellationToken)
+ {
+ await File.WriteAllTextAsync(request.Path, request.Content, cancellationToken).ConfigureAwait(false);
+ return new WriteTextFileResponse();
+ }
+
+ // ── IAcpClient — terminal ─────────────────────────────────────────────────
+
+ public Task CreateTerminalAsync(CreateTerminalRequest request, CancellationToken cancellationToken)
+ {
+ var id = Guid.NewGuid().ToString("N");
+ var buf = new StringBuilder();
+ var psi = new ProcessStartInfo(request.Command)
+ {
+ UseShellExecute = false, RedirectStandardOutput = true,
+ RedirectStandardError = true, CreateNoWindow = true,
+ WorkingDirectory = request.Cwd ?? paths.ProjectsDirectory
+ };
+ foreach (var arg in request.Args ?? []) psi.ArgumentList.Add(arg);
+ foreach (var env in request.Env ?? []) psi.EnvironmentVariables[env.Name] = env.Value;
+
+ var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ proc.OutputDataReceived += (_, e) => { if (e.Data != null) lock (buf) buf.AppendLine(e.Data); };
+ proc.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (buf) buf.AppendLine(e.Data); };
+ proc.Start(); proc.BeginOutputReadLine(); proc.BeginErrorReadLine();
+
+ _terminals[id] = new TerminalEntry(proc, buf);
+ return Task.FromResult(new CreateTerminalResponse { TerminalId = id });
+ }
+
+ public Task TerminalOutputAsync(TerminalOutputRequest request, CancellationToken cancellationToken)
+ {
+ if (!_terminals.TryGetValue(request.TerminalId, out var entry))
+ return Task.FromResult(new TerminalOutputResponse { Output = string.Empty });
+ string output; lock (entry.Output) { output = entry.Output.ToString(); }
+ return Task.FromResult(new TerminalOutputResponse
+ {
+ Output = output, Truncated = false,
+ ExitStatus = entry.Process.HasExited ? new TerminalExitStatus { ExitCode = (uint?)entry.Process.ExitCode } : null
+ });
+ }
+
+ public async Task WaitForTerminalExitAsync(WaitForTerminalExitRequest request, CancellationToken cancellationToken)
+ {
+ if (!_terminals.TryGetValue(request.TerminalId, out var entry))
+ return new WaitForTerminalExitResponse { ExitCode = null };
+ await entry.Process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ return new WaitForTerminalExitResponse { ExitCode = (uint?)entry.Process.ExitCode };
+ }
+
+ public Task ReleaseTerminalAsync(ReleaseTerminalRequest request, CancellationToken cancellationToken)
+ {
+ if (_terminals.TryRemove(request.TerminalId, out var entry)) KillTerminalEntry(entry);
+ return Task.FromResult(new ReleaseTerminalResponse());
+ }
+
+ public Task KillTerminalAsync(KillTerminalRequest request, CancellationToken cancellationToken)
+ {
+ if (_terminals.TryRemove(request.TerminalId, out var entry)) KillTerminalEntry(entry);
+ return Task.FromResult(new KillTerminalResponse());
+ }
+
+ public Task