From 5c4bd232e3c72195cac7bde8dae5776792eed1d9 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 18 Jun 2026 12:24:42 +0200 Subject: [PATCH 1/2] acp --- OneWare.slnx | 1 + build/props/DotAcp.Client.props | 7 + src/OneWare.Acp/AcpModule.cs | 104 ++++ src/OneWare.Acp/OneWare.Acp.csproj | 11 + src/OneWare.Acp/Services/AcpChatService.cs | 480 ++++++++++++++++++ src/OneWare.Acp/Services/CodexChatService.cs | 91 ++++ .../DesktopStudioApp.cs | 2 + .../OneWare.Studio.Desktop.csproj | 1 + 8 files changed, 697 insertions(+) create mode 100644 build/props/DotAcp.Client.props create mode 100644 src/OneWare.Acp/AcpModule.cs create mode 100644 src/OneWare.Acp/OneWare.Acp.csproj create mode 100644 src/OneWare.Acp/Services/AcpChatService.cs create mode 100644 src/OneWare.Acp/Services/CodexChatService.cs 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..6a5d94c2 --- /dev/null +++ b/src/OneWare.Acp/AcpModule.cs @@ -0,0 +1,104 @@ +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 CodexVersion = "0.141.0"; + private const string CodexTag = "rust-v0.141.0"; + + public static readonly Package CodexPackage = new() + { + Category = "Binaries", + Id = "codexcli", + Type = "NativeTool", + Name = "Codex CLI", + Description = "OpenAI Codex agent with ACP support for OneWare Studio", + 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/openai/codex" }, + new PackageLink { Name = "Documentation", Url = "https://github.com/openai/codex#readme" } + ], + Versions = + [ + new PackageVersion + { + Version = CodexVersion, + Targets = + [ + new PackageTarget + { + Target = "win-x64", + Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-x86_64-pc-windows-msvc.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "codex.exe", SettingKey = CodexChatService.CodexPathKey }] + }, + new PackageTarget + { + Target = "win-arm64", + Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-aarch64-pc-windows-msvc.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex.exe", SettingKey = CodexChatService.CodexPathKey }] + }, + new PackageTarget + { + Target = "linux-x64", + Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-x86_64-unknown-linux-musl.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + }, + new PackageTarget + { + Target = "linux-arm64", + Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-aarch64-unknown-linux-musl.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + }, + new PackageTarget + { + Target = "osx-x64", + Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-x86_64-apple-darwin.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + }, + new PackageTarget + { + Target = "osx-arm64", + Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-aarch64-apple-darwin.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + } + ] + } + ] + }; + + public override void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + } + + public override void Initialize(IServiceProvider serviceProvider) + { + serviceProvider.Resolve().RegisterPackage(CodexPackage); + + serviceProvider.Resolve().RegisterSetting( + "AI Chat", "Codex CLI", CodexChatService.CodexPathKey, + new FilePathSetting( + "Codex CLI Path", "", null, + serviceProvider.Resolve().NativeToolsDirectory, + PlatformHelper.Exists, + PlatformHelper.ExeFile) + { + HoverDescription = "Path to the Codex CLI binary. Install it automatically via the Package Manager." + }); + + var codex = serviceProvider.Resolve(); + + serviceProvider.Resolve().RegisterChatService(codex); + + // Kick off update check in the background — shows an update button in chat if needed + _ = 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..e7f8c69e --- /dev/null +++ b/src/OneWare.Acp/Services/AcpChatService.cs @@ -0,0 +1,480 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using dotacp.client; +using dotacp.protocol; +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() => []; + + /// Called when the binary could not be found. Fire an install button via EventReceived. + protected abstract void OnAgentNotFound(); + + // ── 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); + + _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) + { + var method = methods.OfType().FirstOrDefault(); + if (method != null) + await _connection.AuthenticateAsync( + new AuthenticateRequest { MethodId = method.Id }) + .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")); + 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 — 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 ExtMethodAsync(string method, object request, CancellationToken cancellationToken) + { + logger.LogDebug("ACP unhandled extension method: {Method}", method); + throw new NotImplementedException($"Extension method '{method}' is not supported."); + } + + public Task ExtNotificationAsync(string method, object notification, CancellationToken cancellationToken) + { + logger.LogDebug("ACP extension notification: {Method}", method); + return Task.CompletedTask; + } + + public void OnDisconnected(Connection connection) + { + var stderr = GetStderr(); + logger.LogWarning("ACP JSON-RPC disconnected. stderr={Stderr}", stderr); + StatusChanged?.Invoke(this, new StatusEvent(false, "Disconnected")); + EventReceived?.Invoke(this, new ChatErrorEvent( + stderr is { Length: > 0 } + ? $"Connection lost — agent output:\n{stderr}" + : "Connection lost. If you see 'stdin is not a terminal', try adding --acp to the agent startup arguments.")); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static void KillTerminalEntry(TerminalEntry entry) + { + try { if (!entry.Process.HasExited) entry.Process.Kill(entireProcessTree: true); } + catch { /* ignore */ } + finally { entry.Process.Dispose(); } + } + + private sealed record TerminalEntry(Process Process, StringBuilder Output); +} + + diff --git a/src/OneWare.Acp/Services/CodexChatService.cs b/src/OneWare.Acp/Services/CodexChatService.cs new file mode 100644 index 00000000..a5e1375d --- /dev/null +++ b/src/OneWare.Acp/Services/CodexChatService.cs @@ -0,0 +1,91 @@ +using Avalonia.Controls; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Enums; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Acp.Services; + +/// +/// ACP chat service backed by the OpenAI Codex CLI. +/// The binary is managed by the OneWare package manager; no user settings are required. +/// +public sealed class CodexChatService( + IPaths paths, + ISettingsService settingsService, + IPackageService packageService, + IPackageWindowService packageWindowService, + ILogger logger) + : AcpChatService(paths, logger) +{ + // Silently-stored path key — populated automatically by PackageAutoSetting after install. + // Not registered via ISettingsService.RegisterSetting so it never appears in the Settings UI. + internal const string CodexPathKey = "AI_Chat_Codex_Path"; + + private static readonly string CodexExe = + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( + System.Runtime.InteropServices.OSPlatform.Windows) + ? "codex.exe" + : "codex"; + + public override string Name => "Codex"; + + // Tell Codex to start in ACP server mode over stdin/stdout instead of interactive TUI mode. + protected override IEnumerable GetAgentArguments() => ["-- --acp"]; + + protected override string? ResolveAgentPath() + { + // 1. Path written by the package manager after install + var fromPackage = settingsService.GetSettingValue(CodexPathKey); + if (PlatformHelper.Exists(fromPackage)) + return PlatformHelper.GetFullPath(fromPackage); + + // 2. Codex on the system PATH (e.g. `npm install -g @openai/codex`) + return PlatformHelper.GetFullPath(CodexExe); + } + + protected override void OnAgentNotFound() + { + RaiseStatusChanged(new StatusEvent(false, "Codex not found")); + RaiseEventReceived(new ChatButtonEvent( + "Codex CLI is not installed. Download it via the Package Manager.", + "Install Codex CLI", + new AsyncRelayCommand(owner => InstallCodexAsync(owner)))); + } + + // ── Install / update ────────────────────────────────────────────────────── + + private async Task InstallCodexAsync(Control? owner, bool update = false) + { + if (!update && ResolveAgentPath() != null) return; + + var installed = await packageWindowService + .QuickInstallPackageAsync(AcpModule.CodexPackage.Id!) + .ConfigureAwait(false); + + if (!installed) return; + + RaiseSessionReset(); + await InitializeAsync().ConfigureAwait(false); + } + + // ── Update check ────────────────────────────────────────────────────────── + + internal async Task CheckForUpdateAsync() + { + if (!packageService.IsLoaded && !await packageService.RefreshAsync().ConfigureAwait(false)) + return; + + if (packageService.Packages.TryGetValue(AcpModule.CodexPackage.Id!, out var state) && + state.Status is PackageStatus.UpdateAvailable) + { + RaiseStatusChanged(new StatusEvent(false, "Codex update available")); + RaiseEventReceived(new ChatButtonEvent( + "A Codex CLI update is available.", + "Update Codex CLI", + new AsyncRelayCommand(owner => InstallCodexAsync(owner, update: true)))); + } + } +} diff --git a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs index 4e4e623f..1551bbb2 100644 --- a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs +++ b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs @@ -15,6 +15,7 @@ using Dock.Model.Mvvm.Controls; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using OneWare.Acp; using OneWare.Chat; using OneWare.CloudIntegration; using OneWare.Copilot; @@ -65,6 +66,7 @@ protected override void ConfigureModuleCatalog(OneWareModuleCatalog moduleCatalo moduleCatalog.AddModule(); moduleCatalog.AddModule(); moduleCatalog.AddModule(); + moduleCatalog.AddModule(); moduleCatalog.AddModule(); } diff --git a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index ba730d82..e7776b0e 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -30,6 +30,7 @@ + From 654f820f7355f7e0686e6008e03cce9cf570caa8 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Mon, 22 Jun 2026 12:16:48 +0200 Subject: [PATCH 2/2] progress --- src/OneWare.Acp/AcpModule.cs | 60 ++++--- src/OneWare.Acp/Services/AcpChatService.cs | 174 ++++++++++++++++++- src/OneWare.Acp/Services/CodexChatService.cs | 119 +++++++++++-- 3 files changed, 307 insertions(+), 46 deletions(-) diff --git a/src/OneWare.Acp/AcpModule.cs b/src/OneWare.Acp/AcpModule.cs index 6a5d94c2..b390323b 100644 --- a/src/OneWare.Acp/AcpModule.cs +++ b/src/OneWare.Acp/AcpModule.cs @@ -9,65 +9,66 @@ namespace OneWare.Acp; public class AcpModule : OneWareModuleBase { - private const string CodexVersion = "0.141.0"; - private const string CodexTag = "rust-v0.141.0"; + 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 = "codexcli", + Id = "codexacp", Type = "NativeTool", - Name = "Codex CLI", - Description = "OpenAI Codex agent with ACP support for OneWare Studio", + 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/openai/codex" }, - new PackageLink { Name = "Documentation", Url = "https://github.com/openai/codex#readme" } + 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 = CodexVersion, + Version = CodexAcpVersion, Targets = [ new PackageTarget { Target = "win-x64", - Url = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-x86_64-pc-windows-msvc.tar.gz", - AutoSetting = [new PackageAutoSetting { RelativePath = "codex.exe", SettingKey = CodexChatService.CodexPathKey }] + 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 = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-aarch64-pc-windows-msvc.tar.gz", - AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex.exe", SettingKey = CodexChatService.CodexPathKey }] + 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 = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-x86_64-unknown-linux-musl.tar.gz", - AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + 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 = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-aarch64-unknown-linux-musl.tar.gz", - AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + 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 = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-x86_64-apple-darwin.tar.gz", - AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + 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 = $"https://github.com/openai/codex/releases/download/{CodexTag}/codex-package-aarch64-apple-darwin.tar.gz", - AutoSetting = [new PackageAutoSetting { RelativePath = "bin/codex", SettingKey = CodexChatService.CodexPathKey }] + Url = $"{BaseUrl}/codex-acp-{CodexAcpVersion}-aarch64-apple-darwin.tar.gz", + AutoSetting = [new PackageAutoSetting { RelativePath = "codex-acp", SettingKey = CodexChatService.CodexPathKey }] } ] } @@ -83,22 +84,29 @@ public override void Initialize(IServiceProvider serviceProvider) { serviceProvider.Resolve().RegisterPackage(CodexPackage); - serviceProvider.Resolve().RegisterSetting( - "AI Chat", "Codex CLI", CodexChatService.CodexPathKey, + var settings = serviceProvider.Resolve(); + + settings.RegisterSetting( + "AI Chat", "Codex", CodexChatService.CodexPathKey, new FilePathSetting( - "Codex CLI Path", "", null, + "Codex ACP Path", "", null, serviceProvider.Resolve().NativeToolsDirectory, PlatformHelper.Exists, PlatformHelper.ExeFile) { - HoverDescription = "Path to the Codex CLI binary. Install it automatically via the Package Manager." + HoverDescription = "Path to the codex-acp binary. Install it automatically via the Package Manager." }); - var codex = serviceProvider.Resolve(); + 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); - // Kick off update check in the background — shows an update button in chat if needed _ = codex.CheckForUpdateAsync(); } } diff --git a/src/OneWare.Acp/Services/AcpChatService.cs b/src/OneWare.Acp/Services/AcpChatService.cs index e7f8c69e..43bacd67 100644 --- a/src/OneWare.Acp/Services/AcpChatService.cs +++ b/src/OneWare.Acp/Services/AcpChatService.cs @@ -1,11 +1,12 @@ 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; -using dotacp.protocol; +using dotacp.client.unstable; +using dotacp.protocol.unstable; using Microsoft.Extensions.Logging; using OneWare.Essentials.Models; using OneWare.Essentials.Services; @@ -29,9 +30,98 @@ public abstract class AcpChatService(IPaths paths, ILogger logger) /// 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; @@ -192,6 +282,11 @@ private async Task ConnectAsync() 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) => { @@ -233,11 +328,61 @@ private async Task ConnectAsync() if (initResponse.AuthMethods is { Length: > 0 } methods) { - var method = methods.OfType().FirstOrDefault(); - if (method != null) + // 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 = method.Id }) + 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( @@ -372,6 +517,23 @@ public Task RequestPermissionAsync( 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) @@ -462,7 +624,7 @@ public void OnDisconnected(Connection connection) EventReceived?.Invoke(this, new ChatErrorEvent( stderr is { Length: > 0 } ? $"Connection lost — agent output:\n{stderr}" - : "Connection lost. If you see 'stdin is not a terminal', try adding --acp to the agent startup arguments.")); + : "The connection to the agent was lost.")); } // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/src/OneWare.Acp/Services/CodexChatService.cs b/src/OneWare.Acp/Services/CodexChatService.cs index a5e1375d..f0ceaf0f 100644 --- a/src/OneWare.Acp/Services/CodexChatService.cs +++ b/src/OneWare.Acp/Services/CodexChatService.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using CommunityToolkit.Mvvm.Input; +using dotacp.protocol.unstable; using Microsoft.Extensions.Logging; using OneWare.Essentials.Enums; using OneWare.Essentials.Helpers; @@ -9,31 +10,35 @@ namespace OneWare.Acp.Services; /// -/// ACP chat service backed by the OpenAI Codex CLI. -/// The binary is managed by the OneWare package manager; no user settings are required. +/// ACP chat service backed by the zed-industries/codex-acp binary (OpenAI Codex via ACP). /// public sealed class CodexChatService( IPaths paths, ISettingsService settingsService, IPackageService packageService, IPackageWindowService packageWindowService, + ITerminalManagerService terminalManagerService, ILogger logger) : AcpChatService(paths, logger) { // Silently-stored path key — populated automatically by PackageAutoSetting after install. - // Not registered via ISettingsService.RegisterSetting so it never appears in the Settings UI. internal const string CodexPathKey = "AI_Chat_Codex_Path"; + /// + /// User-visible setting key for the OpenAI API key. + /// Registered in under "AI Chat → Codex". + /// + internal const string CodexApiKeySettingKey = "AI_Chat_Codex_ApiKey"; + private static readonly string CodexExe = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( System.Runtime.InteropServices.OSPlatform.Windows) - ? "codex.exe" - : "codex"; + ? "codex-acp.exe" + : "codex-acp"; public override string Name => "Codex"; - // Tell Codex to start in ACP server mode over stdin/stdout instead of interactive TUI mode. - protected override IEnumerable GetAgentArguments() => ["-- --acp"]; + // codex-acp starts in ACP server mode over stdin/stdout by default — no extra flags needed. protected override string? ResolveAgentPath() { @@ -42,19 +47,70 @@ public sealed class CodexChatService( if (PlatformHelper.Exists(fromPackage)) return PlatformHelper.GetFullPath(fromPackage); - // 2. Codex on the system PATH (e.g. `npm install -g @openai/codex`) + // 2. Codex on the system PATH return PlatformHelper.GetFullPath(CodexExe); } + protected override IReadOnlyDictionary GetEnvironmentVariables() + { + var dict = new Dictionary(); + var apiKey = settingsService.GetSettingValue(CodexApiKeySettingKey); + if (!string.IsNullOrWhiteSpace(apiKey)) + dict["OPENAI_API_KEY"] = apiKey; + return dict; + } + protected override void OnAgentNotFound() { - RaiseStatusChanged(new StatusEvent(false, "Codex not found")); + RaiseStatusChanged(new StatusEvent(false, "Codex ACP not found")); RaiseEventReceived(new ChatButtonEvent( - "Codex CLI is not installed. Download it via the Package Manager.", - "Install Codex CLI", + "codex-acp is not installed. Download it via the Package Manager.", + "Install Codex ACP", new AsyncRelayCommand(owner => InstallCodexAsync(owner)))); } + protected override void OnAuthRequired(AuthMethodEnvVar method, IReadOnlyList missingVars) + { + var link = method.Link; + var linkText = link != null ? $"\n\nGet your key at: {link}" : string.Empty; + RaiseStatusChanged(new StatusEvent(false, "OpenAI API key required")); + RaiseEventReceived(new ChatErrorEvent( + $"An OpenAI API key is required to use Codex. " + + $"Enter it in **Settings → AI Chat → Codex → OpenAI API Key**.{linkText}")); + } + + protected override void OnTerminalAuthRequired(AuthMethodTerminal method) + { + RaiseStatusChanged(new StatusEvent(false, "Login required")); + RaiseEventReceived(new ChatButtonEvent( + $"Login with your ChatGPT account to use Codex. " + + "A browser window will open to complete the sign-in.", + "Login with ChatGPT", + new AsyncRelayCommand(async _ => + { + await RunTerminalAuthAsync(method).ConfigureAwait(false); + await InitializeAsync().ConfigureAwait(false); + }))); + } + + protected override void OnAgentAuthRequired(AuthMethodAgent method) + { + var description = string.IsNullOrWhiteSpace(method.Description) + ? "Login with your ChatGPT account to use Codex. A browser window will open to complete the sign-in." + : method.Description; + var buttonLabel = string.IsNullOrWhiteSpace(method.Name) ? "Login with ChatGPT" : method.Name; + + RaiseStatusChanged(new StatusEvent(false, "Login required")); + RaiseEventReceived(new ChatButtonEvent( + description, + buttonLabel, + new AsyncRelayCommand(async _ => + { + RaiseStatusChanged(new StatusEvent(false, "Authenticating\u2026")); + await PerformAgentAuthAndOpenSessionAsync(method.Id).ConfigureAwait(false); + }))); + } + // ── Install / update ────────────────────────────────────────────────────── private async Task InstallCodexAsync(Control? owner, bool update = false) @@ -81,11 +137,46 @@ internal async Task CheckForUpdateAsync() if (packageService.Packages.TryGetValue(AcpModule.CodexPackage.Id!, out var state) && state.Status is PackageStatus.UpdateAvailable) { - RaiseStatusChanged(new StatusEvent(false, "Codex update available")); + RaiseStatusChanged(new StatusEvent(false, "Codex ACP update available")); RaiseEventReceived(new ChatButtonEvent( - "A Codex CLI update is available.", - "Update Codex CLI", + "A Codex ACP update is available.", + "Update Codex ACP", new AsyncRelayCommand(owner => InstallCodexAsync(owner, update: true)))); } } + + // ── Terminal auth ───────────────────────────────────────────────────────── + + private async Task RunTerminalAuthAsync(AuthMethodTerminal method) + { + var agentPath = ResolveAgentPath(); + if (agentPath == null) + { + RaiseEventReceived(new ChatErrorEvent("codex-acp binary not found. Please install it first.")); + return; + } + + // Build command: [extra args from AuthMethodTerminal] + var extraArgs = method.Args ?? []; + var argString = extraArgs.Length > 0 ? " " + string.Join(" ", extraArgs) : string.Empty; + var command = $"\"{agentPath}\"{argString}"; + + RaiseStatusChanged(new StatusEvent(false, "Waiting for login…")); + + var result = await terminalManagerService.ExecuteInTerminalAsync( + command, + id: "codex-auth", + showInUi: true, + timeout: TimeSpan.FromMinutes(10)).ConfigureAwait(false); + + if (result.TimedOut) + { + RaiseEventReceived(new ChatErrorEvent("Login timed out. Please try again.")); + } + else if (result.ExitCode != 0) + { + RaiseEventReceived(new ChatErrorEvent( + $"Login process exited with code {result.ExitCode}. Please try again.")); + } + } }