From bc77da30f6b8ac8cd3fc328a02a0472a81e036ef Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 6 Feb 2026 11:52:00 -0600 Subject: [PATCH] Persistent sessions, streaming fixes, stop button, settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Persistent Copilot sessions that survive app restarts (headless TCP server mode) - ServerManager: spawn/detect/stop detached copilot --headless servers with PID tracking - Settings page: toggle between Embedded and Persistent connection modes - Stop button to abort running chat responses (SDK AbortAsync) - Session reconnection with 'πŸ”„ Session reconnected' indicator - Auto-restore previous sessions on app launch Streaming & rendering fixes: - Fix message duplication: deduplicate AssistantMessageEvent by MessageId (SDK fires events N times for sessions resumed N times) - Fix per-turn delta tracking: HasReceivedDeltasThisTurn flag prevents double-append when both delta and full message events fire - Fix streaming content lifecycle: accumulate across turns, clear only when full response completes (no more vanishing messages) - Remove tool call/reasoning cards from chat history (caused hundreds of thin green lines filling the screen). Show single inline tool indicator for currently running tool instead - Add explicit color: white to assistant message text and markdown body (fixes invisible text from Bootstrap color override on dark background) UI improvements: - System message type and styling for reconnection indicators - Enter key fix: JS interop prevents default on Enter without Shift - Connection mode indicator in sidebar (Persistent/Embedded) - Settings tab in sidebar navigation New files: - Models/ConnectionSettings.cs - Connection mode enum and settings persistence - Services/ServerManager.cs - Server lifecycle management with PID files - Components/Pages/Settings.razor - Connection settings UI - README.md - Comprehensive project documentation --- Components/Layout/SessionSidebar.razor | 10 +- Components/Pages/Dashboard.razor | 13 +- Components/Pages/Home.razor | 224 ++++++----------- Components/Pages/Home.razor.css | 36 +++ Components/Pages/Settings.razor | 162 ++++++++++++ Components/Pages/Settings.razor.css | 332 +++++++++++++++++++++++++ MauiProgram.cs | 30 +++ Models/ChatMessage.cs | 6 +- Models/ConnectionSettings.cs | 51 ++++ README.md | 270 ++++++++++++++++++++ Services/CopilotService.cs | 223 ++++++++++++++--- Services/ServerManager.cs | 211 ++++++++++++++++ wwwroot/index.html | 10 + 13 files changed, 1389 insertions(+), 189 deletions(-) create mode 100644 Components/Pages/Settings.razor create mode 100644 Components/Pages/Settings.razor.css create mode 100644 Models/ConnectionSettings.cs create mode 100644 README.md create mode 100644 Services/ServerManager.cs diff --git a/Components/Layout/SessionSidebar.razor b/Components/Layout/SessionSidebar.razor index 10f81d96a3..241b5fbf10 100644 --- a/Components/Layout/SessionSidebar.razor +++ b/Components/Layout/SessionSidebar.razor @@ -8,11 +8,19 @@ diff --git a/Components/Pages/Dashboard.razor b/Components/Pages/Dashboard.razor index cc53e67f3f..ac8ba965ed 100644 --- a/Components/Pages/Dashboard.razor +++ b/Components/Pages/Dashboard.razor @@ -36,7 +36,7 @@
@{ - var lastMessages = session.History.TakeLast(6).ToList(); + var lastMessages = session.History.ToList().TakeLast(6).ToList(); } @if (!lastMessages.Any()) { @@ -206,7 +206,16 @@ try { - _ = CopilotService.SendPromptAsync(sessionName, prompt.Trim()); + _ = CopilotService.SendPromptAsync(sessionName, prompt.Trim()).ContinueWith(t => + { + if (t.IsFaulted) + { + InvokeAsync(() => + { + Console.WriteLine($"Error sending to {sessionName}: {t.Exception?.InnerException?.Message}"); + }); + } + }); } catch (Exception ex) { diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index f6a8bdba69..1b1d6fb990 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -51,16 +51,16 @@ } else { - @foreach (var message in activeSession.History.ToList()) + @foreach (var msg in activeSession.History.ToList()) { - @switch (message.MessageType) + @switch (msg.MessageType) { case ChatMessageType.User:
-
@((MarkupString)FormatUserMessage(message.Content))
-
@message.Timestamp.ToString("HH:mm")
+
@((MarkupString)FormatUserMessage(msg.Content))
+
@msg.Timestamp.ToString("HH:mm")
break; @@ -69,98 +69,38 @@
-
@((MarkupString)RenderMarkdown(message.Content))
-
@message.Timestamp.ToString("HH:mm")
+
@((MarkupString)RenderMarkdown(msg.Content))
+
@msg.Timestamp.ToString("HH:mm")
break; - case ChatMessageType.Reasoning: -
- - @if (!message.IsCollapsed || LineCount(message.Content) <= 5) - { -
@message.Content
- } - else - { -
@FirstLines(message.Content, 3)
- } -
- break; - - case ChatMessageType.ToolCall: - @if (message.ToolName == "task_complete") - { -
- - @(string.IsNullOrEmpty(message.Content) || IsUnusableResult(message.Content) ? "Task complete" : message.Content) -
- } - else - { -
-
- @FormatToolName(message.ToolName ?? "") - - @if (!message.IsComplete) - { - Running - } - else if (message.IsSuccess) - { - Done - } - else - { - Failed - } - -
- @if (message.IsComplete && !string.IsNullOrEmpty(message.Content) && !IsUnusableResult(message.Content)) - { - @if (LineCount(message.Content) <= 5) - { -
-
@TruncateResult(message.Content)
-
- } - else - { -
- - @if (!message.IsCollapsed) - { -
@TruncateResult(message.Content)
- } - else - { -
@FirstLines(message.Content, 3)
- } -
- } - } -
- } - break; - case ChatMessageType.Error:
- @message.Content + @msg.Content +
+ break; + + case ChatMessageType.System: +
+
@msg.Content
break; } } + @* Show current tool activity inline *@ + @if (!string.IsNullOrEmpty(currentToolName)) + { +
+
+ @FormatToolName(currentToolName) + @currentTurnToolCount tool@(currentTurnToolCount != 1 ? "s" : "") +
+
+ } + @if (!string.IsNullOrEmpty(streamingContent)) {
@@ -206,10 +146,16 @@
}
- + @if (activeSession.IsProcessing) + { + + } + } + else + { + + } +
+
+ + } + +
+
+ + @if (!string.IsNullOrEmpty(statusMessage)) + { + @statusMessage + } +
+
+ +
+

