diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b15f48 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Root solution lives in `SemanticDeveloper/SemanticDeveloper.sln`; the primary desktop app sits under `SemanticDeveloper/SemanticDeveloper/`. +- UI resources are split between Avalonia XAML (`*.axaml`) and backing C# files (`*.axaml.cs`); dialogs reside in `Views/`, shared models in `Models/`, and service logic (Codex, Git, MCP, settings) in `Services/`. +- Static assets (icons, images) are in `SemanticDeveloper/SemanticDeveloper/Images/`; installer scaffolding lives in `SemanticDeveloper/Installers/`. + +## Build, Test, and Development Commands +- Restore & compile the app: `dotnet build SemanticDeveloper/SemanticDeveloper/SemanticDeveloper.csproj` (the installer projects lack entry points). +- Run the desktop app: `dotnet run --project SemanticDeveloper/SemanticDeveloper`. +- Update NuGet dependencies: `dotnet restore SemanticDeveloper/SemanticDeveloper/SemanticDeveloper.csproj`. + +## Coding Style & Naming Conventions +- Follow standard C# conventions: 4-space indentation, PascalCase for types, camelCase for locals/fields (prefix private fields with `_` when mutable). +- Keep Avalonia XAML tidy: align attributes, prefer named handlers declared in the paired code-behind. +- Favor expression-bodied members for single-line getters and avoid trailing whitespace; run `dotnet format` before large refactors. +- When logging to the CLI pane, use `AppendCliLog` for line entries and prefer the existing `System:` prefixes for system-generated lines. + +## Testing Guidelines +- No automated test project exists yet; add new tests alongside features if appropriate. +- For manual verification, exercise key flows: workspace selection, MCP server loading, Codex login (`codex auth login`), and session restarts. +- If you introduce automated tests, place them under a `Tests/` sibling folder and document the execution command (e.g., `dotnet test`). + +## Commit & Pull Request Guidelines +- Write concise, imperative commit subjects (`Add MCP startup summary`, `Fix Codex auth probe`), with optional body paragraphs for context. +- Reference issue IDs in the body when applicable and squash trivial commits before submitting a PR. +- Pull requests should include: a brief summary, testing notes, screenshots/GIFs for UI changes, and links to relevant issues or discussions. + +## Configuration & Security Notes +- User-specific MCP servers live at `~/.config/SemanticDeveloper/mcp_servers.json` (or `%AppData%\SemanticDeveloper\mcp_servers.json` on Windows); avoid committing sample credentials. +- API keys are configured through the app settings; never hard-code secrets—use environment variables or the settings dialog. diff --git a/README.md b/README.md index bcd2cf2..ce886bf 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,22 @@ ![Semantic Developer Img](/SemanticDeveloper/SemanticDeveloper/Images/SemanticDeveloperLogo.ico) -A cross‑platform desktop UI (Avalonia/.NET 8) for driving the Codex CLI using its JSON protocol. It lets you: +A cross‑platform desktop UI (Avalonia/.NET 8) for driving the Codex CLI app server using its JSON protocol. It lets you: - Select a workspace folder and browse files via a lazy file tree - Start a Codex session and stream assistant output in real time -- Send user input that is wrapped as protocol `Submission`s (proto) +- Send user input that is wrapped as protocol `Submission`s (app server) - Auto‑approve exec/patch requests (automatic) - Select a Codex profile (from `config.toml`) and load MCP servers from a JSON config – See live token usage and estimated context remaining in the header -> Important: This app always runs Codex in proto mode via the `proto` subcommand. +> Important: This app runs Codex through the `app-server` subcommand. ## Requirements - .NET SDK 8.0+ - Codex CLI installed and on `PATH` - - Verify with: `codex proto --help` + - Verify with: `codex app-server --help` - No external Git required — uses LibGit2Sharp for repo init/staging/commit ## Build & Run @@ -32,7 +32,7 @@ A cross‑platform desktop UI (Avalonia/.NET 8) for driving the Codex CLI using 1. Open the app, click “Select Workspace…” and choose a folder. - If it isn’t a git repo and the Git library is available, you’ll be prompted to initialize one. - You can also initialize later from the header via “Initialize Git…”. -2. Click “Restart Session” to launch `codex proto` in the workspace directory (a session also starts automatically after you select a workspace). +2. Click “Restart Session” to launch `codex app-server` in the workspace directory (a session also starts automatically after you select a workspace). 3. Type into the input box and press Enter to send. Output appears in the right panel. 4. “CLI Settings” lets you change: - Profile (from Codex `config.toml`) — passed via `-c profile=` @@ -154,7 +154,7 @@ Notes ## Conversation & Protocol Behavior -- Always uses proto mode: the app starts the CLI with `codex proto`. +- Always uses the Codex app server: the app starts the CLI with `codex app-server`. - User input is wrapped as a protocol `Submission` with a new `id` and an `op` payload: - Defaults to `user_input` with `items: [{ type: "text", text: "..." }]`. - When the app infers that a full turn is required, it sends `user_turn` and includes @@ -175,7 +175,7 @@ Notes - Returns to `idle` only when the server signals `task_complete` (or `turn_aborted`). - Stop vs. Restart: - - Stop sends a proto `interrupt` to abort the current turn (like pressing Esc in the CLI) without killing the session; it falls back to terminating the process if needed. + - Stop sends an `interrupt` to the app server to abort the current turn (like pressing Esc in the CLI) without killing the session; it falls back to terminating the process if needed. - Restart ends the current process and starts a fresh session in the same workspace. - Clear Log clears both the on‑screen log and the underlying editor document; it does not affect the session. @@ -206,12 +206,12 @@ Selection behavior: - Change selections, then click “Restart Session” to apply. ## Troubleshooting -- “Failed to start 'codex'”: Ensure the CLI is installed and on `PATH`. Test with `codex --help` and `codex proto --help`. +- “Failed to start 'codex'”: Ensure the CLI is installed and on `PATH`. Test with `codex --help` and `codex app-server --help`. - Model selection: Prefer using `config.toml` (via Profiles). You can set `model`, `model_provider`, and related options per the Codex docs. - Git init issues: The app uses LibGit2Sharp (no Git CLI needed). If the native lib fails to load, the app skips initialization. Commits use your configured name/email if available; otherwise a fallback signature is used. - Authentication: - - If you are not using an API key and the Codex CLI is not logged in (no `~/.codex/auth.json`), the proto stream returns 401. The app detects this and prompts to run `codex auth login` for you. Follow the browser flow; on success the app restarts the proto session automatically. + - If you are not using an API key and the Codex CLI is not logged in (no `~/.codex/auth.json`), the app-server stream returns 401. The app detects this and prompts to run `codex auth login` for you. Follow the browser flow; on success the app restarts the session automatically. - If your CLI version doesn’t support `auth login`, the app falls back to `codex login`. - When “Use API Key” is enabled in CLI Settings, the app attempts a non‑interactive `codex login --api-key ` before sessions and on 401. If login succeeds, it restarts the session automatically. @@ -235,7 +235,7 @@ Selection behavior: ## Notes -- Proto mode is enforced in code; the app does not fall back to non‑proto modes. +- App-server mode is enforced in code; the app does not fall back to legacy proto mode. - Settings are stored under the OS‑specific application data directory and loaded on startup. - The log view uses AvaloniaEdit + TextMate (Dark+) for better legibility and simple JSON syntax coloring. diff --git a/SemanticDeveloper/Installers/Linux/build_deb.sh b/SemanticDeveloper/Installers/Linux/build_deb.sh index 965a2ef..463f6cc 100755 --- a/SemanticDeveloper/Installers/Linux/build_deb.sh +++ b/SemanticDeveloper/Installers/Linux/build_deb.sh @@ -8,7 +8,7 @@ APP_PROJ="$ROOT/SemanticDeveloper/SemanticDeveloper.csproj" PUBLISH_DIR="$SCRIPT_DIR/out/publish" PKG_ROOT="$SCRIPT_DIR/pkgroot" DIST_DIR="$SCRIPT_DIR/dist" -VERSION="1.0.2" +VERSION="1.0.3" ARCH="amd64" if [[ "$RID" == "linux-arm64" ]]; then ARCH="arm64"; fi diff --git a/SemanticDeveloper/Installers/Linux/dist/semantic-developer_1.0.3_amd64.deb b/SemanticDeveloper/Installers/Linux/dist/semantic-developer_1.0.3_amd64.deb new file mode 100644 index 0000000..81c626e Binary files /dev/null and b/SemanticDeveloper/Installers/Linux/dist/semantic-developer_1.0.3_amd64.deb differ diff --git a/SemanticDeveloper/SemanticDeveloper/MainWindow.axaml.cs b/SemanticDeveloper/SemanticDeveloper/MainWindow.axaml.cs index 97e01b4..d77fdc4 100644 --- a/SemanticDeveloper/SemanticDeveloper/MainWindow.axaml.cs +++ b/SemanticDeveloper/SemanticDeveloper/MainWindow.axaml.cs @@ -29,20 +29,27 @@ using SemanticDeveloper.Services; using SemanticDeveloper.Views; using AvaloniaEdit; -using System.Collections.Generic; +using System.Collections.Concurrent; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace SemanticDeveloper; public partial class MainWindow : Window, INotifyPropertyChanged { private readonly CodexCliService _cli = new(); - private ProtoHelper.SubmissionShape _submissionShape = ProtoHelper.SubmissionShape.NestedInternallyTagged; - private ProtoHelper.ContentStyle _contentStyle = ProtoHelper.ContentStyle.Flattened; - private string _defaultMsgType = "user_turn"; private string? _currentModel; // Auto-approval UI removed; approvals require manual handling private AppSettings _settings = new(); + private long _nextRequestId; + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly List _configuredMcpServers = new(); + private JObject? _pendingConfigOverrides; + private bool _appServerInitialized; + private string? _conversationId; + private string? _conversationSubscriptionId; + private string? _currentWorkspacePath; private string _cliLog = string.Empty; private TextEditor? _logEditor; @@ -176,6 +183,15 @@ public MainWindow() { IsCliRunning = false; SessionStatus = "stopped"; + _appServerInitialized = false; + _conversationId = null; + _conversationSubscriptionId = null; + _currentModel = null; + foreach (var pending in _pendingRequests.Values) + { + pending.TrySetException(new InvalidOperationException("Codex CLI exited.")); + } + _pendingRequests.Clear(); }; // Lazy-load file tree nodes when expanded @@ -196,7 +212,7 @@ private void LoadMcpServersFromConfig() Services.McpConfigService.EnsureConfigExists(); if (!File.Exists(path)) return; var text = File.ReadAllText(path); - var json = Newtonsoft.Json.Linq.JObject.Parse(text); + var json = JObject.Parse(text); var existing = McpServers.ToDictionary(s => s.Name, s => s, StringComparer.OrdinalIgnoreCase); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -209,13 +225,13 @@ void AddOrUpdate(string name) McpServers.Add(new McpServerEntry { Name = name, Selected = true }); } - if (json["mcpServers"] is Newtonsoft.Json.Linq.JObject map) + if (json["mcpServers"] is JObject map) { foreach (var p in map.Properties()) AddOrUpdate(p.Name); } - else if (json["servers"] is Newtonsoft.Json.Linq.JArray arr) + else if (json["servers"] is JArray arr) { - foreach (var s in arr.OfType()) AddOrUpdate(s["name"]?.ToString() ?? string.Empty); + foreach (var s in arr.OfType()) AddOrUpdate(s["name"]?.ToString() ?? string.Empty); } // Remove entries no longer present @@ -500,6 +516,12 @@ private void AppendCliInline(string text) } } + private void AppendCliLogVerbose(string text) + { + if (!_verboseLogging) return; + AppendCliLog(text); + } + private void AutoScrollLogIfNeeded() { if (!_logAutoScroll) return; @@ -545,66 +567,68 @@ private void OnLogScrollOffsetChanged(object? sender, EventArgs e) private void OnCliOutput(object? sender, string line) { - // Hide noisy agent logs (unless verbose) - if (!_verboseLogging && LooksLikeAgentLog(line)) return; - // Hide verbose function call apply_patch payloads (unless verbose) - if (!_verboseLogging && IsFunctionCallApplyPatch(line)) { SetStatusSafe("thinking…"); AppendCliLog("System: Applying patch…"); return; } + if (string.IsNullOrEmpty(line)) + return; - // Prefer pretty rendering for protocol JSON events; fallback to raw text - var handled = TryRenderProtocolEvent(line); - if (!handled) - { - AppendCliLog(line); - } - // Auto-approval disabled; do not auto-respond to approval requests - var l = line.ToLowerInvariant(); - if (l.Contains("expected internally tagged enum op") && _submissionShape == ProtoHelper.SubmissionShape.TopLevelInternallyTagged) - { - _submissionShape = ProtoHelper.SubmissionShape.NestedInternallyTagged; - AppendCliLog("System: Switched submission shape to nested 'op' to satisfy proto parser."); - } - if (l.Contains("unknown field `type`") || l.Contains("missing field `type`")) + if (TryHandleAppServerMessage(line)) + return; + + AppendCliLog(line); + } + + private bool TryHandleAppServerMessage(string line) + { + JObject root; + try { root = JObject.Parse(line); } + catch { return false; } + + if (root.TryGetValue("method", out var methodToken)) { - _contentStyle = ProtoHelper.ContentStyle.Flattened; - _defaultMsgType = "user_input"; - AppendCliLog("System: Setting style=flattened and type='user_input'."); + var method = methodToken?.ToString() ?? string.Empty; + if (root.ContainsKey("id")) + { + _ = HandleServerRequestAsync(root); + } + else + { + HandleServerNotification(method, root["params"] as JObject); + } + return true; } - if (l.Contains("unknown field `msg`") || l.Contains("missing field `msg`")) + + if (root.ContainsKey("id")) { - _contentStyle = ProtoHelper.ContentStyle.Flattened; - AppendCliLog("System: Switching content style to flattened (no 'msg' field)."); + HandleServerResponse(root); + return true; } - if (l.Contains("missing field `items`")) + + if (root["error"] is JObject errorObj) { - _defaultMsgType = "user_turn"; - AppendCliLog("System: Setting message type to 'user_turn' with items."); + var message = errorObj["message"]?.ToString() ?? "Unknown Codex error"; + AppendCliLog("System: ERROR: " + message); + SetStatusSafe("error"); + return true; } - // Try capture model from session_configured messages - TryUpdateModelFromJson(line); - } - - private bool LooksLikeAgentLog(string line) - { - if (string.IsNullOrEmpty(line)) return false; - if (char.IsDigit(line.FirstOrDefault()) && line.Contains(" codex_core::")) return true; return false; } - private bool IsFunctionCallApplyPatch(string line) - => line.Contains("FunctionCall: shell(") && line.Contains("\"apply_patch\""); - - private bool TryRenderProtocolEvent(string line) + private bool TryRenderProtocolEvent(JObject root) { - var json = line.Trim(); - if (!json.StartsWith("{")) return false; - Newtonsoft.Json.Linq.JObject? root; - try { root = (Newtonsoft.Json.Linq.JObject?)Newtonsoft.Json.Linq.JToken.Parse(json); } - catch { return false; } - if (root is null) return false; + JObject? msg = root["msg"] as JObject; + if (msg is null) + { + if (root.TryGetValue("type", out var typeCandidate) && typeCandidate?.Type == JTokenType.String) + { + msg = root; + } + else + { + return false; + } + } - var msg = root["msg"] as Newtonsoft.Json.Linq.JObject; - var type = msg? ["type"]?.ToString(); + var type = msg["type"]?.ToString(); if (string.IsNullOrWhiteSpace(type)) return false; var lower = type.ToLowerInvariant(); @@ -679,7 +703,7 @@ private bool TryRenderProtocolEvent(string line) { try { - var inv = msg? ["invocation"] as Newtonsoft.Json.Linq.JObject; + var inv = msg? ["invocation"] as JObject; var server = inv? ["server"]?.ToString(); var tool = inv? ["tool"]?.ToString(); if (!string.IsNullOrWhiteSpace(server) && !string.IsNullOrWhiteSpace(tool)) @@ -692,11 +716,11 @@ private bool TryRenderProtocolEvent(string line) { try { - var inv = msg? ["invocation"] as Newtonsoft.Json.Linq.JObject; + var inv = msg? ["invocation"] as JObject; var server = inv? ["server"]?.ToString(); var tool = inv? ["tool"]?.ToString(); // Handle success/error across variants (is_error/isError, Err, error, success=false) - var res = msg? ["result"] as Newtonsoft.Json.Linq.JObject; + var res = msg? ["result"] as JObject; bool isError = false; string? errorMsg = null; int contentCount = -1; @@ -712,21 +736,21 @@ private bool TryRenderProtocolEvent(string line) { isError = true; var e = res["Err"]; - errorMsg = e?.Type == Newtonsoft.Json.Linq.JTokenType.String ? e?.ToString() : e?.ToString(Newtonsoft.Json.Formatting.None); + errorMsg = e?.Type == JTokenType.String ? e?.ToString() : e?.ToString(Newtonsoft.Json.Formatting.None); } // Common error keys if (!isError && res["error"] != null) { isError = true; var e = res["error"]; - errorMsg = e?.Type == Newtonsoft.Json.Linq.JTokenType.String ? e?.ToString() : e?.ToString(Newtonsoft.Json.Formatting.None); + errorMsg = e?.Type == JTokenType.String ? e?.ToString() : e?.ToString(Newtonsoft.Json.Formatting.None); } // success flag if (!isError && res["success"] != null && bool.TryParse(res["success"]?.ToString(), out var succ)) isError = !succ; try { - if (res["content"] is Newtonsoft.Json.Linq.JArray arr) + if (res["content"] is JArray arr) contentCount = arr.Count; } catch { } @@ -737,7 +761,7 @@ private bool TryRenderProtocolEvent(string line) { hasStructured = true; var resultsNode = sc["results"]; - if (resultsNode is Newtonsoft.Json.Linq.JArray arr2) + if (resultsNode is JArray arr2) structuredResults = arr2.Count; } } @@ -763,20 +787,20 @@ private bool TryRenderProtocolEvent(string line) if (!isError && _showMcpResultsInLog && (!_showMcpResultsOnlyWhenNoEdits || (!_turnSawExec && !_turnSawPatch))) { // Prefer structured_content.results list with title/url - var resObj = msg? ["result"] as Newtonsoft.Json.Linq.JObject; + var resObj = msg? ["result"] as JObject; var sc = (resObj? ["structured_content"]) ?? (resObj? ["structuredContent"]); - var results = sc? ["results"] as Newtonsoft.Json.Linq.JArray; + var results = sc? ["results"] as JArray; if (results != null && results.Count > 0) { // Local helpers to normalize field names across servers - string? GetTitle(Newtonsoft.Json.Linq.JObject it) + string? GetTitle(JObject it) { return it["title"]?.ToString() ?? it["Title"]?.ToString() ?? it["item1"]?.ToString() ?? it["Item1"]?.ToString(); } - string? GetUrl(Newtonsoft.Json.Linq.JObject it) + string? GetUrl(JObject it) { return it["url"]?.ToString() ?? it["Url"]?.ToString() @@ -790,7 +814,7 @@ private bool TryRenderProtocolEvent(string line) AppendCliLog($"Results ({server}.{tool}):"); for (int i = 0; i < max; i++) { - if (results[i] is Newtonsoft.Json.Linq.JObject item) + if (results[i] is JObject item) { var title = GetTitle(item); var url = GetUrl(item); @@ -808,12 +832,12 @@ private bool TryRenderProtocolEvent(string line) // Fallback: show first text content block if present var contentTok = resObj? ["content"]; string? text = null; - if (contentTok is Newtonsoft.Json.Linq.JArray contentArr) + if (contentTok is JArray contentArr) { - var textBlock = contentArr.FirstOrDefault(t => (t?["type"]?.ToString() ?? string.Empty).Equals("text", StringComparison.OrdinalIgnoreCase)) as Newtonsoft.Json.Linq.JObject; + var textBlock = contentArr.FirstOrDefault(t => (t?["type"]?.ToString() ?? string.Empty).Equals("text", StringComparison.OrdinalIgnoreCase)) as JObject; text = textBlock? ["text"]?.ToString(); } - else if (contentTok is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String) + else if (contentTok is JValue v && v.Type == JTokenType.String) { text = v.ToString(); } @@ -856,8 +880,8 @@ private bool TryRenderProtocolEvent(string line) { try { - var tools = msg? ["tools"] as Newtonsoft.Json.Linq.JObject; - var toolArray = msg? ["tools"] as Newtonsoft.Json.Linq.JArray; + var tools = msg? ["tools"] as JObject; + var toolArray = msg? ["tools"] as JArray; var names = new List(); if (tools != null) { @@ -972,7 +996,7 @@ private bool TryRenderProtocolEvent(string line) } case "exec_command_begin": { - var cmdArr = msg? ["command"] as Newtonsoft.Json.Linq.JArray; + var cmdArr = msg? ["command"] as JArray; var callId = msg? ["call_id"]?.ToString(); var tokens = cmdArr is null ? new System.Collections.Generic.List() : cmdArr.Select(t => t?.ToString() ?? string.Empty).ToList(); _turnSawExec = true; @@ -1042,38 +1066,7 @@ private bool TryRenderProtocolEvent(string line) return true; case "exec_approval_request": case "apply_patch_approval_request": - { - try - { - var eventId = root["id"]?.ToString(); - if (!string.IsNullOrWhiteSpace(eventId)) - { - var op = lower.StartsWith("exec") ? "exec_approval" : "patch_approval"; - var approvalJson = Services.ProtoHelper.BuildApproval(op, eventId!, "approved", _submissionShape); - _ = _cli.SendAsync(approvalJson); - var summary = string.Empty; - if (lower.StartsWith("exec")) - { - var cmdArr = msg? ["command"] as Newtonsoft.Json.Linq.JArray; - if (cmdArr != null && cmdArr.Count > 0) - { - var tokens = cmdArr.Select(t => t?.ToString() ?? string.Empty).ToList(); - if (tokens.Any(t => t.StartsWith("*** Begin Patch")) || tokens.Contains("apply_patch")) - summary = "apply_patch"; - else - summary = string.Join(" ", tokens.Take(5)); - } - } - if (!string.IsNullOrEmpty(summary)) - { - if (!CliLog.EndsWith("\n")) AppendCliInline(Environment.NewLine); - AppendCliLog($"System: Auto-approved {op}: {summary}"); - } - } - } - catch { } - return true; - } + return true; case "task_started": if (!CliLog.EndsWith("\n")) AppendCliInline(Environment.NewLine); AppendCliLog("System: Task started"); @@ -1122,33 +1115,11 @@ private void SetStatusSafe(string status) Dispatcher.UIThread.Post(() => SessionStatus = status); } - private void TryUpdateModelFromJson(string line) + private void UpdateTokenStats(JObject msg) { - try - { - var json = line.Trim(); - if (!json.StartsWith("{")) return; - var token = Newtonsoft.Json.Linq.JToken.Parse(json); - if (token is not Newtonsoft.Json.Linq.JObject obj) return; - var msg = obj["msg"] as Newtonsoft.Json.Linq.JObject; - if (msg? ["type"]?.ToString() == "session_configured") - { - var model = msg["model"]?.ToString(); - if (!string.IsNullOrWhiteSpace(model)) - { - _currentModel = model; - AppendCliLog($"System: Detected model: {_currentModel}"); - } - } - } - catch { } - } - - private void UpdateTokenStats(Newtonsoft.Json.Linq.JObject msg) - { - var info = msg["info"] as Newtonsoft.Json.Linq.JObject; + var info = msg["info"] as JObject; if (info is null) return; - var total = info["total_token_usage"] as Newtonsoft.Json.Linq.JObject; + var total = info["total_token_usage"] as JObject; if (total is null) return; long input = total.Value("input_tokens") ?? 0L; @@ -1156,7 +1127,7 @@ private void UpdateTokenStats(Newtonsoft.Json.Linq.JObject msg) long output = total.Value("output_tokens") ?? 0L; long blendedTotal = Math.Max(0, (input - cached)) + output; - var modelCw = (info["model_context_window"]?.Type == Newtonsoft.Json.Linq.JTokenType.Integer) + var modelCw = (info["model_context_window"]?.Type == JTokenType.Integer) ? info.Value("model_context_window") : null; @@ -2149,7 +2120,6 @@ private async Task RestartCliAsync() SessionStatus = "starting…"; try { - // Build profile flag if selected (use config override to avoid unsupported --profile on proto) var prevArgs = _cli.AdditionalArgs; var effectiveArgs = new System.Text.StringBuilder(); if (!string.IsNullOrWhiteSpace(_settings.SelectedProfile)) @@ -2157,25 +2127,48 @@ private async Task RestartCliAsync() effectiveArgs.Append("-c profile=").Append(_settings.SelectedProfile).Append(' '); AppendCliLog($"System: Using profile '{_settings.SelectedProfile}'"); } - if (IsMcpEnabled) + + _pendingConfigOverrides = null; + if (IsMcpEnabled) + { + try { - try + Services.McpConfigService.EnsureConfigExists(); + var overrides = BuildMcpConfigOverrides(out var serverNames); + if (overrides is not null) { - Services.McpConfigService.EnsureConfigExists(); - var flags = BuildMcpServersFlags(); - if (!string.IsNullOrWhiteSpace(flags)) + _pendingConfigOverrides = overrides; + if (_verboseLogging) { - effectiveArgs.Append(flags).Append(' '); - AppendCliLog("System: Injected MCP servers from config."); + if (serverNames.Count == 1) + { + AppendCliLog($"System: Injected MCP server '{serverNames[0]}'."); + } + else if (serverNames.Count > 1) + { + AppendCliLog($"System: Injected MCP servers: {string.Join(", ", serverNames)}."); + } + else + { + AppendCliLog("System: Injected MCP servers from config."); + } } - else AppendCliLog("System: No MCP servers configured; skipping."); } - catch (Exception ex) + else if (serverNames.Count == 0) + { + AppendCliLog("System: No MCP servers configured; skipping."); + } + else if (_verboseLogging) { - AppendCliLog("System: Failed to prepare MCP flags: " + ex.Message); + AppendCliLog("System: Injected MCP servers from config."); + } + } + catch (Exception ex) + { + AppendCliLog("System: Failed to prepare MCP config: " + ex.Message); } } - // Apply args composed from profile + MCP flags only (AdditionalArgs deprecated) + // Apply args composed from profile overrides only (AdditionalArgs deprecated) var composed = effectiveArgs.ToString().Trim(); _cli.AdditionalArgs = composed; @@ -2249,17 +2242,29 @@ private async Task RestartCliAsync() await _cli.StartAsync(CurrentWorkspacePath, CancellationToken.None); _cli.AdditionalArgs = prevArgs; // restore original IsCliRunning = _cli.IsRunning; - SessionStatus = IsCliRunning ? "idle" : "stopped"; - // Request MCP tools list once session is up (if enabled) - if (IsMcpEnabled && IsCliRunning) + if (!IsCliRunning) { - try - { - var msg = Services.ProtoHelper.BuildListMcpTools(); - await _cli.SendAsync(msg); - AppendCliLog("System: Requested MCP tools…"); - } - catch { } + SessionStatus = "stopped"; + return; + } + + _pendingRequests.Clear(); + _nextRequestId = 0; + _appServerInitialized = false; + _conversationId = null; + _conversationSubscriptionId = null; + + try + { + await InitializeAppServerAsync(); + SessionStatus = "idle"; + } + catch (Exception initEx) + { + AppendCliLog("System: Failed to initialize Codex: " + initEx.Message); + SessionStatus = "error"; + _cli.Stop(); + IsCliRunning = false; } } catch (Exception ex) @@ -2269,13 +2274,340 @@ private async Task RestartCliAsync() } } + private async Task InitializeAppServerAsync() + { + if (!_cli.IsRunning) + throw new InvalidOperationException("CLI is not running."); + + var version = typeof(MainWindow).Assembly?.GetName().Version?.ToString() ?? "dev"; + var initParams = new JObject + { + ["clientInfo"] = new JObject + { + ["name"] = "semantic-developer", + ["title"] = "Semantic Developer", + ["version"] = version + } + }; + + await SendRequestAsync("initialize", initParams); + _appServerInitialized = true; + AppendCliLog("System: Codex app server ready."); + + await StartConversationAsync(); + } + + private async Task StartConversationAsync() + { + if (!_appServerInitialized) + throw new InvalidOperationException("Codex app server not initialized."); + + var conversationParams = new JObject + { + ["approvalPolicy"] = "on-request", + ["sandbox"] = "workspace-write" + }; + + if (_pendingConfigOverrides is { }) + conversationParams["config"] = (JObject)_pendingConfigOverrides.DeepClone(); + + if (!string.IsNullOrWhiteSpace(_settings.SelectedProfile)) + conversationParams["profile"] = _settings.SelectedProfile; + + if (!string.IsNullOrWhiteSpace(CurrentWorkspacePath)) + conversationParams["cwd"] = CurrentWorkspacePath; + + var response = await SendRequestAsync("newConversation", conversationParams); + if (response is JObject obj) + { + _conversationId = obj["conversationId"]?.ToString() ?? _conversationId; + var model = obj["model"]?.ToString(); + if (!string.IsNullOrWhiteSpace(model)) + { + _currentModel = model; + AppendCliLog($"System: Model • {model}"); + } + } + + await SubscribeToConversationAsync(); + _ = RefreshMcpToolInventoryAsync(); + } + + private async Task SubscribeToConversationAsync() + { + if (string.IsNullOrWhiteSpace(_conversationId)) + return; + + var response = await SendRequestAsync( + "addConversationListener", + new JObject { ["conversationId"] = _conversationId } + ); + + if (response is JObject obj) + { + _conversationSubscriptionId = obj["subscriptionId"]?.ToString(); + } + } + + private void HandleServerResponse(JObject response) + { + if (!response.TryGetValue("id", out var idToken)) + return; + + if (!TryGetRequestId(idToken, out var id)) + return; + + if (!_pendingRequests.TryRemove(id, out var tcs)) + return; + + if (response["error"] is JObject error) + { + var message = error["message"]?.ToString() ?? "Unknown Codex error"; + tcs.TrySetException(new InvalidOperationException(message)); + if (!CliLog.EndsWith("\n")) AppendCliInline(Environment.NewLine); + AppendCliLog("System: ERROR: " + message); + if (message.IndexOf("401", StringComparison.OrdinalIgnoreCase) >= 0) + _ = HandleUnauthorizedAsync(); + SetStatusSafe("error"); + } + else + { + tcs.TrySetResult(response["result"] ?? JValue.CreateNull()); + } + } + + private void HandleServerNotification(string method, JObject? parameters) + { + if (string.IsNullOrEmpty(method)) + return; + + switch (method) + { + case "authStatusChange": + break; + case "loginChatGptComplete": + if (parameters? ["success"]?.Value() == true) + AppendCliLog("System: ChatGPT login completed."); + else if (parameters != null) + AppendCliLog("System: ChatGPT login failed: " + (parameters["error"]?.ToString() ?? "unknown error")); + break; + case "sessionConfigured": + if (parameters is not null) + HandleSessionConfiguredNotification(parameters); + break; + default: + if (method.StartsWith("codex/event/", StringComparison.Ordinal)) + { + if (parameters is JObject evt) + { + var handled = TryRenderProtocolEvent(evt); + if (!handled && _verboseLogging) + { + AppendCliLog(evt.ToString(Formatting.None)); + } + } + } + else if (_verboseLogging) + { + AppendCliLog($"System: Notification {method}: {parameters?.ToString(Formatting.None) ?? string.Empty}"); + } + break; + } + } + + private void HandleSessionConfiguredNotification(JObject payload) + { + try + { + var sessionId = payload["sessionId"]?.ToString(); + if (!string.IsNullOrWhiteSpace(sessionId)) + _conversationId = sessionId; + + var model = payload["model"]?.ToString(); + if (!string.IsNullOrWhiteSpace(model)) + { + _currentModel = model; + AppendCliLog($"System: Session model • {model}"); + } + + if (payload["initialMessages"] is JArray initial) + { + foreach (var item in initial.OfType()) + { + _ = TryRenderProtocolEvent(item); + } + } + } + catch { } + } + + private Task HandleServerRequestAsync(JObject request) + { + return Task.Run(async () => + { + var method = request["method"]?.ToString() ?? string.Empty; + var idToken = request["id"]; + if (idToken is null) + return; + + try + { + switch (method) + { + case "execCommandApproval": + LogApprovalSummary("exec", request["params"] as JObject); + await SendServerResponseAsync(idToken, new JObject { ["decision"] = "approved" }); + break; + case "applyPatchApproval": + LogApprovalSummary("patch", request["params"] as JObject); + await SendServerResponseAsync(idToken, new JObject { ["decision"] = "approved" }); + break; + default: + AppendCliLog($"System: Unhandled request '{method}'"); + await SendServerErrorAsync(idToken, -32601, $"Unsupported request '{method}'"); + break; + } + } + catch (Exception ex) + { + AppendCliLog("System: Failed to respond to request: " + ex.Message); + } + }); + } + + private async Task SendServerResponseAsync(JToken idToken, JObject result) + { + var response = new JObject + { + ["id"] = idToken.DeepClone(), + ["result"] = result + }; + await _cli.SendAsync(response.ToString(Formatting.None)).ConfigureAwait(false); + } + + private async Task SendServerErrorAsync(JToken idToken, int code, string message) + { + var response = new JObject + { + ["id"] = idToken.DeepClone(), + ["error"] = new JObject + { + ["code"] = code, + ["message"] = message + } + }; + await _cli.SendAsync(response.ToString(Formatting.None)).ConfigureAwait(false); + } + + private void LogApprovalSummary(string kind, JObject? parameters) + { + if (parameters is null) + return; + + try + { + string summary = string.Empty; + if (kind == "exec") + { + var cmd = parameters["command"] as JArray; + if (cmd != null) + { + var tokens = cmd.Select(t => t?.ToString() ?? string.Empty).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(); + if (tokens.Count > 0) + summary = string.Join(" ", tokens.Take(5)); + } + } + else if (kind == "patch") + { + var files = parameters["file_changes"] as JObject; + if (files != null) + { + summary = $"{files.Properties().Count()} file(s)"; + } + } + + if (!string.IsNullOrEmpty(summary)) + { + if (!CliLog.EndsWith("\n")) AppendCliInline(Environment.NewLine); + AppendCliLog($"System: Auto-approved {kind}: {summary}"); + } + } + catch { } + } + + private async Task SendRequestAsync(string method, JObject? parameters = null, CancellationToken cancellationToken = default) + { + if (!_cli.IsRunning) + throw new InvalidOperationException("CLI is not running."); + + var id = Interlocked.Increment(ref _nextRequestId); + var request = new JObject + { + ["id"] = id, + ["method"] = method + }; + + if (parameters != null && parameters.HasValues) + request["params"] = parameters; + + var payload = request.ToString(Formatting.None); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[id] = tcs; + + try + { + await _cli.SendAsync(payload).ConfigureAwait(false); + } + catch (Exception sendEx) + { + _pendingRequests.TryRemove(id, out _); + tcs.TrySetException(sendEx); + } + + if (cancellationToken.CanBeCanceled) + { + cancellationToken.Register(() => + { + if (_pendingRequests.TryRemove(id, out var source)) + source.TrySetCanceled(); + }); + } + + return await tcs.Task.ConfigureAwait(false); + } + + private static bool TryGetRequestId(JToken token, out long id) + { + id = 0; + try + { + id = token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.String when long.TryParse(token.Value(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => id + }; + return id != 0 || token.Type == JTokenType.Integer; + } + catch + { + return false; + } + } + private async Task InterruptCliAsync() { if (!_cli.IsRunning) return; try { - var interrupt = Services.ProtoHelper.BuildInterrupt(); - await _cli.SendAsync(interrupt); + if (!string.IsNullOrWhiteSpace(_conversationId)) + { + await SendRequestAsync( + "interruptConversation", + new JObject { ["conversationId"] = _conversationId } + ); + } SetStatusSafe("idle"); } catch @@ -2286,106 +2618,515 @@ private async Task InterruptCliAsync() } } - private static string EscapeForCli(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); - private static string BuildJsonArray(IEnumerable items) - { - var sb = new System.Text.StringBuilder(); - sb.Append('['); - bool first = true; - foreach (var it in items) - { - if (!first) sb.Append(','); first = false; - var v = it.Replace("\\", "\\\\").Replace("\"", "\\\""); - sb.Append('"').Append(v).Append('"'); - } - sb.Append(']'); - return sb.ToString(); - } - - private string BuildMcpServersFlags() + private JObject? BuildMcpConfigOverrides(out List serverNames) { + _configuredMcpServers.Clear(); + var included = new HashSet(StringComparer.OrdinalIgnoreCase); + serverNames = new List(); try { var path = Services.McpConfigService.GetConfigPath(); - if (!File.Exists(path)) return string.Empty; - var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + if (!File.Exists(path)) + return null; - // Support new format: { "mcpServers": { "name": { command, args, cwd?, env?, enabled? }, ... } } - // and legacy format: { "servers": [ { name, command, args, cwd?, env?, enabled? }, ... ] } - var parts = new List(); - - // Selected servers set + var root = JObject.Parse(File.ReadAllText(path)); var selected = new HashSet(McpServers.Where(s => s.Selected).Select(s => s.Name), StringComparer.OrdinalIgnoreCase); bool HasSelection() => selected.Count > 0; - if (json["mcpServers"] is Newtonsoft.Json.Linq.JObject map) + var result = new JObject(); + var mcpServersObj = new JObject(); + result["mcp_servers"] = mcpServersObj; + + void AddServer(string rawName, JObject definition) + { + if (definition is null) return; + + var enabledTok = definition["enabled"]; + if (enabledTok != null && bool.TryParse(enabledTok.ToString(), out var enabled) && !enabled) + return; + + var name = SanitizeServerName(rawName); + if (string.IsNullOrWhiteSpace(name)) + return; + if (HasSelection() && !selected.Contains(name)) + return; + + var commandText = definition["command"]?.ToString(); + var urlText = definition["url"]?.ToString(); + if (string.IsNullOrWhiteSpace(commandText) && string.IsNullOrWhiteSpace(urlText)) + return; + + var serverObj = new JObject(); + var def = new McpServerDefinition { Name = name }; + + if (!string.IsNullOrWhiteSpace(commandText)) + { + commandText = commandText.Trim(); + serverObj["command"] = commandText; + def.Command = commandText; + + if (definition["args"] is JArray argsArray && argsArray.Count > 0) + { + var args = argsArray + .Select(a => a?.ToString() ?? string.Empty) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .ToList(); + if (args.Count > 0) + { + serverObj["args"] = new JArray(args); + def.Args.AddRange(args); + } + } + else if (definition["args"] is JValue argValue && argValue.Type == JTokenType.String) + { + var argText = argValue.ToString(); + if (!string.IsNullOrWhiteSpace(argText)) + { + serverObj["args"] = new JArray(argText); + def.Args.Add(argText); + } + } + + var cwdToken = definition["cwd"]; + if (cwdToken != null && !string.IsNullOrWhiteSpace(cwdToken.ToString())) + { + var cwd = cwdToken.ToString(); + serverObj["cwd"] = cwd; + def.Cwd = cwd; + } + } + else if (!string.IsNullOrWhiteSpace(urlText)) + { + urlText = urlText.Trim(); + serverObj["url"] = urlText; + def.Url = urlText; + var bearerToken = definition["bearer_token"]?.ToString(); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + serverObj["bearer_token"] = bearerToken; + def.BearerToken = bearerToken; + } + } + + var startupSecToken = definition["startup_timeout_sec"]; + if (startupSecToken != null && double.TryParse(startupSecToken.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var startupSec)) + { + serverObj["startup_timeout_sec"] = startupSec; + def.StartupTimeoutSec = startupSec; + } + + var startupMsToken = definition["startup_timeout_ms"]; + if (startupMsToken != null && long.TryParse(startupMsToken.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var startupMs)) + serverObj["startup_timeout_ms"] = startupMs; + + var toolTimeoutToken = definition["tool_timeout_sec"]; + if (toolTimeoutToken != null && double.TryParse(toolTimeoutToken.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var toolTimeout)) + { + serverObj["tool_timeout_sec"] = toolTimeout; + def.ToolTimeoutSec = toolTimeout; + } + + if (definition["env"] is JObject envObj && envObj.Properties().Any()) + { + var env = new JObject(); + foreach (var prop in envObj.Properties()) + { + var value = prop.Value?.ToString() ?? string.Empty; + env[prop.Name] = value; + def.Env[prop.Name] = value; + } + serverObj["env"] = env; + } + + if (!serverObj.HasValues) + return; + + mcpServersObj[name] = serverObj; + _configuredMcpServers.Add(def); + included.Add(name); + } + + if (root["mcpServers"] is JObject map) { - foreach (var prop in map.Properties()) + foreach (var property in map.Properties()) { - var name = prop.Name.Trim(); - if (prop.Value is not Newtonsoft.Json.Linq.JObject s) continue; - if (!HasSelection() || selected.Contains(name)) - AppendServerFlags(parts, name, s); + if (property.Value is JObject definition) + AddServer(property.Name, definition); } } - else if (json["servers"] is Newtonsoft.Json.Linq.JArray serversArr) + else if (root["servers"] is JArray legacy) { - foreach (var s in serversArr.OfType()) + foreach (var definition in legacy.OfType()) { - var name = (s["name"]?.ToString() ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(name)) continue; - if (!HasSelection() || selected.Contains(name)) - AppendServerFlags(parts, name, s); + var name = definition["name"]?.ToString() ?? string.Empty; + AddServer(name, definition); } } - if (parts.Count == 0) return string.Empty; - return string.Join(' ', parts); + + if (root["experimental_use_rmcp_client"] is JToken rmcpToken) + { + if (rmcpToken.Type == JTokenType.Boolean) + result["experimental_use_rmcp_client"] = rmcpToken.Value(); + else if (bool.TryParse(rmcpToken.ToString(), out var rmcp)) + result["experimental_use_rmcp_client"] = rmcp; + } + + if (!included.Any()) + { + _configuredMcpServers.Clear(); + return null; + } + + serverNames = included.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); + return result; + } + catch + { + _configuredMcpServers.Clear(); + included.Clear(); } - catch { return string.Empty; } + + serverNames = included.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); + return null; } - private static void AppendServerFlags(List parts, string rawName, Newtonsoft.Json.Linq.JObject s) + private Task RefreshMcpToolInventoryAsync() { - try + var servers = _configuredMcpServers.Select(s => s.Clone()).ToList(); + if (servers.Count == 0) { - var enabledTok = s["enabled"]; - if (enabledTok != null && bool.TryParse(enabledTok.ToString(), out var en) && !en) return; - if (string.IsNullOrWhiteSpace(rawName)) return; - var name = new string(rawName.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '-').ToArray()); + Dispatcher.UIThread.Post(() => + { + foreach (var entry in McpServers) + entry.Tools.Clear(); + }); + return Task.CompletedTask; + } - // Only stdio/local servers are supported by Codex CLI: require command - var cmd = (s["command"]?.ToString() ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(cmd)) return; + return Task.Run(async () => + { + var results = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var successServers = new HashSet(StringComparer.OrdinalIgnoreCase); + var discoverySkipped = new HashSet(StringComparer.OrdinalIgnoreCase); + var logs = new List<(string Message, bool Always)>(); - // args can be array or single string - IEnumerable args = Enumerable.Empty(); - if (s["args"] is Newtonsoft.Json.Linq.JArray arr) + foreach (var server in servers) { - args = arr.Select(a => a?.ToString() ?? string.Empty); + try + { + if (!string.IsNullOrWhiteSpace(server.Url)) + { + logs.Add(($"System: MCP server '{server.Name}' uses streamable transport; tool discovery is not yet supported.", true)); + results[server.Name] = new List(); + discoverySkipped.Add(server.Name); + continue; + } + + var tools = await TryFetchToolsAsync(server, CancellationToken.None).ConfigureAwait(false); + results[server.Name] = tools; + successServers.Add(server.Name); + if (tools.Count > 0) + logs.Add(($"System: MCP tools • {server.Name}: {string.Join(", ", tools)}", false)); + else + logs.Add(($"System: MCP tools • {server.Name}: none detected", false)); + } + catch (Exception ex) + { + logs.Add(($"System: MCP tool probe failed for '{server.Name}': {ex.Message}", true)); + results[server.Name] = new List(); + } } - else if (s["args"] is Newtonsoft.Json.Linq.JValue val && val.Type == Newtonsoft.Json.Linq.JTokenType.String) + + Dispatcher.UIThread.Post(() => { - args = new[] { val.ToString() }; + try + { + foreach (var entry in McpServers) + { + entry.Tools.Clear(); + if (!results.TryGetValue(entry.Name, out var toolNames)) + continue; + + foreach (var toolName in toolNames) + { + entry.Tools.Add(new ToolItem + { + Short = toolName, + Full = string.IsNullOrWhiteSpace(entry.Name) ? toolName : $"{entry.Name}.{toolName}" + }); + } + + if (successServers.Contains(entry.Name)) + { + var count = toolNames.Count; + if (count == 0) + AppendCliLog($"System: MCP '{entry.Name}' server started (no tools detected)."); + else + AppendCliLog($"System: MCP '{entry.Name}' server started with {count} tool{(count == 1 ? string.Empty : "s")}."); + } + else if (discoverySkipped.Contains(entry.Name)) + { + AppendCliLog($"System: MCP '{entry.Name}' server started (tool discovery skipped)."); + } + } + + foreach (var (message, always) in logs) + { + if (_verboseLogging || always) + AppendCliLog(message); + } + } + catch (Exception ex) + { + AppendCliLog("System: Failed to update MCP tools UI: " + ex.Message); + } + }); + }); + } + + private async Task> TryFetchToolsAsync(McpServerDefinition server, CancellationToken cancellationToken) + { + var tools = new List(); + + if (string.IsNullOrWhiteSpace(server.Command)) + return tools; + + var psi = new ProcessStartInfo + { + FileName = server.Command, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + if (!string.IsNullOrWhiteSpace(server.Cwd)) + { + try { psi.WorkingDirectory = server.Cwd; } catch { } + } + + foreach (var arg in server.Args) + psi.ArgumentList.Add(arg); + + foreach (var kvp in server.Env) + { + try { psi.Environment[kvp.Key] = kvp.Value; } catch { } + } + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + if (!process.Start()) + { + AppendCliLog($"System: Failed to start MCP server '{server.Name}' for tool probe."); + return tools; + } + + process.StandardInput.NewLine = "\n"; + process.StandardInput.AutoFlush = true; + + var sawStderr = false; + string? firstStderrLine = null; + var stderrTask = Task.Run(async () => + { + try + { + string? errLine; + while ((errLine = await process.StandardError.ReadLineAsync().ConfigureAwait(false)) != null) + { + if (!string.IsNullOrWhiteSpace(errLine)) + { + if (_verboseLogging) + { + AppendCliLog($"System: MCP '{server.Name}' stderr: {errLine}"); + } + else + { + if (!sawStderr) + firstStderrLine = errLine; + sawStderr = true; + } + } + } } + catch { } + }); + + var stdout = process.StandardOutput; + var stdin = process.StandardInput; - var cwd = s["cwd"]?.ToString(); - var envObj = s["env"] as Newtonsoft.Json.Linq.JObject; + var timeoutSeconds = server.StartupTimeoutSec.HasValue && server.StartupTimeoutSec > 0 + ? server.StartupTimeoutSec.Value + : 15.0; - parts.Add($"-c mcp_servers.{name}.command={EscapeForCli(cmd)}"); - var argsList = args.ToList(); - if (argsList.Count > 0) parts.Add($"-c mcp_servers.{name}.args={BuildJsonArray(argsList)}"); - if (!string.IsNullOrWhiteSpace(cwd)) parts.Add($"-c mcp_servers.{name}.cwd={EscapeForCli(cwd!)}"); - if (envObj != null) + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + await SendJsonAsync(stdin, new JObject { - foreach (var prop in envObj.Properties()) + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "initialize", + ["params"] = new JObject { - var k = prop.Name; - var v = prop.Value?.ToString() ?? string.Empty; - parts.Add($"-c mcp_servers.{name}.env.{k}={EscapeForCli(v)}"); + ["clientInfo"] = new JObject + { + ["name"] = "semantic-developer", + ["version"] = typeof(MainWindow).Assembly?.GetName().Version?.ToString() ?? "dev" + }, + ["protocolVersion"] = "2025-06-18", + ["capabilities"] = new JObject() + } + }, timeoutCts.Token).ConfigureAwait(false); + + var initResponse = await ReadResponseAsync(stdout, 1, timeoutCts.Token).ConfigureAwait(false); + if (initResponse? ["error"] != null) + { + var message = initResponse["error"]?["message"]?.ToString() ?? "unknown error"; + AppendCliLog($"System: MCP '{server.Name}' init failed: {message}"); + return tools; + } + + await SendJsonAsync(stdin, new JObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/initialized", + ["params"] = new JObject() + }, timeoutCts.Token).ConfigureAwait(false); + + await SendJsonAsync(stdin, new JObject + { + ["jsonrpc"] = "2.0", + ["id"] = 2, + ["method"] = "tools/list", + ["params"] = new JObject() + }, timeoutCts.Token).ConfigureAwait(false); + + var listResponse = await ReadResponseAsync(stdout, 2, timeoutCts.Token).ConfigureAwait(false); + if (listResponse? ["error"] != null) + { + var message = listResponse["error"]?["message"]?.ToString() ?? "unknown error"; + AppendCliLog($"System: MCP '{server.Name}' list tools failed: {message}"); + return tools; + } + + if (listResponse? ["result"]?["tools"] is JArray toolArray) + { + foreach (var tool in toolArray.OfType()) + { + var name = tool["name"]?.ToString(); + if (!string.IsNullOrWhiteSpace(name) && !tools.Contains(name, StringComparer.OrdinalIgnoreCase)) + tools.Add(name); } } } - catch { } + catch (OperationCanceledException) + { + AppendCliLog($"System: MCP '{server.Name}' tool probe timed out after {timeoutSeconds:F0}s."); + } + catch (Exception ex) + { + AppendCliLog($"System: MCP '{server.Name}' tool probe error: {ex.Message}"); + } + finally + { + try + { + if (!process.HasExited) + { + try + { + await SendJsonAsync(stdin, new JObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/shutdown", + ["params"] = new JObject() + }, CancellationToken.None).ConfigureAwait(false); + } + catch { } + + try { process.Kill(true); } catch { } + } + } + catch { } + finally + { + try { await process.WaitForExitAsync(); } catch { } + try { await stderrTask.ConfigureAwait(false); } catch { } + if (!_verboseLogging && sawStderr) + { + var trimmed = firstStderrLine?.Trim(); + if (!string.IsNullOrWhiteSpace(trimmed) && trimmed.Length > 120) + trimmed = trimmed.Substring(0, 120) + "…"; + var suffix = string.IsNullOrWhiteSpace(trimmed) ? string.Empty : $" (first line: {trimmed})"; + AppendCliLog($"System: MCP '{server.Name}' emitted stderr output{suffix}. Enable verbose logging for full details."); + } + } + } + + return tools; + } + + private static async Task SendJsonAsync(TextWriter writer, JObject payload, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = payload.ToString(Formatting.None); + await writer.WriteLineAsync(json).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + } + + private static async Task ReadResponseAsync(StreamReader reader, long targetId, CancellationToken cancellationToken) + { + while (true) + { + var message = await ReadJsonMessageAsync(reader, cancellationToken).ConfigureAwait(false); + if (message is null) + return null; + + if (message.TryGetValue("id", out var idToken) && idToken != null && idToken.Type != JTokenType.Null) + { + if (TryGetRequestId(idToken, out var id) && id == targetId) + return message; + } + } + } + + private static async Task ReadJsonMessageAsync(StreamReader reader, CancellationToken cancellationToken) + { + while (true) + { + var line = await ReadLineWithCancellationAsync(reader, cancellationToken).ConfigureAwait(false); + if (line is null) + return null; + line = line.Trim(); + if (line.Length == 0) + continue; + try + { + return JObject.Parse(line); + } + catch + { + continue; + } + } + } + + private static async Task ReadLineWithCancellationAsync(StreamReader reader, CancellationToken cancellationToken) + { + var readTask = reader.ReadLineAsync(); + var completed = await Task.WhenAny(readTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); + if (completed != readTask) + throw new OperationCanceledException(cancellationToken); + return await readTask.ConfigureAwait(false); + } + + private static string SanitizeServerName(string rawName) + { + if (string.IsNullOrWhiteSpace(rawName)) + return string.Empty; + return new string(rawName.Trim().Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '-').ToArray()); } private static string ParseServerNameFromTool(string toolName) @@ -2433,6 +3174,37 @@ public class ToolItem public string Full { get; set; } = string.Empty; } + private class McpServerDefinition + { + public string Name { get; set; } = string.Empty; + public string? Command { get; set; } + public List Args { get; } = new(); + public string? Cwd { get; set; } + public Dictionary Env { get; } = new(StringComparer.OrdinalIgnoreCase); + public string? Url { get; set; } + public string? BearerToken { get; set; } + public double? StartupTimeoutSec { get; set; } + public double? ToolTimeoutSec { get; set; } + + public McpServerDefinition Clone() + { + var clone = new McpServerDefinition + { + Name = Name, + Command = Command, + Cwd = Cwd, + Url = Url, + BearerToken = BearerToken, + StartupTimeoutSec = StartupTimeoutSec, + ToolTimeoutSec = ToolTimeoutSec + }; + clone.Args.AddRange(Args); + foreach (var kvp in Env) + clone.Env[kvp.Key] = kvp.Value; + return clone; + } + } + // McpServerToolGroup removed; combined into McpServerEntry private async Task HandleUnauthorizedAsync() @@ -2781,7 +3553,7 @@ private void OnMcpRefreshConfigClick(object? sender, Avalonia.Interactivity.Rout try { LoadMcpServersFromConfig(); - AppendCliLog("System: MCP servers reloaded from config."); + AppendCliLogVerbose("System: MCP servers reloaded from config."); } catch (Exception ex) { @@ -3098,8 +3870,8 @@ private List DetectRunCandidates(string root) try { var txt = File.ReadAllText(pkg); - var obj = Newtonsoft.Json.Linq.JObject.Parse(txt); - var scripts = obj["scripts"] as Newtonsoft.Json.Linq.JObject; + var obj = JObject.Parse(txt); + var scripts = obj["scripts"] as JObject; if (scripts != null) { var script = scripts["dev"]?.ToString(); @@ -3402,8 +4174,8 @@ private static bool HasNodeBuildScript(string dir) var pkg = Path.Combine(dir, "package.json"); if (!File.Exists(pkg)) return false; var txt = File.ReadAllText(pkg); - var obj = Newtonsoft.Json.Linq.JObject.Parse(txt); - var scripts = obj["scripts"] as Newtonsoft.Json.Linq.JObject; + var obj = JObject.Parse(txt); + var scripts = obj["scripts"] as JObject; var build = scripts? ["build"]?.ToString(); return !string.IsNullOrWhiteSpace(build); } @@ -3979,44 +4751,59 @@ private async void OnGitInitClick(object? sender, Avalonia.Interactivity.RoutedE private async Task SendCliInputAsync() { if (!_cli.IsRunning || string.IsNullOrWhiteSpace(CliInput)) return; + if (string.IsNullOrWhiteSpace(_conversationId)) + { + AppendCliLog("System: Conversation not ready yet."); + return; + } + var line = CliInput; CliInput = string.Empty; - // Log the user request locally with a label; de-dup against server echo later _pendingUserInput = line; if (!CliLog.EndsWith("\n")) AppendCliInline(Environment.NewLine); AppendCliLog("You:"); AppendCliLog(line); + try { - if (_cli.UseProto) - { - var cwd = HasWorkspace ? CurrentWorkspacePath : null; - var approvalPolicy = "on-request"; - var (ok, prepared, error) = ProtoHelper.PrepareSubmission( - line, - cwd, - _submissionShape, - _contentStyle, - _defaultMsgType, - _currentModel, - approvalPolicy, - allowNetworkAccess: _allowNetworkAccess); - if (!ok) - { - AppendCliLog("Invalid proto submission: " + error); - return; - } - try + var items = new JArray + { + new JObject { - AppendCliLog($"System: Turn context • network={( _allowNetworkAccess ? "on" : "off")}"); + ["type"] = "text", + ["data"] = new JObject { ["text"] = line } } - catch { } - await _cli.SendAsync(prepared); - } - else + }; + + var sandbox = new JObject { - await _cli.SendAsync(line); - } + ["mode"] = "workspace-write", + ["writable_roots"] = new JArray(), + ["network_access"] = _allowNetworkAccess, + ["exclude_tmpdir_env_var"] = false, + ["exclude_slash_tmp"] = false + }; + + var cwd = HasWorkspace && !string.IsNullOrWhiteSpace(CurrentWorkspacePath) + ? CurrentWorkspacePath! + : Directory.GetCurrentDirectory(); + + var model = string.IsNullOrWhiteSpace(_currentModel) ? "gpt-5-codex" : _currentModel; + + var turnParams = new JObject + { + ["conversationId"] = _conversationId, + ["items"] = items, + ["cwd"] = cwd, + ["approvalPolicy"] = "on-request", + ["sandboxPolicy"] = sandbox, + ["model"] = model, + ["effort"] = "medium", + ["summary"] = "auto" + }; + + AppendCliLog($"System: Turn context • network={( _allowNetworkAccess ? "on" : "off")}"); + await SendRequestAsync("sendUserTurn", turnParams); } catch (Exception ex) { @@ -4045,5 +4832,4 @@ private async Task SendCliInputAsync() return null; } - } diff --git a/SemanticDeveloper/SemanticDeveloper/SemanticDeveloper.csproj b/SemanticDeveloper/SemanticDeveloper/SemanticDeveloper.csproj index 003b4d2..4c68abb 100644 --- a/SemanticDeveloper/SemanticDeveloper/SemanticDeveloper.csproj +++ b/SemanticDeveloper/SemanticDeveloper/SemanticDeveloper.csproj @@ -6,7 +6,7 @@ true app.manifest true - 1.0.2 + 1.0.3 2025 Stainless Designer LLC diff --git a/SemanticDeveloper/SemanticDeveloper/Services/CodexCliService.cs b/SemanticDeveloper/SemanticDeveloper/Services/CodexCliService.cs index d81f84c..5df4173 100644 --- a/SemanticDeveloper/SemanticDeveloper/Services/CodexCliService.cs +++ b/SemanticDeveloper/SemanticDeveloper/Services/CodexCliService.cs @@ -13,7 +13,6 @@ public class CodexCliService private Process? _process; public string Command { get; set; } = "codex"; // Assumes codex CLI is on PATH - public bool UseProto => true; // Always use --proto for this app public string AdditionalArgs { get; set; } = string.Empty; // Extra CLI args if needed public bool UseWsl { get; set; } = false; // Windows: run via wsl.exe when true public bool IsRunning => _process is { HasExited: false }; @@ -23,18 +22,13 @@ public class CodexCliService public event EventHandler? OutputReceived; public event EventHandler? Exited; - private enum ProtoMode { Flag, Subcommand, None } - private ProtoMode? _detectedMode; - public async Task StartAsync(string workspacePath, CancellationToken cancellationToken) { if (IsRunning) throw new InvalidOperationException("CLI already running."); // Force proto subcommand per environment ("codex proto") - var mode = ProtoMode.Subcommand; - - var tokens = BuildArgumentTokens(mode, AdditionalArgs); + var tokens = BuildArgumentTokens(AdditionalArgs); var effectiveWorkspace = string.IsNullOrWhiteSpace(workspacePath) ? Directory.GetCurrentDirectory() : workspacePath; var psi = await BuildProcessStartInfoAsync(effectiveWorkspace, tokens, redirectStdIn: true); @@ -119,73 +113,6 @@ public Task SendAsync(string data) return p.StandardInput.WriteLineAsync(data); } - private async Task DetectProtoModeAsync(string workingDir) - { - if (_detectedMode is { } cached) - return cached; - - try - { - var (codeFlag, _, _) = await RunArgsAsync(new[] { "--proto", "--help" }, workingDir); - if (codeFlag == 0) - { - _detectedMode = ProtoMode.Flag; - return _detectedMode.Value; - } - - var (codeSub, _, _) = await RunArgsAsync(new[] { "proto", "--help" }, workingDir); - if (codeSub == 0) - { - _detectedMode = ProtoMode.Subcommand; - return _detectedMode.Value; - } - - // Fallback: attempt to detect from general help text - var help = await RunHelpTextAsync(workingDir); - if (help.IndexOf("--proto", StringComparison.OrdinalIgnoreCase) >= 0) - _detectedMode = ProtoMode.Flag; - else if (help.IndexOf(" proto\n", StringComparison.Ordinal) >= 0 || help.IndexOf("\n proto ", StringComparison.Ordinal) >= 0 || help.Contains("SUBCOMMANDS") && help.IndexOf("proto", StringComparison.OrdinalIgnoreCase) >= 0) - _detectedMode = ProtoMode.Subcommand; - else - _detectedMode = ProtoMode.None; - } - catch - { - _detectedMode = ProtoMode.None; - } - - return _detectedMode.Value; - } - - private async Task RunHelpTextAsync(string workingDir) - { - try - { - var psi = await BuildProcessStartInfoAsync(workingDir, new[] { "--help" }, redirectStdIn: false); - using var p = Process.Start(psi); - if (p is null) return string.Empty; - var stdout = await p.StandardOutput.ReadToEndAsync(); - var stderr = await p.StandardError.ReadToEndAsync(); - await p.WaitForExitAsync(); - return string.IsNullOrWhiteSpace(stdout) ? stderr : stdout; - } - catch (Exception ex) - { - return ex.Message; - } - } - - private async Task<(int Exit, string Stdout, string Stderr)> RunArgsAsync(IEnumerable args, string workingDir) - { - var psi = await BuildProcessStartInfoAsync(workingDir, args, redirectStdIn: false); - using var p = new Process { StartInfo = psi }; - p.Start(); - var stdout = await p.StandardOutput.ReadToEndAsync(); - var stderr = await p.StandardError.ReadToEndAsync(); - await p.WaitForExitAsync(); - return (p.ExitCode, stdout, stderr); - } - internal async Task BuildProcessStartInfoAsync(string workingDir, IEnumerable commandArgs, bool redirectStdIn, bool redirectStdOut = true, bool redirectStdErr = true) { var effectiveDir = string.IsNullOrWhiteSpace(workingDir) ? Directory.GetCurrentDirectory() : workingDir; @@ -291,14 +218,9 @@ private static string NormalizeToken(string token) return token; } - private static List BuildArgumentTokens(ProtoMode mode, string? additional) + private static List BuildArgumentTokens(string? additional) { - var tokens = new List(); - switch (mode) - { - case ProtoMode.Flag: tokens.Add("--proto"); break; - case ProtoMode.Subcommand: tokens.Add("proto"); break; - } + var tokens = new List { "app-server" }; if (!string.IsNullOrWhiteSpace(additional)) { tokens.AddRange(SplitArgsRespectingQuotes(additional!));