Current Connection

+

Mode: @CopilotService.CurrentMode

+

Status: @(CopilotService.IsInitialized ? "βœ… Connected" : "❌ Disconnected")

+ +

About Transport Modes

+ +
+ + +@code { + private ConnectionSettings settings = new(); + private string? statusMessage; + private string statusClass = ""; + private bool serverAlive; + private bool starting; + + protected override void OnInitialized() + { + settings = ConnectionSettings.Load(); + serverAlive = ServerManager.CheckServerRunning("localhost", settings.Port); + } + + private void SetMode(ConnectionMode mode) + { + settings.Mode = mode; + } + + private async Task StartServer() + { + starting = true; + StateHasChanged(); + + var success = await ServerManager.StartServerAsync(settings.Port); + serverAlive = success; + starting = false; + + if (success) + statusMessage = $"βœ… Server started on port {settings.Port}"; + else + statusMessage = "❌ Failed to start server"; + statusClass = success ? "success" : "error"; + StateHasChanged(); + } + + private void StopServer() + { + ServerManager.StopServer(); + serverAlive = false; + statusMessage = "Server stopped"; + statusClass = ""; + StateHasChanged(); + } + + private async Task SaveAndApply() + { + // If switching to Persistent mode, ensure server is running + if (settings.Mode == ConnectionMode.Persistent && !serverAlive) + { + statusMessage = "⚠️ Start the persistent server first"; + statusClass = "error"; + StateHasChanged(); + return; + } + + settings.Save(); + statusMessage = "Settings saved. Reconnecting..."; + statusClass = ""; + StateHasChanged(); + + try + { + await CopilotService.ReconnectAsync(settings); + statusMessage = "βœ… Connected!"; + statusClass = "success"; + } + catch (Exception ex) + { + statusMessage = $"❌ Connection failed: {ex.Message}"; + statusClass = "error"; + } + StateHasChanged(); + } +} diff --git a/Components/Pages/Settings.razor.css b/Components/Pages/Settings.razor.css new file mode 100644 index 0000000000..c0ca991c00 --- /dev/null +++ b/Components/Pages/Settings.razor.css @@ -0,0 +1,332 @@ +.settings-page { + padding: 1.5rem; + color: white; + background: #0f0f23; + height: 100%; + overflow-y: auto; +} + +.settings-header h2 { + margin: 0 0 1.5rem 0; + font-size: 1.6rem; +} + +.settings-section { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; +} + +.settings-section h3 { + margin: 0 0 1rem 0; + font-size: 1.15rem; + color: rgba(255,255,255,0.9); +} + +.settings-section.info { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.2); +} + +.settings-section.info p { + color: rgba(255,255,255,0.6); + margin: 0.5rem 0; +} + +.settings-section.info code { + display: block; + background: rgba(0,0,0,0.3); + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-family: monospace; + color: #60a5fa; + margin: 0.5rem 0; +} + +.settings-section.info ul { + color: rgba(255,255,255,0.6); + padding-left: 1.25rem; + margin: 0.5rem 0 0; +} + +.settings-section.info li { + margin: 0.25rem 0; +} + +.mode-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.mode-card { + border: 2px solid rgba(255,255,255,0.15); + border-radius: 10px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.mode-card:hover { + border-color: rgba(255,255,255,0.3); + background: rgba(255,255,255,0.05); +} + +.mode-card.selected { + border-color: #3b82f6; + background: rgba(59, 130, 246, 0.1); +} + +.mode-icon { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.mode-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.3rem; +} + +.mode-desc { + font-size: 0.85rem; + color: rgba(255,255,255,0.5); +} + +.form-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.form-row label { + min-width: 60px; + color: rgba(255,255,255,0.7); + font-size: 0.95rem; +} + +.form-input { + flex: 1; + max-width: 250px; + padding: 0.4rem 0.6rem; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 6px; + background: rgba(255,255,255,0.08); + color: white; + font-size: 0.95rem; +} + +.form-input:focus { + outline: none; + border-color: #3b82f6; +} + +.server-status { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.75rem 0; + padding: 0.5rem 0.75rem; + background: rgba(0,0,0,0.2); + border-radius: 6px; +} + +.status-running { + color: #48bb78; +} + +.status-stopped { + color: rgba(255,255,255,0.4); +} + +.status-checking { + color: #fbbf24; +} + +.check-btn { + padding: 0.25rem 0.6rem; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + background: transparent; + color: rgba(255,255,255,0.6); + cursor: pointer; + font-size: 0.85rem; +} + +.check-btn:hover:not(:disabled) { + border-color: rgba(255,255,255,0.4); + color: white; +} + +.server-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.75rem 0; +} + +.action-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.95rem; + cursor: pointer; + font-weight: 500; +} + +.action-btn.start { + background: #48bb78; + color: white; +} + +.action-btn.start:hover:not(:disabled) { + background: #38a169; +} + +.action-btn.start:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-btn.stop { + background: rgba(239, 68, 68, 0.8); + color: white; +} + +.action-btn.stop:hover { + background: #ef4444; +} + +.action-hint { + font-size: 0.8rem; + color: rgba(255,255,255,0.3); + font-family: monospace; +} + +.save-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.save-btn { + padding: 0.6rem 1.5rem; + border: none; + border-radius: 8px; + background: #3b82f6; + color: white; + font-size: 1rem; + font-weight: 500; + cursor: pointer; +} + +.save-btn:hover { + background: #2563eb; +} + +.save-status { + font-size: 0.9rem; + color: rgba(255,255,255,0.6); +} + +.save-status.success { + color: #48bb78; +} + +.save-status.error { + color: #ef4444; +} + +.server-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.status-dot.alive { + background: #48bb78; + box-shadow: 0 0 6px #48bb78; +} + +.status-dot.dead { + background: rgba(255,255,255,0.3); +} + +.pid-label { + font-size: 0.8rem; + color: rgba(255,255,255,0.4); + font-family: monospace; +} + +.port-input { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.port-input label { + color: rgba(255,255,255,0.7); +} + +.port-input input { + width: 100px; + padding: 0.4rem 0.6rem; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 6px; + background: rgba(255,255,255,0.08); + color: white; + font-size: 0.95rem; +} + +.port-input input:focus { + outline: none; + border-color: #3b82f6; +} + +.server-buttons { + display: flex; + gap: 0.5rem; +} + +.start-btn, .stop-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.95rem; + cursor: pointer; + font-weight: 500; +} + +.start-btn { + background: #48bb78; + color: white; +} + +.start-btn:hover:not(:disabled) { + background: #38a169; +} + +.start-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.stop-btn { + background: rgba(239, 68, 68, 0.8); + color: white; +} + +.stop-btn:hover { + background: #ef4444; +} diff --git a/MauiProgram.cs b/MauiProgram.cs index e286c201ad..08f2c8c2c8 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -5,8 +5,24 @@ namespace AutoPilot.App; public static class MauiProgram { + private static readonly string CrashLogPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".copilot", "autopilot-crash.log"); + public static MauiApp CreateMauiApp() { + // Set up global exception handlers + AppDomain.CurrentDomain.UnhandledException += (sender, args) => + { + LogException("AppDomain.UnhandledException", args.ExceptionObject as Exception); + }; + + TaskScheduler.UnobservedTaskException += (sender, args) => + { + LogException("TaskScheduler.UnobservedTaskException", args.Exception); + args.SetObserved(); // Prevent crash + }; + var builder = MauiApp.CreateBuilder(); builder .UseMauiApp() @@ -19,6 +35,7 @@ public static MauiApp CreateMauiApp() // Register CopilotService as singleton so state is shared across components builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); @@ -27,4 +44,17 @@ public static MauiApp CreateMauiApp() return builder.Build(); } + + private static void LogException(string source, Exception? ex) + { + if (ex == null) return; + try + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + var logEntry = $"\n=== {timestamp} [{source}] ===\n{ex}\n"; + File.AppendAllText(CrashLogPath, logEntry); + Console.WriteLine($"[CRASH] {source}: {ex.Message}"); + } + catch { /* Don't throw in exception handler */ } + } } diff --git a/Models/ChatMessage.cs b/Models/ChatMessage.cs index 4e5e55cc06..ef61fe9be8 100644 --- a/Models/ChatMessage.cs +++ b/Models/ChatMessage.cs @@ -6,7 +6,8 @@ public enum ChatMessageType Assistant, Reasoning, ToolCall, - Error + Error, + System } public class ChatMessage @@ -56,4 +57,7 @@ public static ChatMessage ToolCallMessage(string toolName, string? toolCallId = public static ChatMessage ErrorMessage(string content, string? toolName = null) => new("assistant", content, DateTime.Now, ChatMessageType.Error) { ToolName = toolName, IsComplete = true }; + + public static ChatMessage SystemMessage(string content) => + new("system", content, DateTime.Now, ChatMessageType.System) { IsComplete = true }; } diff --git a/Models/ConnectionSettings.cs b/Models/ConnectionSettings.cs new file mode 100644 index 0000000000..71e8fc9312 --- /dev/null +++ b/Models/ConnectionSettings.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AutoPilot.App.Models; + +public enum ConnectionMode +{ + Embedded, // SDK spawns copilot via stdio (default, dies with app) + Persistent // App spawns detached copilot server; survives app restarts +} + +public class ConnectionSettings +{ + public ConnectionMode Mode { get; set; } = ConnectionMode.Embedded; + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 4321; + public bool AutoStartServer { get; set; } = false; + + [JsonIgnore] + public string CliUrl => $"{Host}:{Port}"; + + private static readonly string SettingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".copilot", "autopilot-settings.json"); + + public static ConnectionSettings Load() + { + try + { + if (File.Exists(SettingsPath)) + { + var json = File.ReadAllText(SettingsPath); + return JsonSerializer.Deserialize(json) ?? new(); + } + } + catch { } + return new(); + } + + public void Save() + { + try + { + var dir = Path.GetDirectoryName(SettingsPath)!; + Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(SettingsPath, json); + } + catch { } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000000..d92ae79501 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# AutoPilot.App + +A .NET MAUI Blazor hybrid desktop app that manages multiple GitHub Copilot CLI sessions. AutoPilot provides a native GUI for creating, orchestrating, and interacting with parallel Copilot agent sessions β€” acting as a multi-agent control plane. + +## What Problem It Solves + +Working with GitHub Copilot CLI is powerful, but limited to a single terminal session at a time. AutoPilot lets you: + +- Run **multiple Copilot sessions in parallel**, each with its own model, working directory, and conversation history +- **Orchestrate agents** from a dashboard β€” broadcast the same prompt to all sessions at once +- **Resume sessions** across app restarts β€” sessions persist to disk and can be picked up later +- **Choose connection modes** β€” from simple embedded stdio to a persistent server that outlives the app + +## Features + +### Multi-Session Management +Create named sessions with different models and working directories. Sessions appear in a sidebar and can be switched between instantly. Each session maintains its own conversation history and processing state. + +### Chat Interface +Full chat UI with streaming responses, real-time activity logging, Markdown rendering (code blocks, inline code, bold), and auto-scrolling. Shows typing indicators and tool execution status as Copilot works. + +### Session Orchestrator Dashboard +A grid view of all active sessions showing their last messages, streaming output, and processing state. Includes per-card message input and a **Broadcast to All** feature to send the same prompt to every idle session simultaneously. + +### Real-Time Activity Log +During processing, the UI displays a live activity feed showing Copilot's intent (`πŸ’­ Thinking...`), tool calls (`πŸ”§ Running bash...`), and completion status (`βœ… Tool completed`). This gives full visibility into multi-step agent workflows. + +### Session Persistence & Resume +- **Active sessions** are saved to `~/.copilot/autopilot-active-sessions.json` and automatically restored on app relaunch +- **All Copilot sessions** persisted in `~/.copilot/session-state/` can be browsed and resumed from the sidebar's "Saved Sessions" panel +- Conversation history is reconstructed from the SDK's `events.jsonl` files on resume +- Sessions display their first user message as a title for easy identification + +### UI State Persistence +The app remembers which page you were on (Chat, Dashboard, or Settings) and which session was active, restoring both on relaunch via `~/.copilot/autopilot-ui-state.json`. + +### Auto-Reconnect +If a session disconnects during a prompt, the service automatically attempts to resume the session by its GUID and retry the message. + +### Per-Session Working Directory +Each session can target a different directory on disk. A native macOS folder picker (via `UIDocumentPickerViewController`) is available for browsing. + +### Model Selection +Sessions can be created with any of the supported models: +`claude-opus-4.6`, `claude-sonnet-4.5`, `claude-sonnet-4`, `claude-haiku-4.5`, `gpt-5.2`, `gpt-5.1`, `gpt-5`, `gpt-5-mini`, `gemini-3-pro-preview` + +### System Instructions +Automatically loads project-level instructions from `.github/copilot-instructions.md` and appends them to every session's system message. When a session targets the AutoPilot project directory, it also injects build/relaunch instructions. + +### Crash Logging +Unhandled exceptions and unobserved task failures are caught globally and written to `~/.copilot/autopilot-crash.log`. + +## Connection Modes + +AutoPilot supports three transport modes, configurable from the Settings page: + +| Mode | Transport | Lifecycle | Best For | +|------|-----------|-----------|----------| +| **Embedded** (default) | stdio | Dies with app | Simple single-machine use | +| **TCP Server** | SDK-managed TCP | Dies with app | More stable long sessions | +| **Persistent Server** | Detached TCP server | Survives app restarts | Session continuity across relaunches | + +### Embedded (stdio) +The SDK spawns a Copilot CLI process and communicates via stdin/stdout. Simplest setup β€” no port configuration needed. The process terminates when the app closes. + +### TCP Server +The SDK spawns and manages a Copilot CLI process using TCP transport internally. More stable for long-running sessions, but the server still dies when the app exits. + +### Persistent Server +The app spawns a **detached** Copilot CLI server process (`copilot --headless --port 4321`) that runs independently. The server's PID and port are tracked in `~/.copilot/autopilot-server.pid`. On relaunch, the app detects the existing server and reconnects. You can start/stop the server from the Settings page. + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ AutoPilot.App β”‚ +β”‚ (.NET MAUI Blazor Hybrid) β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ SessionSidebarβ”‚ β”‚ Home.razorβ”‚ β”‚ Dashboard.razorβ”‚ β”‚ +β”‚ β”‚ (create/ β”‚ β”‚ (chat UI) β”‚ β”‚ (orchestrator) β”‚ β”‚ +β”‚ β”‚ resume) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CopilotService β”‚ (singleton) β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ SessionState β”‚ β”‚ ConcurrentDict β”‚ +β”‚ β”‚ β”‚ β”œβ”€ Session β”‚ β”‚ of named sessions β”‚ +β”‚ β”‚ β”‚ β”œβ”€ Info β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ └─ Response β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ CopilotClient β”‚ (GitHub.Copilot.SDK)β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ stdio β”‚ TCP β”‚ TCP (remote)β”‚ +β”‚ β–Ό β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ copilot β”‚ β”‚ copilot β”‚ β”‚ Persistent β”‚ β”‚ +β”‚ β”‚ (child) β”‚ β”‚ (child) β”‚ β”‚ Server β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (detached) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–² β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ ServerManager β”‚ (PID file tracking) β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Key Components + +- **`CopilotService`** β€” Singleton service wrapping the Copilot SDK. Manages a `ConcurrentDictionary` of named sessions, handles all SDK events (deltas, tool calls, intents, errors), marshals events to the UI thread via `SynchronizationContext`, and persists session/UI state to disk. +- **`ServerManager`** β€” Manages the persistent Copilot server lifecycle: start, stop, detect existing instances, PID file tracking, TCP health checks. +- **`CopilotClient`** / **`CopilotSession`** β€” From `GitHub.Copilot.SDK`. The client creates/resumes sessions; sessions send prompts and emit events via the ACP (Agent Control Protocol). + +### SDK Event Flow + +When a prompt is sent, the SDK emits events processed by `HandleSessionEvent`: + +1. `AssistantTurnStartEvent` β†’ "Thinking..." activity +2. `AssistantMessageDeltaEvent` β†’ streaming content chunks to the UI +3. `AssistantMessageEvent` β†’ full message with optional tool requests +4. `ToolExecutionStartEvent` / `ToolExecutionCompleteEvent` β†’ tool activity indicators +5. `AssistantIntentEvent` β†’ intent/plan updates +6. `SessionIdleEvent` β†’ turn complete, response finalized, notifications fired + +## Project Structure + +``` +AutoPilot.App/ +β”œβ”€β”€ AutoPilot.App.csproj # Project config, SDK reference, trimmer settings +β”œβ”€β”€ MauiProgram.cs # App bootstrap, DI registration, crash logging +β”œβ”€β”€ relaunch.sh # Build + seamless relaunch script +β”œβ”€β”€ .github/ +β”‚ └── copilot-instructions.md # System instructions loaded into every session +β”œβ”€β”€ Models/ +β”‚ β”œβ”€β”€ AgentSessionInfo.cs # Session metadata (name, model, history, state) +β”‚ β”œβ”€β”€ ChatMessage.cs # Chat message record (role, content, timestamp) +β”‚ └── ConnectionSettings.cs # Connection mode enum + serializable settings +β”œβ”€β”€ Services/ +β”‚ β”œβ”€β”€ CopilotService.cs # Core service: session CRUD, events, persistence +β”‚ └── ServerManager.cs # Persistent server lifecycle + PID tracking +β”œβ”€β”€ Components/ +β”‚ β”œβ”€β”€ Layout/ +β”‚ β”‚ β”œβ”€β”€ MainLayout.razor # App shell with sidebar + content area +β”‚ β”‚ β”œβ”€β”€ SessionSidebar.razor# Session list, create/resume, model picker +β”‚ β”‚ └── NavMenu.razor # Top navigation bar +β”‚ └── Pages/ +β”‚ β”œβ”€β”€ Home.razor # Chat UI with streaming + activity log +β”‚ β”œβ”€β”€ Dashboard.razor # Multi-session orchestrator grid +β”‚ └── Settings.razor # Connection mode selector, server controls +β”œβ”€β”€ Platforms/ +β”‚ └── MacCatalyst/ +β”‚ β”œβ”€β”€ Entitlements.plist # Sandbox disabled, network access enabled +β”‚ β”œβ”€β”€ FolderPickerService.cs # Native macOS folder picker +β”‚ └── Program.cs # Mac Catalyst entry point +└── wwwroot/ + └── app.css # Global styles +``` + +## Prerequisites + +- **.NET 10 SDK** (Preview) β€” the project targets `net10.0-maccatalyst` +- **.NET MAUI workload** β€” install with `dotnet workload install maui` +- **GitHub Copilot CLI** β€” installed globally via npm (`npm install -g @github/copilot`) +- **macOS** β€” the app runs as a Mac Catalyst application (macOS 15.0+) +- **GitHub Copilot subscription** β€” required for the CLI to authenticate + +## Building & Running + +### First-time setup + +```bash +# Install .NET MAUI workload +dotnet workload install maui + +# Restore NuGet packages +cd /path/to/AutoPilot.App +dotnet restore +``` + +### Build and run + +```bash +# Build for Mac Catalyst +dotnet build -f net10.0-maccatalyst + +# Run the app +open bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/AutoPilot.App.app +``` + +### Relaunch after code changes + +The project includes a `relaunch.sh` script for seamless hot-relaunch. It builds, copies to a staging directory, launches a new instance, waits for it to start, then kills the old one: + +```bash +./relaunch.sh +``` + +This is safe to run from a Copilot session inside the app β€” the new instance is fully running before the old one is terminated. + +## Configuration + +### Settings files (all in `~/.copilot/`) + +| File | Purpose | +|------|---------| +| `autopilot-settings.json` | Connection mode, host, port, auto-start preference | +| `autopilot-active-sessions.json` | List of active sessions (session ID, display name, model) for restore on relaunch | +| `autopilot-ui-state.json` | Last active page and session name | +| `autopilot-server.pid` | PID and port of the persistent Copilot server | +| `autopilot-crash.log` | Unhandled exception log | +| `session-state//events.jsonl` | Per-session event history (managed by Copilot SDK) | + +### Example `autopilot-settings.json` + +```json +{ + "Mode": 0, + "Host": "localhost", + "Port": 4321, + "AutoStartServer": false +} +``` + +Mode values: `0` = Embedded, `1` = Server, `2` = Persistent. + +## How It Works + +### Session Lifecycle + +1. **Create**: User enters a name, picks a model and optional working directory in the sidebar. `CopilotService.CreateSessionAsync` calls `CopilotClient.CreateSessionAsync` with a `SessionConfig` (model, working directory, system message). The SDK spawns/connects to Copilot and returns a `CopilotSession`. + +2. **Chat**: User types a message β†’ `SendPromptAsync` adds it to history, calls `session.SendAsync`, and awaits a `TaskCompletionSource` that completes when `SessionIdleEvent` fires. Streaming deltas are emitted to the UI in real time. + +3. **Persist**: After every session create/close, the active session list is written to disk. The Copilot SDK independently persists session state in `~/.copilot/session-state//`. + +4. **Resume**: On relaunch, `RestorePreviousSessionsAsync` reads the active sessions file and calls `ResumeSessionAsync` for each. Conversation history is reconstructed from the SDK's `events.jsonl`. Users can also manually resume any saved session from the sidebar. + +5. **Close**: `CloseSessionAsync` disposes the `CopilotSession`, removes it from the dictionary, and updates the active sessions file. + +### Event Handling + +All SDK events are received on background threads. `CopilotService` captures the UI `SynchronizationContext` during initialization and uses `_syncContext.Post` to marshal event callbacks to the Blazor UI thread, where components call `StateHasChanged()` to re-render. + +### Reconnect on Failure + +If `SendAsync` throws (e.g., the underlying process died), the service attempts to resume the session by its persisted GUID and retry the prompt once. This is transparent to the user β€” they see a "πŸ”„ Reconnecting session..." activity indicator. + +### Persistent Server Detection + +On startup in Persistent mode, `ServerManager.DetectExistingServer()` reads `autopilot-server.pid`, checks if the process is alive via TCP connect, and reuses it if available. Stale PID files are cleaned up automatically. + +## NuGet Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| `GitHub.Copilot.SDK` | 0.1.22 | Copilot CLI client (ACP protocol) | +| `Microsoft.Maui.Controls` | (MAUI SDK) | .NET MAUI framework | +| `Microsoft.AspNetCore.Components.WebView.Maui` | (MAUI SDK) | Blazor WebView for MAUI | +| `Microsoft.Extensions.Logging.Debug` | 10.0.0 | Debug logging | + +> **Note**: The csproj includes `` to prevent the linker from stripping SDK event types needed for runtime pattern matching. Do not remove this. diff --git a/Services/CopilotService.cs b/Services/CopilotService.cs index 32869d9ac8..925ea167e3 100644 --- a/Services/CopilotService.cs +++ b/Services/CopilotService.cs @@ -9,10 +9,16 @@ namespace AutoPilot.App.Services; public class CopilotService : IAsyncDisposable { private readonly ConcurrentDictionary _sessions = new(); + private readonly ServerManager _serverManager; private CopilotClient? _client; private string? _activeSessionName; private SynchronizationContext? _syncContext; + public CopilotService(ServerManager serverManager) + { + _serverManager = serverManager; + } + private static readonly string SessionStatePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "session-state"); @@ -47,6 +53,7 @@ private static string FindProjectDir() public string? SystemInstructions { get; set; } public bool IsInitialized { get; private set; } public string? ActiveSessionName => _activeSessionName; + public ConnectionMode CurrentMode { get; private set; } = ConnectionMode.Embedded; // Debug info public string LastDebugMessage { get; private set; } = ""; @@ -74,6 +81,8 @@ private class SessionState public required AgentSessionInfo Info { get; init; } public TaskCompletionSource? ResponseCompletion { get; set; } public StringBuilder CurrentResponse { get; } = new(); + public bool HasReceivedDeltasThisTurn { get; set; } + public string? LastMessageId { get; set; } } private void Debug(string message) @@ -91,10 +100,34 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) _syncContext = SynchronizationContext.Current; Debug($"SyncContext captured: {_syncContext?.GetType().Name ?? "null"}"); - _client = new CopilotClient(); + var settings = ConnectionSettings.Load(); + CurrentMode = settings.Mode; + + // In Persistent mode, auto-start the server if not already running + if (settings.Mode == ConnectionMode.Persistent) + { + if (!_serverManager.CheckServerRunning("localhost", settings.Port)) + { + Debug($"Persistent server not running, auto-starting on port {settings.Port}..."); + var started = await _serverManager.StartServerAsync(settings.Port); + if (!started) + { + Debug("Failed to auto-start server, falling back to Embedded mode"); + settings.Mode = ConnectionMode.Embedded; + CurrentMode = ConnectionMode.Embedded; + } + } + else + { + Debug($"Persistent server already running on port {settings.Port}"); + } + } + + _client = CreateClient(settings); + await _client.StartAsync(cancellationToken); IsInitialized = true; - Debug("Copilot client started"); + Debug($"Copilot client started in {settings.Mode} mode"); // Load default system instructions from the project's copilot-instructions.md var instructionsPath = Path.Combine(ProjectDir, ".github", "copilot-instructions.md"); @@ -105,6 +138,58 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) } OnStateChanged?.Invoke(); + + // Restore previous sessions (includes subscribing to untracked server sessions in Persistent mode) + await RestorePreviousSessionsAsync(cancellationToken); + } + + /// + /// Disconnect from current client and reconnect with new settings + /// + public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken cancellationToken = default) + { + Debug($"Reconnecting with mode: {settings.Mode}..."); + + // Dispose existing sessions and client + foreach (var state in _sessions.Values) + { + try { await state.Session.DisposeAsync(); } catch { } + } + _sessions.Clear(); + _activeSessionName = null; + + if (_client != null) + { + try { await _client.DisposeAsync(); } catch { } + _client = null; + } + + IsInitialized = false; + CurrentMode = settings.Mode; + OnStateChanged?.Invoke(); + + _client = CreateClient(settings); + + await _client.StartAsync(cancellationToken); + IsInitialized = true; + Debug($"Reconnected in {settings.Mode} mode"); + OnStateChanged?.Invoke(); + + // Restore previous sessions + await RestorePreviousSessionsAsync(cancellationToken); + } + + private static CopilotClient CreateClient(ConnectionSettings settings) + { + return settings.Mode switch + { + ConnectionMode.Persistent => new CopilotClient(new CopilotClientOptions + { + CliUrl = settings.CliUrl, + UseStdio = false + }), + _ => new CopilotClient() + }; } /// @@ -330,6 +415,9 @@ public async Task ResumeSessionAsync(string sessionId, string } info.MessageCount = info.History.Count; + // Add reconnection indicator + info.History.Add(ChatMessage.SystemMessage("πŸ”„ Session reconnected")); + var state = new SessionState { Session = copilotSession, @@ -460,14 +548,18 @@ void Invoke(Action action) case AssistantMessageDeltaEvent delta: var deltaContent = delta.Data.DeltaContent; + state.HasReceivedDeltasThisTurn = true; state.CurrentResponse.Append(deltaContent); Invoke(() => OnContentReceived?.Invoke(sessionName, deltaContent ?? "")); break; case AssistantMessageEvent msg: var msgContent = msg.Data.Content; - if (!string.IsNullOrEmpty(msgContent) && state.CurrentResponse.Length == 0) + var msgId = msg.Data.MessageId; + // Deduplicate: SDK fires this event multiple times for resumed sessions + if (!string.IsNullOrEmpty(msgContent) && !state.HasReceivedDeltasThisTurn && msgId != state.LastMessageId) { + state.LastMessageId = msgId; state.CurrentResponse.Append(msgContent); Invoke(() => OnContentReceived?.Invoke(sessionName, msgContent)); } @@ -519,6 +611,7 @@ void Invoke(Action action) break; case AssistantTurnStartEvent: + state.HasReceivedDeltasThisTurn = false; Invoke(() => { OnTurnStart?.Invoke(sessionName); @@ -667,10 +760,47 @@ await state.Session.SendAsync(new MessageOptions catch (Exception ex) { Console.WriteLine($"[DEBUG] SendAsync threw: {ex.Message}"); - OnError?.Invoke(sessionName, $"SendAsync failed: {ex.Message}"); - state.Info.IsProcessing = false; - OnStateChanged?.Invoke(); - throw; + + // Try to reconnect the session and retry once + if (state.Info.SessionId != null) + { + Debug($"Session '{sessionName}' disconnected, attempting reconnect..."); + OnActivity?.Invoke(sessionName, "πŸ”„ Reconnecting session..."); + try + { + await state.Session.DisposeAsync(); + var newSession = await _client!.ResumeSessionAsync(state.Info.SessionId, cancellationToken: cancellationToken); + var newState = new SessionState + { + Session = newSession, + Info = state.Info + }; + newSession.On(evt => HandleSessionEvent(newState, evt)); + _sessions[sessionName] = newState; + state = newState; + + Debug($"Session '{sessionName}' reconnected, retrying prompt..."); + await state.Session.SendAsync(new MessageOptions + { + Prompt = prompt + }, cancellationToken); + } + catch (Exception retryEx) + { + Console.WriteLine($"[DEBUG] Reconnect+retry failed: {retryEx.Message}"); + OnError?.Invoke(sessionName, $"Session disconnected and reconnect failed: {retryEx.Message}"); + state.Info.IsProcessing = false; + OnStateChanged?.Invoke(); + throw; + } + } + else + { + OnError?.Invoke(sessionName, $"SendAsync failed: {ex.Message}"); + state.Info.IsProcessing = false; + OnStateChanged?.Invoke(); + throw; + } } Console.WriteLine($"[DEBUG] SendAsync completed, waiting for response..."); @@ -690,6 +820,28 @@ await state.Session.SendAsync(new MessageOptions return await state.ResponseCompletion.Task; } + public async Task AbortSessionAsync(string sessionName) + { + if (!_sessions.TryGetValue(sessionName, out var state)) + return; + + if (!state.Info.IsProcessing) return; + + try + { + await state.Session.AbortAsync(); + Debug($"Aborted session '{sessionName}'"); + } + catch (Exception ex) + { + Debug($"Abort failed for '{sessionName}': {ex.Message}"); + } + + state.Info.IsProcessing = false; + state.ResponseCompletion?.TrySetCanceled(); + OnStateChanged?.Invoke(); + } + public void EnqueueMessage(string sessionName, string prompt) { if (!_sessions.TryGetValue(sessionName, out var state)) @@ -841,40 +993,43 @@ private void SaveActiveSessionsToDisk() /// public async Task RestorePreviousSessionsAsync(CancellationToken cancellationToken = default) { - if (!File.Exists(ActiveSessionsFile)) return; - - try + if (File.Exists(ActiveSessionsFile)) { - var json = await File.ReadAllTextAsync(ActiveSessionsFile, cancellationToken); - var entries = JsonSerializer.Deserialize>(json); - if (entries == null || entries.Count == 0) return; - - Debug($"Restoring {entries.Count} previous sessions..."); - - foreach (var entry in entries) + try { - try + var json = await File.ReadAllTextAsync(ActiveSessionsFile, cancellationToken); + var entries = JsonSerializer.Deserialize>(json); + if (entries != null && entries.Count > 0) { - // Skip if already active - if (_sessions.ContainsKey(entry.DisplayName)) continue; - - // Check the session still exists on disk - var sessionDir = Path.Combine(SessionStatePath, entry.SessionId); - if (!Directory.Exists(sessionDir)) continue; + Debug($"Restoring {entries.Count} previous sessions..."); - await ResumeSessionAsync(entry.SessionId, entry.DisplayName, cancellationToken); - Debug($"Restored session: {entry.DisplayName}"); - } - catch (Exception ex) - { - Debug($"Failed to restore '{entry.DisplayName}': {ex.Message}"); + foreach (var entry in entries) + { + try + { + // Skip if already active + if (_sessions.ContainsKey(entry.DisplayName)) continue; + + // Check the session still exists on disk + var sessionDir = Path.Combine(SessionStatePath, entry.SessionId); + if (!Directory.Exists(sessionDir)) continue; + + await ResumeSessionAsync(entry.SessionId, entry.DisplayName, cancellationToken); + Debug($"Restored session: {entry.DisplayName}"); + } + catch (Exception ex) + { + Debug($"Failed to restore '{entry.DisplayName}': {ex.Message}"); + } + } } } + catch (Exception ex) + { + Debug($"Failed to load active sessions file: {ex.Message}"); + } } - catch (Exception ex) - { - Debug($"Failed to load active sessions file: {ex.Message}"); - } + } public async ValueTask DisposeAsync() diff --git a/Services/ServerManager.cs b/Services/ServerManager.cs new file mode 100644 index 0000000000..c56288bf4d --- /dev/null +++ b/Services/ServerManager.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using System.Net.Sockets; +using AutoPilot.App.Models; + +namespace AutoPilot.App.Services; + +public class ServerManager +{ + private static readonly string PidFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".copilot", "autopilot-server.pid"); + + public bool IsServerRunning => CheckServerRunning(); + public int? ServerPid => ReadPidFile(); + public int ServerPort { get; private set; } = 4321; + + public event Action? OnStatusChanged; + + /// + /// Check if a copilot server is listening on the given port + /// + public bool CheckServerRunning(string host = "localhost", int? port = null) + { + port ??= ServerPort; + try + { + using var client = new TcpClient(); + var result = client.BeginConnect(host, port.Value, null, null); + var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(1)); + if (success && client.Connected) + { + client.EndConnect(result); + return true; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Start copilot in headless server mode, detached from app lifecycle + /// + public async Task StartServerAsync(int port = 4321) + { + ServerPort = port; + + if (CheckServerRunning("localhost", port)) + { + Console.WriteLine($"[ServerManager] Server already running on port {port}"); + OnStatusChanged?.Invoke(); + return true; + } + + try + { + // Use the native binary directly for better detachment + var copilotPath = FindCopilotBinary(); + var psi = new ProcessStartInfo + { + FileName = copilotPath, + Arguments = $"--headless --log-level info --port {port}", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = false + }; + + var process = Process.Start(psi); + if (process == null) + { + Console.WriteLine("[ServerManager] Failed to start copilot process"); + return false; + } + + SavePidFile(process.Id, port); + Console.WriteLine($"[ServerManager] Started copilot server PID {process.Id} on port {port}"); + + // Detach stdout/stderr readers so they don't hold the process + _ = Task.Run(async () => + { + try { while (await process.StandardOutput.ReadLineAsync() != null) { } } catch { } + }); + _ = Task.Run(async () => + { + try { while (await process.StandardError.ReadLineAsync() != null) { } } catch { } + }); + + // Wait for server to become available + for (int i = 0; i < 15; i++) + { + await Task.Delay(1000); + if (CheckServerRunning("localhost", port)) + { + Console.WriteLine($"[ServerManager] Server is ready on port {port}"); + OnStatusChanged?.Invoke(); + return true; + } + } + + Console.WriteLine("[ServerManager] Server started but not responding on port"); + OnStatusChanged?.Invoke(); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"[ServerManager] Error starting server: {ex.Message}"); + return false; + } + } + + /// + /// Stop the persistent server + /// + public void StopServer() + { + var pid = ReadPidFile(); + if (pid != null) + { + try + { + var process = Process.GetProcessById(pid.Value); + process.Kill(); + Console.WriteLine($"[ServerManager] Killed server PID {pid}"); + } + catch (Exception ex) + { + Console.WriteLine($"[ServerManager] Error stopping server: {ex.Message}"); + } + DeletePidFile(); + OnStatusChanged?.Invoke(); + } + } + + /// + /// Check if a server from a previous app session is still alive + /// + public bool DetectExistingServer() + { + var info = ReadPidFileInfo(); + if (info == null) return false; + + ServerPort = info.Value.Port; + if (CheckServerRunning("localhost", info.Value.Port)) + { + Console.WriteLine($"[ServerManager] Found existing server PID {info.Value.Pid} on port {info.Value.Port}"); + return true; + } + + // PID file exists but server is dead β€” clean up + DeletePidFile(); + return false; + } + + private void SavePidFile(int pid, int port) + { + try + { + var dir = Path.GetDirectoryName(PidFilePath)!; + Directory.CreateDirectory(dir); + File.WriteAllText(PidFilePath, $"{pid}\n{port}"); + } + catch { } + } + + private int? ReadPidFile() + { + return ReadPidFileInfo()?.Pid; + } + + private (int Pid, int Port)? ReadPidFileInfo() + { + try + { + if (!File.Exists(PidFilePath)) return null; + var lines = File.ReadAllLines(PidFilePath); + if (lines.Length >= 2 && int.TryParse(lines[0], out var pid) && int.TryParse(lines[1], out var port)) + return (pid, port); + if (lines.Length >= 1 && int.TryParse(lines[0], out pid)) + return (pid, 4321); + } + catch { } + return null; + } + + private void DeletePidFile() + { + try { File.Delete(PidFilePath); } catch { } + } + + private static string FindCopilotBinary() + { + // Try the native binary first (faster startup, better detachment) + var nativePaths = new[] + { + "/opt/homebrew/lib/node_modules/@github/copilot/node_modules/@github/copilot-darwin-arm64/copilot", + "/usr/local/lib/node_modules/@github/copilot/node_modules/@github/copilot-darwin-arm64/copilot", + }; + + foreach (var path in nativePaths) + { + if (File.Exists(path)) return path; + } + + // Fallback to node wrapper + return "copilot"; + } +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 5ae313a27b..79f199c783 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -30,6 +30,16 @@ Notification.requestPermission(); } + window.setupTextareaEnterHandler = function(element) { + if (!element || element._enterHandlerSet) return; + element._enterHandlerSet = true; + element.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + } + }); + }; + window.scrollToBottom = function(element) { if (element) { element.scrollTop = element.scrollHeight;