diff --git a/apps/windows/.gitignore b/apps/windows/.gitignore
new file mode 100644
index 000000000..a0b5ac9fb
--- /dev/null
+++ b/apps/windows/.gitignore
@@ -0,0 +1,14 @@
+# Build outputs
+bin/
+obj/
+publish/
+publish-*/
+dist/
+
+# IDE
+.vs/
+*.user
+*.suo
+
+# WebView2 cache (generated at runtime)
+*.WebView2/
diff --git a/apps/windows/ClawdbotWindows.sln b/apps/windows/ClawdbotWindows.sln
new file mode 100644
index 000000000..e8023476a
--- /dev/null
+++ b/apps/windows/ClawdbotWindows.sln
@@ -0,0 +1,86 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows.Protocol", "src\Clawdbot.Windows.Protocol\Clawdbot.Windows.Protocol.csproj", "{EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows.Core", "src\Clawdbot.Windows.Core\Clawdbot.Windows.Core.csproj", "{05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows", "src\Clawdbot.Windows\Clawdbot.Windows.csproj", "{E9C2C285-C332-428E-805D-D857DE5F17DC}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows.Tests", "tests\Clawdbot.Windows.Tests\Clawdbot.Windows.Tests.csproj", "{C72866C4-C786-4B95-AF15-178167AFA4A9}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x64.Build.0 = Debug|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x86.Build.0 = Debug|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x64.ActiveCfg = Release|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x64.Build.0 = Release|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x86.ActiveCfg = Release|Any CPU
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x86.Build.0 = Release|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x64.Build.0 = Debug|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x86.Build.0 = Debug|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x64.ActiveCfg = Release|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x64.Build.0 = Release|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x86.ActiveCfg = Release|Any CPU
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x86.Build.0 = Release|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x64.Build.0 = Debug|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x86.Build.0 = Debug|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x64.ActiveCfg = Release|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x64.Build.0 = Release|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x86.ActiveCfg = Release|Any CPU
+ {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x86.Build.0 = Release|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x64.Build.0 = Debug|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x86.Build.0 = Debug|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x64.ActiveCfg = Release|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x64.Build.0 = Release|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x86.ActiveCfg = Release|Any CPU
+ {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {E9C2C285-C332-428E-805D-D857DE5F17DC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {C72866C4-C786-4B95-AF15-178167AFA4A9} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/apps/windows/README.md b/apps/windows/README.md
new file mode 100644
index 000000000..249aca032
--- /dev/null
+++ b/apps/windows/README.md
@@ -0,0 +1,226 @@
+# Clawdbot Windows Companion
+
+Windows companion application for the Clawdbot Gateway, providing system tray integration and Control UI access.
+
+## Project Structure
+
+```
+apps/windows/
+├── ClawdbotWindows.sln # Solution file
+├── src/
+│ ├── Clawdbot.Windows/ # Main WPF application
+│ │ ├── App.xaml(.cs) # Application entry point
+│ │ ├── MainWindow.xaml(.cs) # WebView2 Control UI window
+│ │ ├── ExecApprovalDialog.xaml(.cs) # Command approval dialog
+│ │ ├── SettingsWindow.xaml(.cs) # Settings UI dialog
+│ │ └── SystemTrayIcon.cs # System tray implementation
+│ ├── Clawdbot.Windows.Core/ # Gateway client and services
+│ │ ├── GatewayChannel.cs # WebSocket client
+│ │ ├── AppLogger.cs # File-based logging
+│ │ ├── AppSettings.cs # Settings persistence (JSON)
+│ │ ├── AutoStartHelper.cs # Windows auto-start (Registry)
+│ │ ├── NotificationSounds.cs # System notification sounds
+│ │ ├── WebView2Helper.cs # WebView2 runtime detection
+│ │ ├── ExecApprovalModels.cs # Exec approval request/response types
+│ │ └── ExecApprovalService.cs # Exec approval event handling
+│ └── Clawdbot.Windows.Protocol/ # Auto-generated protocol models
+│ └── GatewayModels.cs # Protocol v3 types
+└── tests/
+ └── Clawdbot.Windows.Tests/ # Unit and integration tests
+ ├── Phase0ValidationTests.cs # Gateway connection tests
+ ├── ExecApprovalTests.cs # Exec approval model tests
+ └── SettingsTests.cs # Settings persistence tests
+```
+
+## Prerequisites
+
+- .NET 9.0 SDK
+- Windows 10 version 1903 or later (for WebView2)
+- Microsoft Edge WebView2 Runtime (auto-detected, downloads prompted if missing)
+- Clawdbot Gateway running (in WSL2 or natively)
+
+## Building
+
+```powershell
+cd apps/windows
+dotnet build
+```
+
+## Running
+
+```powershell
+# From the apps/windows directory
+dotnet run --project src\Clawdbot.Windows
+```
+
+Or run the built executable directly:
+```powershell
+.\src\Clawdbot.Windows\bin\Debug\net9.0-windows\Clawdbot.exe
+```
+
+The application will:
+1. Start minimized to the system tray
+2. Attempt to connect to the Gateway at `ws://127.0.0.1:18789/`
+3. Display connection status via the tray icon color
+4. Show the Control UI in a WebView2 window when double-clicking the tray icon
+
+## Logging
+
+Logs are written to:
+```
+%LOCALAPPDATA%\Clawdbot\logs\clawdbot-YYYY-MM-DD.log
+```
+
+View logs in PowerShell:
+```powershell
+Get-Content "$env:LOCALAPPDATA\Clawdbot\logs\clawdbot-$(Get-Date -Format 'yyyy-MM-dd').log" -Wait
+```
+
+## Settings
+
+Settings are stored in:
+```
+%LOCALAPPDATA%\Clawdbot\settings.json
+```
+
+Access settings via the system tray icon → **Settings**.
+
+### Available Settings
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| Gateway URL | `ws://127.0.0.1:18789/` | WebSocket URL of the Gateway |
+| Start on login | Off | Launch automatically at Windows startup |
+| Minimize to tray | On | Minimize to tray instead of closing |
+| Play sounds | On | Play notification sounds for events |
+| Show connection notifications | On | Show balloon notifications |
+
+### Auto-Start
+
+When enabled, adds a registry entry to:
+```
+HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
+```
+
+This starts Clawdbot automatically when you log in to Windows.
+
+## Exec Approval Dialogs
+
+When an AI agent requests to run a command, Clawdbot shows an approval dialog:
+
+
+
+### Features
+- **Command display**: Shows the full command in a monospaced font
+- **Context details**: Working directory, executable path, host, agent ID, security level
+- **Countdown timer**: Auto-denies after timeout (typically 60 seconds)
+- **Three actions**:
+ - **Allow Once** (green): Run this command once
+ - **Always Allow** (blue): Add to allowlist and run
+ - **Don't Allow** (red): Deny the command
+
+### Testing
+In Debug builds, right-click the system tray icon and select **Debug > Test Approval Dialog** to see a sample dialog.
+
+### Keyboard Shortcuts
+- **Enter**: Allow Once (default button)
+- **Escape**: Don't Allow
+
+## Testing
+
+### Unit Tests
+```powershell
+dotnet test
+```
+
+Current test results: **23 passing, 7 skipped** (integration tests require Gateway)
+
+### Integration Tests (requires running Gateway)
+Start the Gateway first:
+```bash
+# In WSL2 or Linux terminal
+clawdbot gateway run --bind loopback --port 18789
+```
+
+Then the integration tests will run automatically (they detect if the Gateway is available).
+
+## Architecture
+
+### Clawdbot.Windows.Protocol
+Auto-generated C# models matching the Gateway's TypeScript schema (Protocol version 3).
+- Request/response frames
+- Event payloads
+- Configuration types
+
+### Clawdbot.Windows.Core
+Gateway client with:
+- WebSocket connection management
+- Automatic reconnection with exponential backoff
+- Request/response correlation
+- Event subscription and dispatch
+- File-based logging (AppLogger)
+- WebView2 runtime detection (WebView2Helper)
+
+### Clawdbot.Windows (WPF App)
+- System tray icon with context menu
+- WebView2 embedding the Control UI (`http://127.0.0.1:18789/ui`)
+- Connection status visualization
+- Graceful exit handling
+
+## Development Status
+
+**Phase 0: Foundation Validation** ✅
+- [x] Project structure
+- [x] Protocol models (GatewayModels.cs)
+- [x] Gateway WebSocket client (GatewayChannel.cs)
+- [x] Unit tests passing (23/23)
+- [x] Application icon from favicon.ico
+- [x] File-based debug logging
+- [x] WebView2 runtime detection
+
+**Phase 1: Core Features** ✅
+- [x] System tray icon with context menu
+- [x] WebView2 Control UI embedding
+- [x] Connection status in tray tooltip
+- [x] Exec approval dialogs (ExecApprovalDialog.xaml)
+- [x] Exec approval service with queue management
+- [x] Timeout handling (auto-deny on expiry)
+- [x] Debug menu for testing dialogs
+
+**Phase 2: Production Ready** ✅
+- [x] Settings persistence (`%LOCALAPPDATA%\Clawdbot\settings.json`)
+- [x] Auto-start on login (Windows Registry)
+- [x] Settings UI window
+- [x] Notification sounds (Windows system sounds)
+- [x] Settings tests (6 new tests)
+
+**Phase 3: Distribution** 🔲
+- [ ] Installer/packaging (MSIX or MSI)
+- [ ] Auto-update mechanism
+- [ ] Code signing
+- [ ] Release automation
+
+See [Windows-Companion-App-Plan.md](../../Windows-Companion-App-Plan.md) for the full roadmap.
+
+## Known Issues
+
+1. **No Gateway Available**: Without a running Gateway, the app shows "Disconnected" status. Need to install pnpm/bun to build and run the Gateway locally.
+
+2. **Manual Testing Required**: Some features (tray icon visibility, window opening) require manual verification.
+
+3. **WebView2 Control UI**: When Gateway is not connected, the WebView2 shows a "Waiting for Gateway connection..." overlay.
+
+## Gateway Connection
+
+The Windows companion connects to the Clawdbot Gateway via WebSocket:
+
+| Component | URL |
+|-----------|-----|
+| Gateway WebSocket | `ws://127.0.0.1:18789/` |
+| Control UI | `http://127.0.0.1:18789/ui` |
+
+The Gateway must be running for the companion app to function.
+
+## License
+
+MIT - see [LICENSE](../../LICENSE) in the project root.
diff --git a/apps/windows/installer/clawdbot.iss b/apps/windows/installer/clawdbot.iss
new file mode 100644
index 000000000..ad8cac267
--- /dev/null
+++ b/apps/windows/installer/clawdbot.iss
@@ -0,0 +1,96 @@
+; Clawdbot Windows Companion - Inno Setup Script
+; Download Inno Setup from: https://jrsoftware.org/isdl.php
+
+#define MyAppName "Clawdbot"
+#define MyAppVersion "2025.1.25"
+#define MyAppPublisher "Clawdbot"
+#define MyAppURL "https://clawd.bot"
+#define MyAppExeName "Clawdbot.exe"
+
+[Setup]
+; NOTE: AppId uniquely identifies this application
+AppId={{8E7B9A42-F3D8-4C91-B5E6-1A2B3C4D5E6F}
+AppName={#MyAppName}
+AppVersion={#MyAppVersion}
+AppPublisher={#MyAppPublisher}
+AppPublisherURL={#MyAppURL}
+AppSupportURL={#MyAppURL}/support
+AppUpdatesURL={#MyAppURL}/updates
+DefaultDirName={autopf}\{#MyAppName}
+DefaultGroupName={#MyAppName}
+AllowNoIcons=yes
+; Output settings
+OutputDir=..\dist
+OutputBaseFilename=ClawdbotSetup-{#MyAppVersion}
+; Compression
+Compression=lzma2/ultra64
+SolidCompression=yes
+; Require admin for Program Files install
+PrivilegesRequired=admin
+PrivilegesRequiredOverridesAllowed=dialog
+; Visual settings
+SetupIconFile=..\src\Clawdbot.Windows\Assets\clawdbot.ico
+WizardStyle=modern
+; Minimum Windows version (Windows 10 1903)
+MinVersion=10.0.18362
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
+Name: "startupicon"; Description: "Start Clawdbot when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked
+
+[Files]
+; Main application files from publish folder
+Source: "..\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+; NOTE: Don't use "Flags: ignoreversion" on any shared system files
+
+[Icons]
+; Start menu
+Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
+Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
+; Desktop (optional)
+Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
+
+[Registry]
+; Auto-start on login (if selected)
+Root: HKCU; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "Clawdbot"; ValueData: """{app}\{#MyAppExeName}"""; Flags: uninsdeletevalue; Tasks: startupicon
+
+[Run]
+; Option to launch after install
+Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+
+[UninstallRun]
+; Stop the app before uninstall
+Filename: "taskkill"; Parameters: "/F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "StopClawdbot"
+
+[UninstallDelete]
+; Clean up app data (optional - ask user?)
+; Type: filesandordirs; Name: "{localappdata}\Clawdbot"
+
+[Code]
+// Check for .NET 9 runtime (framework-dependent builds only)
+function IsDotNet9Installed: Boolean;
+var
+ ResultCode: Integer;
+begin
+ // Try to run dotnet --list-runtimes and check for 9.0
+ Result := Exec('dotnet', '--list-runtimes', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
+end;
+
+function InitializeSetup: Boolean;
+begin
+ Result := True;
+
+ // Uncomment for framework-dependent builds:
+ // if not IsDotNet9Installed then
+ // begin
+ // if MsgBox('Clawdbot requires .NET 9.0 Runtime.'#13#13'Would you like to download it now?',
+ // mbConfirmation, MB_YESNO) = IDYES then
+ // begin
+ // ShellExec('open', 'https://dotnet.microsoft.com/download/dotnet/9.0', '', '', SW_SHOW, ewNoWait, ResultCode);
+ // end;
+ // Result := False;
+ // end;
+end;
diff --git a/apps/windows/scripts/build.ps1 b/apps/windows/scripts/build.ps1
new file mode 100644
index 000000000..6b3718a53
--- /dev/null
+++ b/apps/windows/scripts/build.ps1
@@ -0,0 +1,115 @@
+# Clawdbot Windows Companion - Build & Package Script
+# Run from apps/windows directory
+
+param(
+ [ValidateSet("Debug", "Release")]
+ [string]$Configuration = "Release",
+
+ [switch]$SelfContained,
+ [switch]$SingleFile,
+ [switch]$CreateInstaller
+)
+
+$ErrorActionPreference = "Stop"
+
+# Paths
+$ProjectPath = "src\Clawdbot.Windows\Clawdbot.Windows.csproj"
+$OutputDir = "publish"
+$InstallerDir = "installer"
+
+Write-Host "=== Clawdbot Windows Companion Build ===" -ForegroundColor Cyan
+Write-Host "Configuration: $Configuration"
+Write-Host "Self-Contained: $SelfContained"
+Write-Host "Single File: $SingleFile"
+Write-Host ""
+
+# Clean previous builds
+if (Test-Path $OutputDir) {
+ Write-Host "Cleaning previous publish output..." -ForegroundColor Yellow
+ Remove-Item -Recurse -Force $OutputDir
+}
+
+# Build arguments
+$PublishArgs = @(
+ "publish",
+ $ProjectPath,
+ "-c", $Configuration,
+ "-o", $OutputDir,
+ "-r", "win-x64"
+)
+
+if ($SelfContained) {
+ $PublishArgs += "--self-contained", "true"
+ Write-Host "Building self-contained (includes .NET runtime)..." -ForegroundColor Green
+} else {
+ $PublishArgs += "--self-contained", "false"
+ Write-Host "Building framework-dependent (requires .NET 9 runtime)..." -ForegroundColor Green
+}
+
+if ($SingleFile) {
+ $PublishArgs += "-p:PublishSingleFile=true"
+ $PublishArgs += "-p:IncludeNativeLibrariesForSelfExtract=true"
+ Write-Host "Creating single-file executable..." -ForegroundColor Green
+}
+
+# Run dotnet publish
+Write-Host ""
+Write-Host "Running: dotnet $($PublishArgs -join ' ')" -ForegroundColor Gray
+& dotnet @PublishArgs
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Build failed!" -ForegroundColor Red
+ exit 1
+}
+
+# Get output info
+$ExePath = Join-Path $OutputDir "Clawdbot.exe"
+if (Test-Path $ExePath) {
+ $ExeSize = (Get-Item $ExePath).Length / 1MB
+ Write-Host ""
+ Write-Host "Build successful!" -ForegroundColor Green
+ Write-Host "Output: $ExePath"
+ Write-Host "Size: $([math]::Round($ExeSize, 2)) MB"
+
+ # List all files
+ Write-Host ""
+ Write-Host "Published files:" -ForegroundColor Cyan
+ Get-ChildItem $OutputDir -Recurse | Where-Object { !$_.PSIsContainer } | ForEach-Object {
+ $RelPath = $_.FullName.Replace((Resolve-Path $OutputDir).Path + "\", "")
+ $Size = [math]::Round($_.Length / 1KB, 1)
+ Write-Host " $RelPath ($Size KB)"
+ }
+}
+
+# Create installer if requested
+if ($CreateInstaller) {
+ Write-Host ""
+ Write-Host "=== Creating Installer ===" -ForegroundColor Cyan
+
+ # Check for Inno Setup
+ $InnoPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
+ if (!(Test-Path $InnoPath)) {
+ Write-Host "Inno Setup not found at: $InnoPath" -ForegroundColor Yellow
+ Write-Host "Download from: https://jrsoftware.org/isdl.php" -ForegroundColor Yellow
+ Write-Host "Skipping installer creation." -ForegroundColor Yellow
+ } else {
+ # Create installer script if not exists
+ $IssPath = Join-Path $InstallerDir "clawdbot.iss"
+ if (!(Test-Path $IssPath)) {
+ Write-Host "Installer script not found: $IssPath" -ForegroundColor Yellow
+ Write-Host "Run: .\scripts\create-installer-script.ps1 first" -ForegroundColor Yellow
+ } else {
+ Write-Host "Building installer with Inno Setup..."
+ & $InnoPath $IssPath
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "Installer created successfully!" -ForegroundColor Green
+ }
+ }
+ }
+}
+
+Write-Host ""
+Write-Host "=== Done ===" -ForegroundColor Cyan
+Write-Host ""
+Write-Host "To run: .\$ExePath"
+Write-Host "To test: dotnet test"
diff --git a/apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs b/apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs
new file mode 100644
index 000000000..46d9b5d5f
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs
@@ -0,0 +1,81 @@
+using System.Diagnostics;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Simple file-based logger for debugging
+///
+public static class AppLogger
+{
+ private static readonly string LogPath;
+ private static readonly object WriteLock = new();
+
+ static AppLogger()
+ {
+ // Log to user's AppData folder
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var logDir = Path.Combine(appData, "Clawdbot", "logs");
+ Directory.CreateDirectory(logDir);
+
+ LogPath = Path.Combine(logDir, $"clawdbot-{DateTime.Now:yyyy-MM-dd}.log");
+
+ // Log startup
+ Info($"=== Clawdbot Windows Companion Started ===");
+ Info($"Log path: {LogPath}");
+ Info($".NET Version: {Environment.Version}");
+ Info($"OS: {Environment.OSVersion}");
+ Info($"Machine: {Environment.MachineName}");
+ }
+
+ public static void Info(string message)
+ {
+ Write("INFO", message);
+ }
+
+ public static void Debug(string message)
+ {
+ Write("DEBUG", message);
+ }
+
+ public static void Warn(string message)
+ {
+ Write("WARN", message);
+ }
+
+ public static void Error(string message, Exception? ex = null)
+ {
+ Write("ERROR", message);
+ if (ex != null)
+ {
+ Write("ERROR", $" Exception: {ex.GetType().Name}: {ex.Message}");
+ Write("ERROR", $" Stack: {ex.StackTrace}");
+ if (ex.InnerException != null)
+ {
+ Write("ERROR", $" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
+ }
+ }
+ }
+
+ private static void Write(string level, string message)
+ {
+ var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
+ var logLine = $"[{timestamp}] [{level,-5}] {message}";
+
+ lock (WriteLock)
+ {
+ try
+ {
+ File.AppendAllText(LogPath, logLine + Environment.NewLine);
+ }
+ catch
+ {
+ // Ignore logging failures
+ }
+ }
+
+ // Also write to debug output
+ System.Diagnostics.Debug.WriteLine(logLine);
+ }
+
+ public static string GetLogPath() => LogPath;
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs b/apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs
new file mode 100644
index 000000000..0f9224234
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs
@@ -0,0 +1,164 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Application settings persisted to disk.
+/// Stored at %LOCALAPPDATA%\Clawdbot\settings.json
+///
+public class AppSettings
+{
+ private static readonly string SettingsDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Clawdbot");
+
+ private static readonly string SettingsPath = Path.Combine(SettingsDirectory, "settings.json");
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ ///
+ /// Gateway WebSocket URL (default: ws://127.0.0.1:18789/)
+ ///
+ [JsonPropertyName("gatewayUrl")]
+ public string GatewayUrl { get; set; } = "ws://127.0.0.1:18789/";
+
+ ///
+ /// Whether to start Clawdbot when Windows starts
+ ///
+ [JsonPropertyName("startOnLogin")]
+ public bool StartOnLogin { get; set; } = false;
+
+ ///
+ /// Whether to minimize to tray instead of closing when X is clicked
+ ///
+ [JsonPropertyName("minimizeToTray")]
+ public bool MinimizeToTray { get; set; } = true;
+
+ ///
+ /// Whether to play notification sounds
+ ///
+ [JsonPropertyName("playSounds")]
+ public bool PlaySounds { get; set; } = true;
+
+ ///
+ /// Whether to show balloon notifications for connection status changes
+ ///
+ [JsonPropertyName("showConnectionNotifications")]
+ public bool ShowConnectionNotifications { get; set; } = true;
+
+ ///
+ /// Auto-reconnect interval in seconds (0 = disabled)
+ ///
+ [JsonPropertyName("reconnectIntervalSeconds")]
+ public int ReconnectIntervalSeconds { get; set; } = 5;
+
+ ///
+ /// Exec approval timeout in seconds (0 = use server default)
+ ///
+ [JsonPropertyName("execApprovalTimeoutSeconds")]
+ public int ExecApprovalTimeoutSeconds { get; set; } = 0;
+
+ ///
+ /// Last window position (X)
+ ///
+ [JsonPropertyName("windowX")]
+ public double? WindowX { get; set; }
+
+ ///
+ /// Last window position (Y)
+ ///
+ [JsonPropertyName("windowY")]
+ public double? WindowY { get; set; }
+
+ ///
+ /// Last window width
+ ///
+ [JsonPropertyName("windowWidth")]
+ public double? WindowWidth { get; set; }
+
+ ///
+ /// Last window height
+ ///
+ [JsonPropertyName("windowHeight")]
+ public double? WindowHeight { get; set; }
+
+ ///
+ /// Loads settings from disk, or returns default settings if file doesn't exist.
+ ///
+ public static AppSettings Load()
+ {
+ try
+ {
+ if (File.Exists(SettingsPath))
+ {
+ var json = File.ReadAllText(SettingsPath);
+ var settings = JsonSerializer.Deserialize(json, JsonOptions);
+ if (settings != null)
+ {
+ AppLogger.Info($"Loaded settings from {SettingsPath}");
+ return settings;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn($"Failed to load settings: {ex.Message}");
+ }
+
+ AppLogger.Info("Using default settings");
+ return new AppSettings();
+ }
+
+ ///
+ /// Saves settings to disk.
+ ///
+ public void Save()
+ {
+ try
+ {
+ // Ensure directory exists
+ Directory.CreateDirectory(SettingsDirectory);
+
+ var json = JsonSerializer.Serialize(this, JsonOptions);
+ File.WriteAllText(SettingsPath, json);
+
+ AppLogger.Info($"Saved settings to {SettingsPath}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Failed to save settings: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Resets settings to defaults and saves.
+ ///
+ public void Reset()
+ {
+ GatewayUrl = "ws://127.0.0.1:18789/";
+ StartOnLogin = false;
+ MinimizeToTray = true;
+ PlaySounds = true;
+ ShowConnectionNotifications = true;
+ ReconnectIntervalSeconds = 5;
+ ExecApprovalTimeoutSeconds = 0;
+ WindowX = null;
+ WindowY = null;
+ WindowWidth = null;
+ WindowHeight = null;
+
+ Save();
+ AppLogger.Info("Settings reset to defaults");
+ }
+
+ ///
+ /// Gets the path to the settings file.
+ ///
+ public static string GetSettingsFilePath() => SettingsPath;
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs b/apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs
new file mode 100644
index 000000000..3320dfc79
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs
@@ -0,0 +1,130 @@
+using Microsoft.Win32;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Manages Windows auto-start (Run at login) functionality via Registry.
+///
+public static class AutoStartHelper
+{
+ private const string RegistryKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
+ private const string AppName = "Clawdbot";
+
+ ///
+ /// Checks if auto-start is currently enabled.
+ ///
+ public static bool IsEnabled()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false);
+ var value = key?.GetValue(AppName);
+ return value != null;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn($"Failed to check auto-start status: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Enables auto-start by adding Registry entry.
+ ///
+ public static bool Enable()
+ {
+ try
+ {
+ var exePath = GetExecutablePath();
+ if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath))
+ {
+ AppLogger.Error($"Cannot enable auto-start: executable not found at {exePath}");
+ return false;
+ }
+
+ using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true);
+ if (key == null)
+ {
+ AppLogger.Error("Failed to open Registry key for auto-start");
+ return false;
+ }
+
+ // Add quotes around path in case it contains spaces
+ key.SetValue(AppName, $"\"{exePath}\" --minimized");
+
+ AppLogger.Info($"Auto-start enabled: {exePath}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Failed to enable auto-start: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Disables auto-start by removing Registry entry.
+ ///
+ public static bool Disable()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true);
+ if (key == null)
+ {
+ AppLogger.Warn("Registry key not found for auto-start");
+ return true; // Already disabled
+ }
+
+ if (key.GetValue(AppName) != null)
+ {
+ key.DeleteValue(AppName);
+ AppLogger.Info("Auto-start disabled");
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Failed to disable auto-start: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Sets auto-start enabled/disabled based on the parameter.
+ ///
+ public static bool SetEnabled(bool enabled)
+ {
+ return enabled ? Enable() : Disable();
+ }
+
+ ///
+ /// Gets the path to the current executable.
+ ///
+ private static string GetExecutablePath()
+ {
+ // Get the entry assembly location
+ var entryAssembly = System.Reflection.Assembly.GetEntryAssembly();
+ if (entryAssembly != null)
+ {
+ var location = entryAssembly.Location;
+
+ // If running via dotnet, the location will be the DLL, not the EXE
+ // Try to find the corresponding .exe
+ if (location.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ {
+ var exePath = Path.ChangeExtension(location, ".exe");
+ if (File.Exists(exePath))
+ {
+ return exePath;
+ }
+ }
+
+ return location;
+ }
+
+ // Fallback: use process path
+ return Environment.ProcessPath ?? string.Empty;
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj b/apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj
new file mode 100644
index 000000000..7ea09110f
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net9.0-windows
+ enable
+ enable
+ Clawdbot.Windows.Core
+ Core Gateway client and services for Clawdbot Windows companion
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs
new file mode 100644
index 000000000..c9cee59e3
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs
@@ -0,0 +1,82 @@
+namespace Clawdbot.Windows.Core;
+
+///
+/// Request for exec approval from the Gateway
+///
+public class ExecApprovalPromptRequest
+{
+ public required string Command { get; init; }
+ public string? Cwd { get; init; }
+ public string? Host { get; init; }
+ public string? Security { get; init; }
+ public string? Ask { get; init; }
+ public string? AgentId { get; init; }
+ public string? ResolvedPath { get; init; }
+ public string? SessionKey { get; init; }
+}
+
+///
+/// Gateway exec approval request wrapper
+///
+public class GatewayExecApprovalRequest
+{
+ public required string Id { get; init; }
+ public required ExecApprovalPromptRequest Request { get; init; }
+ public long CreatedAtMs { get; init; }
+ public long ExpiresAtMs { get; init; }
+
+ ///
+ /// Check if the request has expired
+ ///
+ public bool IsExpired => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > ExpiresAtMs;
+
+ ///
+ /// Time remaining until expiration
+ ///
+ public TimeSpan TimeRemaining
+ {
+ get
+ {
+ var remainingMs = ExpiresAtMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ return remainingMs > 0 ? TimeSpan.FromMilliseconds(remainingMs) : TimeSpan.Zero;
+ }
+ }
+}
+
+///
+/// User's decision on an exec approval request
+///
+public enum ExecApprovalDecision
+{
+ ///
+ /// Allow this command to run once
+ ///
+ AllowOnce,
+
+ ///
+ /// Allow this command and add to allowlist
+ ///
+ AllowAlways,
+
+ ///
+ /// Deny the command
+ ///
+ Deny
+}
+
+///
+/// Extension methods for ExecApprovalDecision
+///
+public static class ExecApprovalDecisionExtensions
+{
+ ///
+ /// Convert decision to the wire format expected by Gateway
+ ///
+ public static string ToWireFormat(this ExecApprovalDecision decision) => decision switch
+ {
+ ExecApprovalDecision.AllowOnce => "allow-once",
+ ExecApprovalDecision.AllowAlways => "allow-always",
+ ExecApprovalDecision.Deny => "deny",
+ _ => "deny"
+ };
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs
new file mode 100644
index 000000000..207dc527f
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs
@@ -0,0 +1,212 @@
+using System.Text.Json;
+using Clawdbot.Windows.Protocol;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Handles exec approval requests from the Gateway.
+/// Manages the queue of pending approvals and dispatches them to the UI thread.
+///
+public sealed class ExecApprovalService : IDisposable
+{
+ private readonly GatewayChannel _gateway;
+ private readonly Queue _pendingRequests = new();
+ private readonly object _lock = new();
+ private bool _isShowingDialog;
+ private bool _disposed;
+
+ ///
+ /// Raised when an exec approval request is received from the Gateway.
+ /// Handler should show the dialog and return the decision.
+ ///
+ public event Func? ApprovalRequested;
+
+ ///
+ /// Raised when there are no more pending approvals.
+ ///
+ public event Action? QueueEmpty;
+
+ ///
+ /// Gets the number of pending approval requests.
+ ///
+ public int PendingCount
+ {
+ get
+ {
+ lock (_lock) return _pendingRequests.Count;
+ }
+ }
+
+ public ExecApprovalService(GatewayChannel gateway)
+ {
+ _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway));
+ _gateway.EventReceived += OnGatewayEvent;
+ }
+
+ private void OnGatewayEvent(object? sender, GatewayEventArgs e)
+ {
+ if (e.EventName == "exec.approval.requested")
+ {
+ HandleApprovalRequested(e);
+ }
+ }
+
+ private void HandleApprovalRequested(GatewayEventArgs evt)
+ {
+ try
+ {
+ // Parse the request from the event payload
+ var request = ParseApprovalRequest(evt.Payload);
+ if (request == null)
+ {
+ AppLogger.Warn("Failed to parse exec.approval.requested event payload");
+ return;
+ }
+
+ AppLogger.Info($"Received exec approval request: id={request.Id}, command={TruncateCommand(request.Request.Command)}");
+
+ // Add to queue and process
+ lock (_lock)
+ {
+ _pendingRequests.Enqueue(request);
+ }
+
+ ProcessNextRequest();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Error handling exec.approval.requested: {ex.Message}");
+ }
+ }
+
+ private static GatewayExecApprovalRequest? ParseApprovalRequest(JsonElement? payload)
+ {
+ if (payload == null || !payload.HasValue) return null;
+
+ try
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ };
+ return payload.Value.Deserialize(options);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Failed to parse approval request: {ex.Message}");
+ return null;
+ }
+ }
+
+ private void ProcessNextRequest()
+ {
+ GatewayExecApprovalRequest? request = null;
+
+ lock (_lock)
+ {
+ if (_isShowingDialog || _pendingRequests.Count == 0)
+ {
+ return;
+ }
+
+ // Skip expired requests
+ while (_pendingRequests.Count > 0)
+ {
+ var candidate = _pendingRequests.Peek();
+ if (candidate.IsExpired)
+ {
+ _pendingRequests.Dequeue();
+ AppLogger.Info($"Skipping expired approval request: id={candidate.Id}");
+ // Auto-deny expired requests
+ SendDecision(candidate.Id, ExecApprovalDecision.Deny);
+ }
+ else
+ {
+ request = _pendingRequests.Dequeue();
+ break;
+ }
+ }
+
+ if (request == null)
+ {
+ QueueEmpty?.Invoke();
+ return;
+ }
+
+ _isShowingDialog = true;
+ }
+
+ // Show approval dialog on UI thread
+ ShowApprovalDialog(request);
+ }
+
+ private void ShowApprovalDialog(GatewayExecApprovalRequest request)
+ {
+ try
+ {
+ // The caller is responsible for UI thread dispatch
+ var decision = ApprovalRequested?.Invoke(request) ?? ExecApprovalDecision.Deny;
+ HandleDecision(request.Id, decision);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Error showing approval dialog: {ex.Message}");
+ HandleDecision(request.Id, ExecApprovalDecision.Deny);
+ }
+ }
+
+ private void HandleDecision(string requestId, ExecApprovalDecision decision)
+ {
+ AppLogger.Info($"User decision for request {requestId}: {decision}");
+
+ // Send decision to Gateway
+ SendDecision(requestId, decision);
+
+ lock (_lock)
+ {
+ _isShowingDialog = false;
+ }
+
+ // Process next request in queue
+ ProcessNextRequest();
+ }
+
+ private void SendDecision(string requestId, ExecApprovalDecision decision)
+ {
+ try
+ {
+ var payload = new
+ {
+ id = requestId,
+ decision = decision.ToWireFormat()
+ };
+
+ _ = _gateway.SendAsync("exec.approval.resolve", payload);
+ AppLogger.Info($"Sent exec approval decision: id={requestId}, decision={decision.ToWireFormat()}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Failed to send approval decision: {ex.Message}");
+ }
+ }
+
+ private static string TruncateCommand(string command, int maxLength = 50)
+ {
+ if (string.IsNullOrEmpty(command)) return "(empty)";
+ if (command.Length <= maxLength) return command;
+ return command[..(maxLength - 3)] + "...";
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _gateway.EventReceived -= OnGatewayEvent;
+
+ lock (_lock)
+ {
+ _pendingRequests.Clear();
+ }
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs b/apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs
new file mode 100644
index 000000000..0cb6ab15b
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs
@@ -0,0 +1,472 @@
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Clawdbot.Windows.Protocol;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Gateway connection state
+///
+public enum GatewayState
+{
+ Disconnected,
+ Connecting,
+ Connected,
+ Reconnecting
+}
+
+///
+/// Exception thrown when Gateway request fails
+///
+public class GatewayException : Exception
+{
+ public string Code { get; }
+ public object? ErrorData { get; }
+
+ public GatewayException(string code, string message, object? data = null)
+ : base(message)
+ {
+ Code = code;
+ ErrorData = data;
+ }
+}
+
+///
+/// Event args for Gateway events
+///
+public class GatewayEventArgs : EventArgs
+{
+ public required string EventName { get; init; }
+ public JsonElement? Payload { get; init; }
+}
+
+///
+/// Event args for state changes
+///
+public class GatewayStateChangedEventArgs : EventArgs
+{
+ public GatewayState OldState { get; init; }
+ public GatewayState NewState { get; init; }
+}
+
+///
+/// WebSocket client for Clawdbot Gateway.
+/// Handles connection, reconnection, request/response correlation, and event dispatch.
+///
+public class GatewayChannel : IAsyncDisposable
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly string _url;
+ private readonly ConnectParams _connectParams;
+ private ClientWebSocket? _webSocket;
+ private CancellationTokenSource? _receiveCts;
+ private Task? _receiveTask;
+
+ private readonly ConcurrentDictionary> _pending = new();
+ private int _requestId;
+ private GatewayState _state = GatewayState.Disconnected;
+
+ private int _reconnectAttempts;
+ private const int MaxReconnectAttempts = 10;
+ private static readonly int[] BackoffMs = { 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000 };
+
+ ///
+ /// Current connection state
+ ///
+ public GatewayState State => _state;
+
+ ///
+ /// Snapshot received on connection
+ ///
+ public Snapshot? Snapshot { get; private set; }
+
+ ///
+ /// Hello response from Gateway
+ ///
+ public HelloOk? HelloResponse { get; private set; }
+
+ ///
+ /// Fired when an event is received from Gateway
+ ///
+ public event EventHandler? EventReceived;
+
+ ///
+ /// Fired when connection state changes
+ ///
+ public event EventHandler? StateChanged;
+
+ ///
+ /// Fired when connection is lost
+ ///
+ public event EventHandler? Disconnected;
+
+ ///
+ /// Fired when reconnection succeeds
+ ///
+ public event EventHandler? Reconnected;
+
+ ///
+ /// Create a new Gateway channel
+ ///
+ /// WebSocket URL, e.g. ws://127.0.0.1:18789/
+ /// Optional connection parameters
+ public GatewayChannel(string url = "ws://127.0.0.1:18789/", ConnectParams? connectParams = null)
+ {
+ _url = url.TrimEnd('/') + "/";
+ _connectParams = connectParams ?? new ConnectParams();
+ }
+
+ ///
+ /// Connect to Gateway
+ ///
+ public async Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+ if (_state == GatewayState.Connected)
+ return;
+
+ SetState(GatewayState.Connecting);
+
+ try
+ {
+ _webSocket = new ClientWebSocket();
+ await _webSocket.ConnectAsync(new Uri(_url), cancellationToken);
+
+ // Send hello
+ var helloJson = JsonSerializer.Serialize(_connectParams, JsonOptions);
+ var helloBytes = Encoding.UTF8.GetBytes(helloJson);
+ await _webSocket.SendAsync(helloBytes, WebSocketMessageType.Text, true, cancellationToken);
+
+ // Receive hello.ok
+ var helloResponse = await ReceiveOneAsync(cancellationToken);
+ HelloResponse = JsonSerializer.Deserialize(helloResponse, JsonOptions)
+ ?? throw new GatewayException("PROTOCOL_ERROR", "Invalid hello response");
+
+ Snapshot = HelloResponse.Snapshot;
+
+ // Start receive loop
+ _receiveCts = new CancellationTokenSource();
+ _receiveTask = ReceiveLoopAsync(_receiveCts.Token);
+
+ _reconnectAttempts = 0;
+ SetState(GatewayState.Connected);
+ }
+ catch (Exception) when (_state == GatewayState.Connecting)
+ {
+ SetState(GatewayState.Disconnected);
+ throw;
+ }
+ }
+
+ ///
+ /// Send a request to Gateway and wait for response
+ ///
+ public async Task RequestAsync(
+ string method,
+ object? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ var responseJson = await RequestRawAsync(method, parameters, cancellationToken);
+ return JsonSerializer.Deserialize(responseJson, JsonOptions)
+ ?? throw new GatewayException("PROTOCOL_ERROR", $"Failed to deserialize response for {method}");
+ }
+
+ ///
+ /// Send a request to Gateway and wait for raw JSON response
+ ///
+ public async Task RequestRawAsync(
+ string method,
+ object? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureConnected();
+
+ var id = Interlocked.Increment(ref _requestId).ToString();
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ if (!_pending.TryAdd(id, tcs))
+ throw new InvalidOperationException($"Duplicate request ID: {id}");
+
+ try
+ {
+ var request = new RequestFrame
+ {
+ Id = id,
+ Method = method,
+ Params = parameters
+ };
+
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ await _webSocket!.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
+
+ using (timeoutCts.Token.Register(() => tcs.TrySetCanceled()))
+ {
+ return await tcs.Task;
+ }
+ }
+ finally
+ {
+ _pending.TryRemove(id, out _);
+ }
+ }
+
+ ///
+ /// Send a request without waiting for response (fire and forget)
+ ///
+ public async Task SendAsync(string method, object? parameters = null, CancellationToken cancellationToken = default)
+ {
+ EnsureConnected();
+
+ var id = Interlocked.Increment(ref _requestId).ToString();
+ var request = new RequestFrame
+ {
+ Id = id,
+ Method = method,
+ Params = parameters
+ };
+
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ await _webSocket!.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken);
+ }
+
+ ///
+ /// Disconnect from Gateway
+ ///
+ public async Task DisconnectAsync()
+ {
+ _receiveCts?.Cancel();
+
+ if (_webSocket?.State == WebSocketState.Open)
+ {
+ try
+ {
+ await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnect", CancellationToken.None);
+ }
+ catch
+ {
+ // Ignore close errors
+ }
+ }
+
+ _webSocket?.Dispose();
+ _webSocket = null;
+ SetState(GatewayState.Disconnected);
+
+ // Cancel all pending requests
+ foreach (var (_, tcs) in _pending)
+ {
+ tcs.TrySetCanceled();
+ }
+ _pending.Clear();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisconnectAsync();
+ _receiveCts?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private async Task ReceiveOneAsync(CancellationToken cancellationToken)
+ {
+ var buffer = new byte[8192];
+ using var ms = new MemoryStream();
+
+ WebSocketReceiveResult result;
+ do
+ {
+ result = await _webSocket!.ReceiveAsync(buffer, cancellationToken);
+ ms.Write(buffer, 0, result.Count);
+ } while (!result.EndOfMessage);
+
+ ms.Position = 0;
+ return await JsonSerializer.DeserializeAsync(ms, JsonOptions, cancellationToken);
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
+ {
+ var buffer = new byte[8192];
+ using var ms = new MemoryStream();
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested && _webSocket?.State == WebSocketState.Open)
+ {
+ ms.SetLength(0);
+
+ WebSocketReceiveResult result;
+ do
+ {
+ result = await _webSocket.ReceiveAsync(buffer, cancellationToken);
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await HandleDisconnectAsync();
+ return;
+ }
+ ms.Write(buffer, 0, result.Count);
+ } while (!result.EndOfMessage);
+
+ ms.Position = 0;
+ var json = await JsonSerializer.DeserializeAsync(ms, JsonOptions, cancellationToken);
+ ProcessMessage(json);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal cancellation
+ }
+ catch (WebSocketException)
+ {
+ await HandleDisconnectAsync();
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Gateway receive error: {ex}");
+ await HandleDisconnectAsync();
+ }
+ }
+
+ private void ProcessMessage(JsonElement json)
+ {
+ if (!json.TryGetProperty("type", out var typeElement))
+ return;
+
+ var type = typeElement.GetString();
+
+ switch (type)
+ {
+ case "response":
+ HandleResponse(json);
+ break;
+ case "event":
+ HandleEvent(json);
+ break;
+ }
+ }
+
+ private void HandleResponse(JsonElement json)
+ {
+ if (!json.TryGetProperty("id", out var idElement))
+ return;
+
+ var id = idElement.GetString();
+ if (id == null || !_pending.TryRemove(id, out var tcs))
+ return;
+
+ if (json.TryGetProperty("error", out var errorElement))
+ {
+ var error = JsonSerializer.Deserialize(errorElement, JsonOptions);
+ tcs.TrySetException(new GatewayException(
+ error?.Code ?? "UNKNOWN",
+ error?.Message ?? "Unknown error",
+ error?.Data));
+ }
+ else if (json.TryGetProperty("payload", out var payloadElement))
+ {
+ tcs.TrySetResult(payloadElement);
+ }
+ else
+ {
+ // Empty success response
+ tcs.TrySetResult(default);
+ }
+ }
+
+ private void HandleEvent(JsonElement json)
+ {
+ if (!json.TryGetProperty("event", out var eventElement))
+ return;
+
+ var eventName = eventElement.GetString();
+ if (eventName == null)
+ return;
+
+ json.TryGetProperty("payload", out var payload);
+
+ // Update local snapshot for certain events
+ if (eventName == "snapshot" && payload.ValueKind == JsonValueKind.Object)
+ {
+ Snapshot = JsonSerializer.Deserialize(payload, JsonOptions);
+ }
+
+ EventReceived?.Invoke(this, new GatewayEventArgs
+ {
+ EventName = eventName,
+ Payload = payload
+ });
+ }
+
+ private async Task HandleDisconnectAsync()
+ {
+ if (_state == GatewayState.Disconnected)
+ return;
+
+ SetState(GatewayState.Disconnected);
+ Disconnected?.Invoke(this, EventArgs.Empty);
+
+ // Attempt reconnection
+ await TryReconnectAsync();
+ }
+
+ private async Task TryReconnectAsync()
+ {
+ if (_reconnectAttempts >= MaxReconnectAttempts)
+ {
+ System.Diagnostics.Debug.WriteLine("Max reconnect attempts reached");
+ return;
+ }
+
+ SetState(GatewayState.Reconnecting);
+
+ var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
+ _reconnectAttempts++;
+
+ await Task.Delay(delay);
+
+ try
+ {
+ await ConnectAsync();
+ Reconnected?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Reconnect attempt {_reconnectAttempts} failed: {ex.Message}");
+ await TryReconnectAsync();
+ }
+ }
+
+ private void SetState(GatewayState newState)
+ {
+ var oldState = _state;
+ if (oldState == newState)
+ return;
+
+ _state = newState;
+ StateChanged?.Invoke(this, new GatewayStateChangedEventArgs
+ {
+ OldState = oldState,
+ NewState = newState
+ });
+ }
+
+ private void EnsureConnected()
+ {
+ if (_state != GatewayState.Connected || _webSocket?.State != WebSocketState.Open)
+ throw new InvalidOperationException("Not connected to Gateway");
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs b/apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs
new file mode 100644
index 000000000..d444462fe
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs
@@ -0,0 +1,77 @@
+using System.Media;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Plays Windows notification sounds for various events.
+///
+public static class NotificationSounds
+{
+ ///
+ /// Play when an exec approval request arrives.
+ ///
+ public static void PlayApprovalRequest()
+ {
+ PlaySystemSound(SystemSounds.Exclamation);
+ }
+
+ ///
+ /// Play when approval is granted.
+ ///
+ public static void PlayApprovalGranted()
+ {
+ PlaySystemSound(SystemSounds.Asterisk);
+ }
+
+ ///
+ /// Play when approval is denied.
+ ///
+ public static void PlayApprovalDenied()
+ {
+ PlaySystemSound(SystemSounds.Hand);
+ }
+
+ ///
+ /// Play when Gateway connects successfully.
+ ///
+ public static void PlayConnected()
+ {
+ PlaySystemSound(SystemSounds.Asterisk);
+ }
+
+ ///
+ /// Play when Gateway disconnects.
+ ///
+ public static void PlayDisconnected()
+ {
+ PlaySystemSound(SystemSounds.Exclamation);
+ }
+
+ ///
+ /// Play a generic notification sound.
+ ///
+ public static void PlayNotification()
+ {
+ PlaySystemSound(SystemSounds.Asterisk);
+ }
+
+ ///
+ /// Play an error sound.
+ ///
+ public static void PlayError()
+ {
+ PlaySystemSound(SystemSounds.Hand);
+ }
+
+ private static void PlaySystemSound(SystemSound sound)
+ {
+ try
+ {
+ sound.Play();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Debug($"Failed to play system sound: {ex.Message}");
+ }
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs b/apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs
new file mode 100644
index 000000000..19fc6bdae
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs
@@ -0,0 +1,85 @@
+using Microsoft.Win32;
+
+namespace Clawdbot.Windows.Core;
+
+///
+/// Helper for WebView2 runtime detection
+///
+public static class WebView2Helper
+{
+ ///
+ /// Checks if WebView2 Runtime is installed
+ ///
+ public static bool IsWebView2RuntimeInstalled()
+ {
+ // Check for WebView2 Runtime in registry (both 32-bit and 64-bit)
+ var version = GetWebView2Version();
+ return version != null;
+ }
+
+ ///
+ /// Gets the installed WebView2 Runtime version
+ ///
+ public static string? GetWebView2Version()
+ {
+ // Try 64-bit location
+ var version = GetRegistryValue(
+ @"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
+ "pv",
+ RegistryView.Registry64);
+
+ if (version != null)
+ return version;
+
+ // Try 32-bit location
+ version = GetRegistryValue(
+ @"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
+ "pv",
+ RegistryView.Registry64);
+
+ if (version != null)
+ return version;
+
+ // Try default view
+ version = GetRegistryValue(
+ @"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
+ "pv",
+ RegistryView.Default);
+
+ return version;
+ }
+
+ private static string? GetRegistryValue(string subKey, string valueName, RegistryView view)
+ {
+ try
+ {
+ using var key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
+ using var subKeyHandle = key.OpenSubKey(subKey);
+ return subKeyHandle?.GetValue(valueName)?.ToString();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Gets the download URL for WebView2 Runtime
+ ///
+ public static string GetDownloadUrl()
+ {
+ return "https://go.microsoft.com/fwlink/p/?LinkId=2124703";
+ }
+
+ ///
+ /// Opens the WebView2 download page in the default browser
+ ///
+ public static void OpenDownloadPage()
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = GetDownloadUrl(),
+ UseShellExecute = true
+ });
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj b/apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj
new file mode 100644
index 000000000..be7d5f8f0
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ enable
+ enable
+ Clawdbot.Windows.Protocol
+ Auto-generated Gateway protocol models for Clawdbot Windows companion
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs b/apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs
new file mode 100644
index 000000000..891490ed6
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs
@@ -0,0 +1,769 @@
+//
+// This file contains protocol models for the Clawdbot Gateway.
+// Based on: src/gateway/protocol/schema.ts
+// Protocol Version: 3
+//
+
+#nullable enable
+using System.Text.Json.Serialization;
+
+namespace Clawdbot.Windows.Protocol;
+
+///
+/// Gateway protocol version
+///
+public static class GatewayProtocol
+{
+ public const int Version = 3;
+ public const int MinVersion = 3;
+ public const int MaxVersion = 3;
+}
+
+///
+/// Gateway error codes
+///
+public static class ErrorCodes
+{
+ public const string NotLinked = "NOT_LINKED";
+ public const string NotPaired = "NOT_PAIRED";
+ public const string AgentTimeout = "AGENT_TIMEOUT";
+ public const string InvalidRequest = "INVALID_REQUEST";
+ public const string Unavailable = "UNAVAILABLE";
+ public const string ConfigHashMismatch = "CONFIG_HASH_MISMATCH";
+}
+
+#region Frame Types
+
+///
+/// Base class for all Gateway frames
+///
+public abstract record GatewayFrame
+{
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
+
+///
+/// Request frame sent to Gateway
+///
+public record RequestFrame : GatewayFrame
+{
+ [JsonPropertyName("type")]
+ public override string Type => "request";
+
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("method")]
+ public required string Method { get; init; }
+
+ [JsonPropertyName("params")]
+ public object? Params { get; init; }
+}
+
+///
+/// Response frame received from Gateway
+///
+public record ResponseFrame : GatewayFrame
+{
+ [JsonPropertyName("type")]
+ public override string Type => "response";
+
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("payload")]
+ public object? Payload { get; init; }
+
+ [JsonPropertyName("error")]
+ public GatewayError? Error { get; init; }
+}
+
+///
+/// Event frame pushed by Gateway
+///
+public record EventFrame : GatewayFrame
+{
+ [JsonPropertyName("type")]
+ public override string Type => "event";
+
+ [JsonPropertyName("event")]
+ public required string Event { get; init; }
+
+ [JsonPropertyName("payload")]
+ public object? Payload { get; init; }
+}
+
+///
+/// Gateway error response
+///
+public record GatewayError
+{
+ [JsonPropertyName("code")]
+ public required string Code { get; init; }
+
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+
+ [JsonPropertyName("data")]
+ public object? Data { get; init; }
+}
+
+#endregion
+
+#region Connection Types
+
+///
+/// Parameters for connecting to Gateway
+///
+public record ConnectParams
+{
+ [JsonPropertyName("minProtocol")]
+ public int MinProtocol { get; init; } = GatewayProtocol.MinVersion;
+
+ [JsonPropertyName("maxProtocol")]
+ public int MaxProtocol { get; init; } = GatewayProtocol.MaxVersion;
+
+ [JsonPropertyName("client")]
+ public Dictionary Client { get; init; } = new()
+ {
+ ["name"] = "Clawdbot Windows",
+ ["version"] = "0.1.0",
+ ["platform"] = "windows"
+ };
+
+ [JsonPropertyName("caps")]
+ public List? Caps { get; init; }
+
+ [JsonPropertyName("commands")]
+ public List? Commands { get; init; }
+
+ [JsonPropertyName("role")]
+ public string? Role { get; init; }
+
+ [JsonPropertyName("scopes")]
+ public List? Scopes { get; init; }
+}
+
+///
+/// Successful hello response from Gateway
+///
+public record HelloOk
+{
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = "hello.ok";
+
+ [JsonPropertyName("protocol")]
+ public int Protocol { get; init; }
+
+ [JsonPropertyName("server")]
+ public Dictionary? Server { get; init; }
+
+ [JsonPropertyName("features")]
+ public Dictionary? Features { get; init; }
+
+ [JsonPropertyName("snapshot")]
+ public Snapshot? Snapshot { get; init; }
+
+ [JsonPropertyName("canvasHostUrl")]
+ public string? CanvasHostUrl { get; init; }
+
+ [JsonPropertyName("policy")]
+ public Dictionary? Policy { get; init; }
+}
+
+#endregion
+
+#region Snapshot Types
+
+///
+/// Full state snapshot from Gateway
+///
+public record Snapshot
+{
+ [JsonPropertyName("presence")]
+ public List Presence { get; init; } = new();
+
+ [JsonPropertyName("health")]
+ public object? Health { get; init; }
+
+ [JsonPropertyName("stateVersion")]
+ public StateVersion? StateVersion { get; init; }
+
+ [JsonPropertyName("uptimeMs")]
+ public long UptimeMs { get; init; }
+
+ [JsonPropertyName("configPath")]
+ public string? ConfigPath { get; init; }
+
+ [JsonPropertyName("stateDir")]
+ public string? StateDir { get; init; }
+
+ [JsonPropertyName("sessionDefaults")]
+ public SessionDefaults? SessionDefaults { get; init; }
+}
+
+///
+/// Presence entry for connected device
+///
+public record PresenceEntry
+{
+ [JsonPropertyName("host")]
+ public string? Host { get; init; }
+
+ [JsonPropertyName("ip")]
+ public string? Ip { get; init; }
+
+ [JsonPropertyName("version")]
+ public string? Version { get; init; }
+
+ [JsonPropertyName("platform")]
+ public string? Platform { get; init; }
+
+ [JsonPropertyName("deviceFamily")]
+ public string? DeviceFamily { get; init; }
+
+ [JsonPropertyName("ts")]
+ public long Ts { get; init; }
+
+ [JsonPropertyName("deviceId")]
+ public string? DeviceId { get; init; }
+
+ [JsonPropertyName("roles")]
+ public List? Roles { get; init; }
+}
+
+///
+/// State version for tracking changes
+///
+public record StateVersion
+{
+ [JsonPropertyName("presence")]
+ public int Presence { get; init; }
+
+ [JsonPropertyName("health")]
+ public int Health { get; init; }
+}
+
+///
+/// Session defaults
+///
+public record SessionDefaults
+{
+ [JsonPropertyName("defaultAgentId")]
+ public required string DefaultAgentId { get; init; }
+
+ [JsonPropertyName("mainKey")]
+ public required string MainKey { get; init; }
+
+ [JsonPropertyName("mainSessionKey")]
+ public required string MainSessionKey { get; init; }
+
+ [JsonPropertyName("scope")]
+ public string? Scope { get; init; }
+}
+
+#endregion
+
+#region Config Types
+
+///
+/// Response from config.get
+///
+public record ConfigGetResponse
+{
+ [JsonPropertyName("raw")]
+ public required string Raw { get; init; }
+
+ [JsonPropertyName("hash")]
+ public required string Hash { get; init; }
+
+ [JsonPropertyName("path")]
+ public required string Path { get; init; }
+}
+
+///
+/// Parameters for config.apply
+///
+public record ConfigApplyParams
+{
+ [JsonPropertyName("raw")]
+ public required string Raw { get; init; }
+
+ [JsonPropertyName("baseHash")]
+ public string? BaseHash { get; init; }
+
+ [JsonPropertyName("sessionKey")]
+ public string? SessionKey { get; init; }
+
+ [JsonPropertyName("restartDelayMs")]
+ public int? RestartDelayMs { get; init; }
+}
+
+///
+/// Parameters for config.patch
+///
+public record ConfigPatchParams
+{
+ [JsonPropertyName("raw")]
+ public required string Raw { get; init; }
+
+ [JsonPropertyName("baseHash")]
+ public string? BaseHash { get; init; }
+
+ [JsonPropertyName("sessionKey")]
+ public string? SessionKey { get; init; }
+
+ [JsonPropertyName("restartDelayMs")]
+ public int? RestartDelayMs { get; init; }
+}
+
+///
+/// Response from config.schema
+///
+public record ConfigSchemaResponse
+{
+ [JsonPropertyName("schema")]
+ public required object Schema { get; init; }
+
+ [JsonPropertyName("uiHints")]
+ public Dictionary? UiHints { get; init; }
+
+ [JsonPropertyName("version")]
+ public required string Version { get; init; }
+
+ [JsonPropertyName("generatedAt")]
+ public required string GeneratedAt { get; init; }
+}
+
+///
+/// UI hints for config schema
+///
+public record ConfigUiHint
+{
+ [JsonPropertyName("label")]
+ public string? Label { get; init; }
+
+ [JsonPropertyName("help")]
+ public string? Help { get; init; }
+
+ [JsonPropertyName("group")]
+ public string? Group { get; init; }
+
+ [JsonPropertyName("order")]
+ public int? Order { get; init; }
+
+ [JsonPropertyName("advanced")]
+ public bool? Advanced { get; init; }
+
+ [JsonPropertyName("sensitive")]
+ public bool? Sensitive { get; init; }
+
+ [JsonPropertyName("placeholder")]
+ public string? Placeholder { get; init; }
+}
+
+#endregion
+
+#region Channel Types
+
+///
+/// Parameters for channels.status
+///
+public record ChannelsStatusParams
+{
+ [JsonPropertyName("probe")]
+ public bool? Probe { get; init; }
+
+ [JsonPropertyName("timeoutMs")]
+ public int? TimeoutMs { get; init; }
+}
+
+///
+/// Response from channels.status
+///
+public record ChannelsStatusResponse
+{
+ [JsonPropertyName("ts")]
+ public long Ts { get; init; }
+
+ [JsonPropertyName("channelOrder")]
+ public List ChannelOrder { get; init; } = new();
+
+ [JsonPropertyName("channelLabels")]
+ public Dictionary ChannelLabels { get; init; } = new();
+
+ [JsonPropertyName("channelDetailLabels")]
+ public Dictionary? ChannelDetailLabels { get; init; }
+
+ [JsonPropertyName("channelAccounts")]
+ public Dictionary> ChannelAccounts { get; init; } = new();
+
+ [JsonPropertyName("channelDefaultAccountId")]
+ public Dictionary? ChannelDefaultAccountId { get; init; }
+}
+
+///
+/// Snapshot for a channel account
+///
+public record ChannelAccountSnapshot
+{
+ [JsonPropertyName("accountId")]
+ public required string AccountId { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("enabled")]
+ public bool? Enabled { get; init; }
+
+ [JsonPropertyName("configured")]
+ public bool? Configured { get; init; }
+
+ [JsonPropertyName("linked")]
+ public bool? Linked { get; init; }
+
+ [JsonPropertyName("running")]
+ public bool? Running { get; init; }
+
+ [JsonPropertyName("connected")]
+ public bool? Connected { get; init; }
+
+ [JsonPropertyName("lastConnectedAt")]
+ public long? LastConnectedAt { get; init; }
+
+ [JsonPropertyName("lastError")]
+ public string? LastError { get; init; }
+
+ [JsonPropertyName("lastInboundAt")]
+ public long? LastInboundAt { get; init; }
+
+ [JsonPropertyName("lastOutboundAt")]
+ public long? LastOutboundAt { get; init; }
+}
+
+///
+/// Parameters for channels.logout
+///
+public record ChannelsLogoutParams
+{
+ [JsonPropertyName("channel")]
+ public required string Channel { get; init; }
+
+ [JsonPropertyName("accountId")]
+ public string? AccountId { get; init; }
+}
+
+#endregion
+
+#region Session Types
+
+///
+/// Parameters for sessions.list
+///
+public record SessionsListParams
+{
+ [JsonPropertyName("limit")]
+ public int? Limit { get; init; }
+
+ [JsonPropertyName("activeMinutes")]
+ public int? ActiveMinutes { get; init; }
+
+ [JsonPropertyName("includeGlobal")]
+ public bool? IncludeGlobal { get; init; }
+
+ [JsonPropertyName("includeDerivedTitles")]
+ public bool? IncludeDerivedTitles { get; init; }
+
+ [JsonPropertyName("includeLastMessage")]
+ public bool? IncludeLastMessage { get; init; }
+
+ [JsonPropertyName("agentId")]
+ public string? AgentId { get; init; }
+
+ [JsonPropertyName("search")]
+ public string? Search { get; init; }
+}
+
+///
+/// Session info
+///
+public record SessionInfo
+{
+ [JsonPropertyName("key")]
+ public required string Key { get; init; }
+
+ [JsonPropertyName("agentId")]
+ public string? AgentId { get; init; }
+
+ [JsonPropertyName("label")]
+ public string? Label { get; init; }
+
+ [JsonPropertyName("derivedTitle")]
+ public string? DerivedTitle { get; init; }
+
+ [JsonPropertyName("lastMessage")]
+ public string? LastMessage { get; init; }
+
+ [JsonPropertyName("lastActivityAt")]
+ public long? LastActivityAt { get; init; }
+
+ [JsonPropertyName("createdAt")]
+ public long? CreatedAt { get; init; }
+
+ [JsonPropertyName("model")]
+ public string? Model { get; init; }
+
+ [JsonPropertyName("thinkingLevel")]
+ public string? ThinkingLevel { get; init; }
+}
+
+///
+/// Parameters for sessions.patch
+///
+public record SessionsPatchParams
+{
+ [JsonPropertyName("key")]
+ public required string Key { get; init; }
+
+ [JsonPropertyName("label")]
+ public string? Label { get; init; }
+
+ [JsonPropertyName("thinkingLevel")]
+ public string? ThinkingLevel { get; init; }
+
+ [JsonPropertyName("model")]
+ public string? Model { get; init; }
+
+ [JsonPropertyName("sendPolicy")]
+ public string? SendPolicy { get; init; }
+}
+
+///
+/// Parameters for sessions.delete
+///
+public record SessionsDeleteParams
+{
+ [JsonPropertyName("key")]
+ public required string Key { get; init; }
+
+ [JsonPropertyName("deleteTranscript")]
+ public bool? DeleteTranscript { get; init; }
+}
+
+#endregion
+
+#region Exec Approval Types
+
+///
+/// Response from execApprovals.get
+///
+public record ExecApprovalsGetResponse
+{
+ [JsonPropertyName("path")]
+ public required string Path { get; init; }
+
+ [JsonPropertyName("exists")]
+ public bool Exists { get; init; }
+
+ [JsonPropertyName("hash")]
+ public required string Hash { get; init; }
+
+ [JsonPropertyName("file")]
+ public required ExecApprovalsFile File { get; init; }
+}
+
+///
+/// Exec approvals file structure
+///
+public record ExecApprovalsFile
+{
+ [JsonPropertyName("version")]
+ public int Version { get; init; } = 1;
+
+ [JsonPropertyName("socket")]
+ public ExecApprovalsSocket? Socket { get; init; }
+
+ [JsonPropertyName("defaults")]
+ public ExecApprovalsDefaults? Defaults { get; init; }
+
+ [JsonPropertyName("agents")]
+ public Dictionary? Agents { get; init; }
+}
+
+///
+/// Socket config for exec approvals
+///
+public record ExecApprovalsSocket
+{
+ [JsonPropertyName("path")]
+ public string? Path { get; init; }
+
+ [JsonPropertyName("token")]
+ public string? Token { get; init; }
+}
+
+///
+/// Default exec approval settings
+///
+public record ExecApprovalsDefaults
+{
+ [JsonPropertyName("security")]
+ public string? Security { get; init; }
+
+ [JsonPropertyName("ask")]
+ public string? Ask { get; init; }
+
+ [JsonPropertyName("askFallback")]
+ public string? AskFallback { get; init; }
+
+ [JsonPropertyName("autoAllowSkills")]
+ public bool? AutoAllowSkills { get; init; }
+}
+
+///
+/// Per-agent exec approval settings
+///
+public record ExecApprovalsAgent
+{
+ [JsonPropertyName("security")]
+ public string? Security { get; init; }
+
+ [JsonPropertyName("ask")]
+ public string? Ask { get; init; }
+
+ [JsonPropertyName("askFallback")]
+ public string? AskFallback { get; init; }
+
+ [JsonPropertyName("autoAllowSkills")]
+ public bool? AutoAllowSkills { get; init; }
+
+ [JsonPropertyName("allowlist")]
+ public List? Allowlist { get; init; }
+}
+
+///
+/// Allowlist entry for exec approvals
+///
+public record ExecApprovalsAllowlistEntry
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; init; }
+
+ [JsonPropertyName("pattern")]
+ public required string Pattern { get; init; }
+
+ [JsonPropertyName("lastUsedAt")]
+ public long? LastUsedAt { get; init; }
+
+ [JsonPropertyName("lastUsedCommand")]
+ public string? LastUsedCommand { get; init; }
+
+ [JsonPropertyName("lastResolvedPath")]
+ public string? LastResolvedPath { get; init; }
+}
+
+///
+/// Exec approval request event
+///
+public record ExecApprovalRequest
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("command")]
+ public required string Command { get; init; }
+
+ [JsonPropertyName("cwd")]
+ public string? Cwd { get; init; }
+
+ [JsonPropertyName("host")]
+ public string? Host { get; init; }
+
+ [JsonPropertyName("security")]
+ public string? Security { get; init; }
+
+ [JsonPropertyName("ask")]
+ public string? Ask { get; init; }
+
+ [JsonPropertyName("agentId")]
+ public string? AgentId { get; init; }
+
+ [JsonPropertyName("resolvedPath")]
+ public string? ResolvedPath { get; init; }
+
+ [JsonPropertyName("sessionKey")]
+ public string? SessionKey { get; init; }
+
+ [JsonPropertyName("timeoutMs")]
+ public int? TimeoutMs { get; init; }
+}
+
+///
+/// Parameters for execApproval.resolve
+///
+public record ExecApprovalResolveParams
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("decision")]
+ public required string Decision { get; init; }
+}
+
+#endregion
+
+#region Cron Types
+
+///
+/// Cron job
+///
+public record CronJob
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("schedule")]
+ public required string Schedule { get; init; }
+
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+
+ [JsonPropertyName("sessionKey")]
+ public string? SessionKey { get; init; }
+
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; init; } = true;
+
+ [JsonPropertyName("lastRunAt")]
+ public long? LastRunAt { get; init; }
+
+ [JsonPropertyName("nextRunAt")]
+ public long? NextRunAt { get; init; }
+}
+
+///
+/// Parameters for cron.add
+///
+public record CronAddParams
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("schedule")]
+ public required string Schedule { get; init; }
+
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+
+ [JsonPropertyName("sessionKey")]
+ public string? SessionKey { get; init; }
+
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; init; } = true;
+}
+
+#endregion
diff --git a/apps/windows/src/Clawdbot.Windows/App.xaml b/apps/windows/src/Clawdbot.Windows/App.xaml
new file mode 100644
index 000000000..89846dd69
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/App.xaml
@@ -0,0 +1,25 @@
+
+
+
+ #E53935
+ #B71C1C
+ #1E1E1E
+ #2D2D2D
+ #FFFFFF
+ #B0B0B0
+ #4CAF50
+ #FFC107
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows/App.xaml.cs b/apps/windows/src/Clawdbot.Windows/App.xaml.cs
new file mode 100644
index 000000000..ae659fd1b
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/App.xaml.cs
@@ -0,0 +1,220 @@
+using System.Windows;
+using Clawdbot.Windows.Core;
+
+namespace Clawdbot.Windows;
+
+///
+/// Main application entry point
+///
+public partial class App : Application
+{
+ private AppSettings? _settings;
+ private GatewayChannel? _gateway;
+ private SystemTrayIcon? _trayIcon;
+ private ExecApprovalService? _execApprovalService;
+
+ ///
+ /// Gets the current application settings.
+ ///
+ public AppSettings Settings => _settings ?? new AppSettings();
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ AppLogger.Info("Application startup initiated");
+
+ // Load settings
+ _settings = AppSettings.Load();
+ AppLogger.Info($"Settings loaded. Gateway URL: {_settings.GatewayUrl}");
+
+ // Set up global exception handlers
+ DispatcherUnhandledException += (_, args) =>
+ {
+ AppLogger.Error("Unhandled UI exception", args.Exception);
+ args.Handled = true;
+ };
+ AppDomain.CurrentDomain.UnhandledException += (_, args) =>
+ {
+ AppLogger.Error("Unhandled domain exception", args.ExceptionObject as Exception);
+ };
+ TaskScheduler.UnobservedTaskException += (_, args) =>
+ {
+ AppLogger.Error("Unobserved task exception", args.Exception);
+ args.SetObserved();
+ };
+
+ try
+ {
+ // Initialize Gateway connection with URL from settings
+ AppLogger.Info($"Creating GatewayChannel for {_settings.GatewayUrl}");
+ _gateway = new GatewayChannel(_settings.GatewayUrl);
+
+ // Initialize system tray
+ AppLogger.Info("Creating SystemTrayIcon");
+ _trayIcon = new SystemTrayIcon(_gateway, _settings);
+ _trayIcon.ExitRequested += (_, _) =>
+ {
+ AppLogger.Info("Exit requested from tray icon");
+ Shutdown();
+ };
+ _trayIcon.ShowWindowRequested += (_, _) =>
+ {
+ AppLogger.Info("Show window requested from tray icon");
+ ShowMainWindow();
+ };
+ _trayIcon.SettingsRequested += (_, _) =>
+ {
+ AppLogger.Info("Settings requested from tray icon");
+ ShowSettingsWindow();
+ };
+ _trayIcon.ShowWindowRequested += (_, _) =>
+ {
+ AppLogger.Info("Show window requested from tray icon");
+ ShowMainWindow();
+ };
+
+ AppLogger.Info("System tray icon created successfully");
+
+ // Try to connect to Gateway
+ AppLogger.Info("Attempting to connect to Gateway");
+ try
+ {
+ await _gateway.ConnectAsync();
+ AppLogger.Info("Gateway connected successfully");
+ _trayIcon.UpdateStatus(GatewayState.Connected);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn($"Gateway connection failed: {ex.Message}");
+ _trayIcon.UpdateStatus(GatewayState.Disconnected, ex.Message);
+ }
+
+ // Subscribe to Gateway events
+ _gateway.StateChanged += (_, args) =>
+ {
+ AppLogger.Debug($"Gateway state changed: {args.OldState} -> {args.NewState}");
+ Dispatcher.Invoke(() =>
+ {
+ _trayIcon?.UpdateStatus(args.NewState);
+
+ // Play sound notification
+ if (_settings?.PlaySounds == true)
+ {
+ if (args.NewState == GatewayState.Connected)
+ NotificationSounds.PlayConnected();
+ else if (args.NewState == GatewayState.Disconnected && args.OldState == GatewayState.Connected)
+ NotificationSounds.PlayDisconnected();
+ }
+ });
+ };
+
+ _gateway.EventReceived += OnGatewayEvent;
+
+ // Initialize exec approval service
+ AppLogger.Info("Setting up ExecApprovalService");
+ _execApprovalService = new ExecApprovalService(_gateway);
+ _execApprovalService.ApprovalRequested += OnExecApprovalRequested;
+
+ AppLogger.Info("Application startup completed");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("Fatal error during startup", ex);
+ MessageBox.Show($"Failed to start Clawdbot: {ex.Message}", "Clawdbot Error",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ Shutdown(-1);
+ }
+ }
+
+ protected override async void OnExit(ExitEventArgs e)
+ {
+ AppLogger.Info($"Application exiting with code {e.ApplicationExitCode}");
+
+ _execApprovalService?.Dispose();
+ _trayIcon?.Dispose();
+
+ if (_gateway != null)
+ await _gateway.DisposeAsync();
+
+ AppLogger.Info("Application exit complete");
+ base.OnExit(e);
+ }
+
+ private void OnGatewayEvent(object? sender, GatewayEventArgs e)
+ {
+ // Gateway events are now handled by ExecApprovalService
+ AppLogger.Debug($"Gateway event received: {e.EventName}");
+ }
+
+ private ExecApprovalDecision OnExecApprovalRequested(GatewayExecApprovalRequest request)
+ {
+ AppLogger.Info($"Showing exec approval dialog for request: {request.Id}");
+
+ // Play approval request sound
+ if (_settings?.PlaySounds == true)
+ NotificationSounds.PlayApprovalRequest();
+
+ var decision = ExecApprovalDialog.ShowApproval(request);
+
+ // Play result sound
+ if (_settings?.PlaySounds == true)
+ {
+ if (decision == ExecApprovalDecision.Deny)
+ NotificationSounds.PlayApprovalDenied();
+ else
+ NotificationSounds.PlayApprovalGranted();
+ }
+
+ return decision;
+ }
+
+ private void ShowMainWindow()
+ {
+ if (MainWindow == null)
+ {
+ MainWindow = new MainWindow(_gateway!);
+ }
+
+ MainWindow.Show();
+ MainWindow.Activate();
+ }
+
+ private void ShowSettingsWindow()
+ {
+ var settingsWindow = new SettingsWindow(_settings!);
+ settingsWindow.Owner = MainWindow;
+ var result = settingsWindow.ShowDialog();
+
+ // If Gateway URL changed and user wants to reconnect
+ if (result == true && _gateway != null)
+ {
+ _ = ReconnectGatewayAsync();
+ }
+ }
+
+ private async Task ReconnectGatewayAsync()
+ {
+ try
+ {
+ AppLogger.Info("Reconnecting to Gateway with new URL...");
+ await _gateway!.DisconnectAsync();
+
+ // Create new gateway with updated URL
+ var oldGateway = _gateway;
+ _gateway = new GatewayChannel(_settings!.GatewayUrl);
+
+ // Dispose old gateway
+ await oldGateway.DisposeAsync();
+
+ // Reconnect
+ await _gateway.ConnectAsync();
+ AppLogger.Info("Reconnected to Gateway successfully");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error($"Failed to reconnect: {ex.Message}");
+ _trayIcon?.UpdateStatus(GatewayState.Disconnected, ex.Message);
+ }
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico b/apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico
new file mode 100644
index 000000000..ec5665f56
Binary files /dev/null and b/apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico differ
diff --git a/apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj b/apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj
new file mode 100644
index 000000000..eacaa162d
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj
@@ -0,0 +1,31 @@
+
+
+
+ WinExe
+ net9.0-windows
+ enable
+ enable
+ true
+ Assets\clawdbot.ico
+ Clawdbot.Windows
+ Clawdbot
+ Clawdbot Windows Companion - System tray app for Clawdbot Gateway
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml b/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml
new file mode 100644
index 000000000..63235fe7d
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml.cs b/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml.cs
new file mode 100644
index 000000000..93d9c8d7d
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml.cs
@@ -0,0 +1,252 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using Clawdbot.Windows.Core;
+
+namespace Clawdbot.Windows;
+
+///
+/// Exec approval dialog window - allows users to approve or deny commands.
+/// Mirrors the macOS NSAlert-based approval UI.
+///
+public partial class ExecApprovalDialog : Window
+{
+ private readonly GatewayExecApprovalRequest _request;
+ private readonly DispatcherTimer _countdownTimer;
+
+ ///
+ /// Gets the user's decision after the dialog closes.
+ /// Null if the dialog was closed without a decision (timeout or window close).
+ ///
+ public ExecApprovalDecision? Decision { get; private set; }
+
+ ///
+ /// Creates a new exec approval dialog for the given request.
+ ///
+ public ExecApprovalDialog(GatewayExecApprovalRequest request)
+ {
+ _request = request ?? throw new ArgumentNullException(nameof(request));
+
+ InitializeComponent();
+
+ // Populate command
+ CommandTextBox.Text = request.Request.Command;
+
+ // Build context details
+ PopulateContext();
+
+ // Set up countdown timer
+ _countdownTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(1)
+ };
+ _countdownTimer.Tick += CountdownTimer_Tick;
+
+ UpdateTimerDisplay();
+ _countdownTimer.Start();
+
+ // Handle keyboard shortcuts
+ PreviewKeyDown += (s, e) =>
+ {
+ if (e.Key == System.Windows.Input.Key.Escape)
+ {
+ Decision = ExecApprovalDecision.Deny;
+ DialogResult = false;
+ Close();
+ e.Handled = true;
+ }
+ };
+
+ // Set focus to Allow Once button
+ Loaded += (s, e) => AllowOnceButton.Focus();
+
+ AppLogger.Info($"Showing exec approval dialog for command: {TruncateCommand(request.Request.Command)}");
+ }
+
+ private void PopulateContext()
+ {
+ var req = _request.Request;
+
+ // Working directory
+ if (!string.IsNullOrEmpty(req.Cwd))
+ {
+ AddContextRow("Working Directory", req.Cwd);
+ }
+
+ // Resolved path (executable)
+ if (!string.IsNullOrEmpty(req.ResolvedPath))
+ {
+ AddContextRow("Executable", req.ResolvedPath);
+ }
+
+ // Host
+ if (!string.IsNullOrEmpty(req.Host))
+ {
+ AddContextRow("Host", req.Host);
+ }
+
+ // Agent ID
+ if (!string.IsNullOrEmpty(req.AgentId))
+ {
+ AddContextRow("Agent", req.AgentId);
+ }
+
+ // Security level
+ AddContextRow("Security", FormatSecurity(req.Security));
+
+ // Ask mode
+ AddContextRow("Ask Mode", FormatAsk(req.Ask));
+ }
+
+ private void AddContextRow(string label, string value)
+ {
+ var row = new Grid();
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ var labelBlock = new TextBlock
+ {
+ Text = label,
+ FontSize = 12,
+ Foreground = FindResource("TextSecondaryBrush") as System.Windows.Media.Brush
+ };
+ Grid.SetColumn(labelBlock, 0);
+
+ var valueBlock = new TextBlock
+ {
+ Text = value,
+ FontSize = 12,
+ Foreground = FindResource("TextPrimaryBrush") as System.Windows.Media.Brush,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ ToolTip = value
+ };
+ Grid.SetColumn(valueBlock, 1);
+
+ row.Children.Add(labelBlock);
+ row.Children.Add(valueBlock);
+
+ // The StackPanel in XAML doesn't support Spacing in older WPF
+ // Add margin to each row for spacing
+ row.Margin = new Thickness(0, 0, 0, 4);
+
+ ContextPanel.Children.Add(row);
+ }
+
+ private static string FormatSecurity(string? security)
+ {
+ return security?.ToLowerInvariant() switch
+ {
+ "sandbox" => "🛡️ Sandboxed",
+ "auto" => "⚙️ Auto",
+ "ask" => "❓ Ask",
+ "trust" => "✅ Trusted",
+ _ => security ?? "Unknown"
+ };
+ }
+
+ private static string FormatAsk(string? ask)
+ {
+ return ask?.ToLowerInvariant() switch
+ {
+ "always" => "Always ask",
+ "once" => "Ask once",
+ "never" => "Never ask",
+ "auto" => "Automatic",
+ _ => ask ?? "Unknown"
+ };
+ }
+
+ private void CountdownTimer_Tick(object? sender, EventArgs e)
+ {
+ if (_request.IsExpired)
+ {
+ _countdownTimer.Stop();
+ AppLogger.Info("Exec approval request expired, auto-denying");
+ Decision = ExecApprovalDecision.Deny;
+ DialogResult = false;
+ Close();
+ return;
+ }
+
+ UpdateTimerDisplay();
+ }
+
+ private void UpdateTimerDisplay()
+ {
+ var remaining = _request.TimeRemaining;
+ if (remaining.TotalSeconds <= 0)
+ {
+ TimerText.Text = "Expired";
+ TimerText.Foreground = FindResource("TextSecondaryBrush") as System.Windows.Media.Brush;
+ }
+ else if (remaining.TotalSeconds <= 10)
+ {
+ TimerText.Text = $"Expires in {remaining.TotalSeconds:F0}s";
+ TimerText.Foreground = System.Windows.Media.Brushes.OrangeRed;
+ }
+ else
+ {
+ var minutes = (int)remaining.TotalMinutes;
+ var seconds = (int)(remaining.TotalSeconds % 60);
+ TimerText.Text = $"Expires in {minutes}:{seconds:D2}";
+ }
+ }
+
+ private void AllowOnceButton_Click(object sender, RoutedEventArgs e)
+ {
+ AppLogger.Info($"User clicked Allow Once for command: {TruncateCommand(_request.Request.Command)}");
+ Decision = ExecApprovalDecision.AllowOnce;
+ DialogResult = true;
+ _countdownTimer.Stop();
+ Close();
+ }
+
+ private void AllowAlwaysButton_Click(object sender, RoutedEventArgs e)
+ {
+ AppLogger.Info($"User clicked Always Allow for command: {TruncateCommand(_request.Request.Command)}");
+ Decision = ExecApprovalDecision.AllowAlways;
+ DialogResult = true;
+ _countdownTimer.Stop();
+ Close();
+ }
+
+ private void DenyButton_Click(object sender, RoutedEventArgs e)
+ {
+ AppLogger.Info($"User clicked Don't Allow for command: {TruncateCommand(_request.Request.Command)}");
+ Decision = ExecApprovalDecision.Deny;
+ DialogResult = false;
+ _countdownTimer.Stop();
+ Close();
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ _countdownTimer.Stop();
+ base.OnClosed(e);
+
+ // If user closed without making a decision, treat as deny
+ if (Decision == null)
+ {
+ AppLogger.Info("Dialog closed without decision, treating as deny");
+ Decision = ExecApprovalDecision.Deny;
+ }
+ }
+
+ private static string TruncateCommand(string command, int maxLength = 50)
+ {
+ if (string.IsNullOrEmpty(command)) return "(empty)";
+ if (command.Length <= maxLength) return command;
+ return command[..(maxLength - 3)] + "...";
+ }
+
+ ///
+ /// Shows the dialog and returns the user's decision.
+ /// Must be called from the UI thread.
+ ///
+ public static ExecApprovalDecision ShowApproval(GatewayExecApprovalRequest request)
+ {
+ var dialog = new ExecApprovalDialog(request);
+ dialog.ShowDialog();
+ return dialog.Decision ?? ExecApprovalDecision.Deny;
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows/MainWindow.xaml b/apps/windows/src/Clawdbot.Windows/MainWindow.xaml
new file mode 100644
index 000000000..526e8ad3e
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/MainWindow.xaml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows/MainWindow.xaml.cs b/apps/windows/src/Clawdbot.Windows/MainWindow.xaml.cs
new file mode 100644
index 000000000..6a5ab4959
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/MainWindow.xaml.cs
@@ -0,0 +1,205 @@
+using System.Windows;
+using System.Windows.Media;
+using Clawdbot.Windows.Core;
+using Microsoft.Web.WebView2.Core;
+
+namespace Clawdbot.Windows;
+
+///
+/// Main window with embedded Control UI
+///
+public partial class MainWindow : Window
+{
+ private readonly GatewayChannel _gateway;
+
+ public MainWindow(GatewayChannel gateway)
+ {
+ AppLogger.Info("MainWindow constructor started");
+ InitializeComponent();
+ _gateway = gateway;
+
+ // Check WebView2 availability early
+ var webView2Version = WebView2Helper.GetWebView2Version();
+ AppLogger.Info($"WebView2 Runtime version: {webView2Version ?? "NOT INSTALLED"}");
+
+ // Subscribe to state changes
+ _gateway.StateChanged += OnGatewayStateChanged;
+
+ // Initialize WebView2
+ InitializeWebViewAsync();
+
+ // Set initial status
+ UpdateStatus(_gateway.State);
+
+ // Handle window closing - hide instead of close
+ Closing += (_, e) =>
+ {
+ AppLogger.Debug("MainWindow closing - hiding instead");
+ e.Cancel = true;
+ Hide();
+ };
+
+ AppLogger.Info("MainWindow constructor completed");
+ }
+
+ private async void InitializeWebViewAsync()
+ {
+ AppLogger.Info("Initializing WebView2...");
+ try
+ {
+ // Initialize WebView2
+ await WebView.EnsureCoreWebView2Async();
+ AppLogger.Info("WebView2 CoreWebView2 initialized successfully");
+
+ // Configure WebView2 settings
+ WebView.CoreWebView2.Settings.AreDevToolsEnabled = true;
+ WebView.CoreWebView2.Settings.IsZoomControlEnabled = false;
+ WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
+
+ // Handle navigation errors
+ WebView.CoreWebView2.NavigationCompleted += (_, args) =>
+ {
+ AppLogger.Debug($"WebView2 navigation completed: Success={args.IsSuccess}, Status={args.WebErrorStatus}");
+ if (!args.IsSuccess)
+ {
+ ShowOverlay($"Failed to load Control UI: {args.WebErrorStatus}");
+ RetryButton.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ HideOverlay();
+ }
+ };
+
+ // Handle new window requests (open in default browser)
+ WebView.CoreWebView2.NewWindowRequested += (_, args) =>
+ {
+ AppLogger.Debug($"New window requested: {args.Uri}");
+ args.Handled = true;
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = args.Uri,
+ UseShellExecute = true
+ });
+ };
+
+ // Navigate if connected
+ if (_gateway.State == GatewayState.Connected)
+ {
+ AppLogger.Info("Gateway connected, navigating to Control UI");
+ WebView.Source = new Uri("http://127.0.0.1:18789/");
+ }
+ else
+ {
+ AppLogger.Info("Gateway not connected, showing waiting message");
+ ShowOverlay("Waiting for Gateway connection...");
+ }
+ }
+ catch (WebView2RuntimeNotFoundException ex)
+ {
+ // Specific error for missing WebView2 Runtime
+ AppLogger.Error("WebView2 Runtime not found", ex);
+ ShowWebView2NotInstalledError();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("WebView2 initialization failed", ex);
+
+ // Check if it's a WebView2-related error
+ if (!WebView2Helper.IsWebView2RuntimeInstalled())
+ {
+ ShowWebView2NotInstalledError();
+ }
+ else
+ {
+ ShowOverlay($"WebView2 initialization failed: {ex.Message}");
+ RetryButton.Visibility = Visibility.Visible;
+ }
+ }
+ }
+
+ private void ShowWebView2NotInstalledError()
+ {
+ var message = "Microsoft Edge WebView2 Runtime is not installed.\n\n" +
+ "Click 'Download' to install it from Microsoft.";
+ ShowOverlay(message);
+
+ // Change button to download WebView2
+ RetryButton.Content = "Download WebView2";
+ RetryButton.Click -= RetryButton_Click;
+ RetryButton.Click += (_, _) => WebView2Helper.OpenDownloadPage();
+ RetryButton.Visibility = Visibility.Visible;
+ }
+
+ private void OnGatewayStateChanged(object? sender, GatewayStateChangedEventArgs e)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ UpdateStatus(e.NewState);
+
+ if (e.NewState == GatewayState.Connected)
+ {
+ // Reload Control UI
+ WebView.Source = new Uri("http://127.0.0.1:18789/");
+ }
+ else if (e.NewState == GatewayState.Disconnected)
+ {
+ ShowOverlay("Gateway disconnected. Waiting for reconnection...");
+ }
+ else if (e.NewState == GatewayState.Reconnecting)
+ {
+ ShowOverlay("Reconnecting to Gateway...");
+ }
+ });
+ }
+
+ private void UpdateStatus(GatewayState state, string? message = null)
+ {
+ var (color, text) = state switch
+ {
+ GatewayState.Connected => (Brushes.LimeGreen, "Connected"),
+ GatewayState.Connecting => (Brushes.Yellow, "Connecting..."),
+ GatewayState.Reconnecting => (Brushes.Orange, "Reconnecting..."),
+ GatewayState.Disconnected => (Brushes.Red, message ?? "Disconnected"),
+ _ => (Brushes.Gray, "Unknown")
+ };
+
+ StatusIndicator.Fill = color;
+ StatusText.Text = text;
+
+ // Update gateway info
+ if (_gateway.HelloResponse?.Server != null)
+ {
+ var version = _gateway.HelloResponse.Server.TryGetValue("version", out var v) ? v.ToString() : "?";
+ GatewayInfo.Text = $"Gateway: ws://127.0.0.1:18789 (v{version})";
+ }
+ }
+
+ private void ShowOverlay(string message)
+ {
+ OverlayMessage.Text = message;
+ OverlayPanel.Visibility = Visibility.Visible;
+ }
+
+ private void HideOverlay()
+ {
+ OverlayPanel.Visibility = Visibility.Collapsed;
+ RetryButton.Visibility = Visibility.Collapsed;
+ }
+
+ private async void RetryButton_Click(object sender, RoutedEventArgs e)
+ {
+ RetryButton.Visibility = Visibility.Collapsed;
+ ShowOverlay("Connecting to Gateway...");
+
+ try
+ {
+ await _gateway.ConnectAsync();
+ }
+ catch (Exception ex)
+ {
+ ShowOverlay($"Connection failed: {ex.Message}");
+ RetryButton.Visibility = Visibility.Visible;
+ }
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml b/apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml
new file mode 100644
index 000000000..0fd0aa145
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml.cs b/apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml.cs
new file mode 100644
index 000000000..857f3bc14
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml.cs
@@ -0,0 +1,141 @@
+using System.Reflection;
+using System.Windows;
+using Clawdbot.Windows.Core;
+
+namespace Clawdbot.Windows;
+
+///
+/// Settings window for configuring Clawdbot preferences.
+///
+public partial class SettingsWindow : Window
+{
+ private readonly AppSettings _settings;
+ private readonly AppSettings _originalSettings;
+
+ public SettingsWindow(AppSettings settings)
+ {
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+
+ // Keep a copy of original settings to detect changes
+ _originalSettings = new AppSettings
+ {
+ GatewayUrl = settings.GatewayUrl,
+ StartOnLogin = settings.StartOnLogin,
+ MinimizeToTray = settings.MinimizeToTray,
+ PlaySounds = settings.PlaySounds,
+ ShowConnectionNotifications = settings.ShowConnectionNotifications
+ };
+
+ InitializeComponent();
+ LoadSettings();
+
+ AppLogger.Info("Settings window opened");
+ }
+
+ private void LoadSettings()
+ {
+ // Load current settings into UI
+ GatewayUrlTextBox.Text = _settings.GatewayUrl;
+ StartOnLoginCheckBox.IsChecked = _settings.StartOnLogin;
+ MinimizeToTrayCheckBox.IsChecked = _settings.MinimizeToTray;
+ PlaySoundsCheckBox.IsChecked = _settings.PlaySounds;
+ ShowConnectionNotificationsCheckBox.IsChecked = _settings.ShowConnectionNotifications;
+
+ // Update info section
+ var version = Assembly.GetExecutingAssembly().GetName().Version;
+ VersionText.Text = $"Version: {version?.Major}.{version?.Minor}.{version?.Build}";
+ SettingsPathText.Text = $"Settings file: {AppSettings.GetSettingsFilePath()}";
+ LogPathText.Text = $"Log file: {AppLogger.GetLogPath()}";
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Validate Gateway URL
+ var gatewayUrl = GatewayUrlTextBox.Text.Trim();
+ if (string.IsNullOrEmpty(gatewayUrl))
+ {
+ MessageBox.Show("Gateway URL cannot be empty.", "Validation Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ GatewayUrlTextBox.Focus();
+ return;
+ }
+
+ if (!gatewayUrl.StartsWith("ws://") && !gatewayUrl.StartsWith("wss://"))
+ {
+ MessageBox.Show("Gateway URL must start with ws:// or wss://", "Validation Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ GatewayUrlTextBox.Focus();
+ return;
+ }
+
+ // Apply settings
+ _settings.GatewayUrl = gatewayUrl;
+ _settings.StartOnLogin = StartOnLoginCheckBox.IsChecked ?? false;
+ _settings.MinimizeToTray = MinimizeToTrayCheckBox.IsChecked ?? true;
+ _settings.PlaySounds = PlaySoundsCheckBox.IsChecked ?? true;
+ _settings.ShowConnectionNotifications = ShowConnectionNotificationsCheckBox.IsChecked ?? true;
+
+ // Handle auto-start Registry changes
+ if (_settings.StartOnLogin != _originalSettings.StartOnLogin)
+ {
+ if (_settings.StartOnLogin)
+ {
+ if (!AutoStartHelper.Enable())
+ {
+ MessageBox.Show("Failed to enable auto-start. You may need to run as administrator.",
+ "Warning", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+ else
+ {
+ AutoStartHelper.Disable();
+ }
+ }
+
+ // Save to disk
+ _settings.Save();
+
+ AppLogger.Info("Settings saved");
+
+ // Check if Gateway URL changed (requires restart)
+ if (_settings.GatewayUrl != _originalSettings.GatewayUrl)
+ {
+ var result = MessageBox.Show(
+ "Gateway URL has changed. Reconnect to the new Gateway now?",
+ "Gateway URL Changed",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ DialogResult = true;
+ }
+ }
+
+ Close();
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ AppLogger.Info("Settings cancelled");
+ DialogResult = false;
+ Close();
+ }
+
+ private void ResetButton_Click(object sender, RoutedEventArgs e)
+ {
+ var result = MessageBox.Show(
+ "Reset all settings to their default values?",
+ "Reset Settings",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ // Reset and reload
+ _settings.Reset();
+ LoadSettings();
+ AppLogger.Info("Settings reset to defaults");
+ }
+ }
+}
diff --git a/apps/windows/src/Clawdbot.Windows/SystemTrayIcon.cs b/apps/windows/src/Clawdbot.Windows/SystemTrayIcon.cs
new file mode 100644
index 000000000..aed61b616
--- /dev/null
+++ b/apps/windows/src/Clawdbot.Windows/SystemTrayIcon.cs
@@ -0,0 +1,290 @@
+using System.Drawing;
+using System.IO;
+using System.Reflection;
+using System.Windows;
+using System.Windows.Controls;
+using Clawdbot.Windows.Core;
+using Hardcodet.Wpf.TaskbarNotification;
+
+namespace Clawdbot.Windows;
+
+///
+/// System tray icon with context menu for Clawdbot
+///
+public class SystemTrayIcon : IDisposable
+{
+ private readonly TaskbarIcon _trayIcon;
+ private readonly GatewayChannel _gateway;
+ private readonly AppSettings _settings;
+ private MenuItem? _statusMenuItem;
+ private MenuItem? _connectMenuItem;
+ private readonly Icon? _baseIcon;
+
+ public event EventHandler? ExitRequested;
+ public event EventHandler? ShowWindowRequested;
+ public event EventHandler? SettingsRequested;
+
+ public SystemTrayIcon(GatewayChannel gateway, AppSettings settings)
+ {
+ _gateway = gateway;
+ _settings = settings;
+
+ // Try to load icon from file
+ _baseIcon = LoadIconFromFile();
+ AppLogger.Debug($"Base icon loaded: {_baseIcon != null}");
+
+ // Create tray icon
+ _trayIcon = new TaskbarIcon
+ {
+ ToolTipText = "Clawdbot - Disconnected",
+ Icon = _baseIcon ?? CreateFallbackIcon(GatewayState.Disconnected),
+ ContextMenu = CreateContextMenu()
+ };
+
+ // Double-click opens main window
+ _trayIcon.TrayMouseDoubleClick += (_, _) => ShowWindowRequested?.Invoke(this, EventArgs.Empty);
+
+ AppLogger.Debug("TaskbarIcon created");
+ }
+
+ public void UpdateStatus(GatewayState state, string? message = null)
+ {
+ var statusText = state switch
+ {
+ GatewayState.Connected => "Connected",
+ GatewayState.Connecting => "Connecting...",
+ GatewayState.Reconnecting => "Reconnecting...",
+ GatewayState.Disconnected => message ?? "Disconnected",
+ _ => "Unknown"
+ };
+
+ _trayIcon.ToolTipText = $"Clawdbot - {statusText}";
+
+ // Use base icon if available, otherwise create fallback
+ _trayIcon.Icon = _baseIcon ?? CreateFallbackIcon(state);
+
+ if (_statusMenuItem != null)
+ {
+ _statusMenuItem.Header = $"Status: {statusText}";
+ }
+
+ if (_connectMenuItem != null)
+ {
+ _connectMenuItem.Header = state == GatewayState.Connected ? "Disconnect" : "Connect";
+ _connectMenuItem.IsEnabled = state != GatewayState.Connecting && state != GatewayState.Reconnecting;
+ }
+
+ // Show balloon notification on disconnect
+ if (state == GatewayState.Disconnected && message != null)
+ {
+ _trayIcon.ShowBalloonTip(
+ "Clawdbot",
+ $"Gateway connection lost: {message}",
+ BalloonIcon.Warning);
+ }
+ }
+
+ public void ShowNotification(string title, string message, BalloonIcon icon = BalloonIcon.Info)
+ {
+ _trayIcon.ShowBalloonTip(title, message, icon);
+ }
+
+ private ContextMenu CreateContextMenu()
+ {
+ var menu = new ContextMenu();
+
+ // Status (read-only)
+ _statusMenuItem = new MenuItem
+ {
+ Header = "Status: Disconnected",
+ IsEnabled = false
+ };
+ menu.Items.Add(_statusMenuItem);
+
+ menu.Items.Add(new Separator());
+
+ // Open Window
+ var openItem = new MenuItem { Header = "Open Clawdbot" };
+ openItem.Click += (_, _) => ShowWindowRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(openItem);
+
+ // Connect/Disconnect
+ _connectMenuItem = new MenuItem { Header = "Connect" };
+ _connectMenuItem.Click += OnConnectClick;
+ menu.Items.Add(_connectMenuItem);
+
+ menu.Items.Add(new Separator());
+
+ // Gateway Status (submenu)
+ var gatewayMenu = new MenuItem { Header = "Gateway" };
+
+ var openControlUi = new MenuItem { Header = "Open Control UI in Browser" };
+ openControlUi.Click += (_, _) =>
+ {
+ // Convert ws:// to http:// for browser
+ var httpUrl = _settings.GatewayUrl
+ .Replace("ws://", "http://")
+ .Replace("wss://", "https://")
+ .TrimEnd('/');
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = httpUrl,
+ UseShellExecute = true
+ });
+ };
+ gatewayMenu.Items.Add(openControlUi);
+
+ var copyUrl = new MenuItem { Header = "Copy Gateway URL" };
+ copyUrl.Click += (_, _) => Clipboard.SetText(_settings.GatewayUrl);
+ gatewayMenu.Items.Add(copyUrl);
+
+ menu.Items.Add(gatewayMenu);
+
+ menu.Items.Add(new Separator());
+
+ // Debug menu (for testing)
+ #if DEBUG
+ var debugMenu = new MenuItem { Header = "Debug" };
+
+ var testApprovalDialog = new MenuItem { Header = "Test Approval Dialog" };
+ testApprovalDialog.Click += (_, _) => ShowTestApprovalDialog();
+ debugMenu.Items.Add(testApprovalDialog);
+
+ menu.Items.Add(debugMenu);
+ menu.Items.Add(new Separator());
+ #endif
+
+ // Settings
+ var settingsItem = new MenuItem { Header = "Settings..." };
+ settingsItem.Click += (_, _) => SettingsRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(settingsItem);
+
+ menu.Items.Add(new Separator());
+
+ // Exit
+ var exitItem = new MenuItem { Header = "Exit" };
+ exitItem.Click += (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(exitItem);
+
+ return menu;
+ }
+
+ private async void OnConnectClick(object sender, RoutedEventArgs e)
+ {
+ if (_gateway.State == GatewayState.Connected)
+ {
+ await _gateway.DisconnectAsync();
+ }
+ else
+ {
+ try
+ {
+ await _gateway.ConnectAsync();
+ }
+ catch (Exception ex)
+ {
+ UpdateStatus(GatewayState.Disconnected, ex.Message);
+ }
+ }
+ }
+
+ private static Icon? LoadIconFromFile()
+ {
+ try
+ {
+ // Get the directory where the exe is located
+ var exePath = Assembly.GetExecutingAssembly().Location;
+ var exeDir = Path.GetDirectoryName(exePath);
+ if (exeDir == null) return null;
+
+ var iconPath = Path.Combine(exeDir, "Assets", "clawdbot.ico");
+ AppLogger.Debug($"Looking for icon at: {iconPath}");
+
+ if (File.Exists(iconPath))
+ {
+ var icon = new Icon(iconPath, 16, 16);
+ AppLogger.Info($"Loaded tray icon from: {iconPath}");
+ return icon;
+ }
+
+ AppLogger.Warn($"Icon file not found at: {iconPath}");
+ return null;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("Failed to load icon from file", ex);
+ return null;
+ }
+ }
+
+ private static Icon CreateFallbackIcon(GatewayState state)
+ {
+ // Create a simple colored icon based on state
+ // In a real app, you'd use actual icon files
+ var color = state switch
+ {
+ GatewayState.Connected => Color.LimeGreen,
+ GatewayState.Connecting => Color.Yellow,
+ GatewayState.Reconnecting => Color.Orange,
+ GatewayState.Disconnected => Color.Red,
+ _ => Color.Gray
+ };
+
+ // Create a simple 16x16 icon with the lobster emoji/shape
+ var bitmap = new Bitmap(16, 16);
+ using (var g = Graphics.FromImage(bitmap))
+ {
+ g.Clear(Color.Transparent);
+
+ // Draw a simple lobster shape (two claws and body)
+ using var brush = new SolidBrush(color);
+
+ // Body (center ellipse)
+ g.FillEllipse(brush, 4, 6, 8, 8);
+
+ // Left claw
+ g.FillEllipse(brush, 0, 2, 6, 5);
+
+ // Right claw
+ g.FillEllipse(brush, 10, 2, 6, 5);
+ }
+
+ return Icon.FromHandle(bitmap.GetHicon());
+ }
+
+ #if DEBUG
+ private void ShowTestApprovalDialog()
+ {
+ AppLogger.Info("Showing test approval dialog");
+
+ var testRequest = new GatewayExecApprovalRequest
+ {
+ Id = $"test-{Guid.NewGuid():N}",
+ Request = new ExecApprovalPromptRequest
+ {
+ Command = "npm install --save-dev typescript @types/node eslint prettier",
+ Cwd = @"C:\Users\User\Projects\MyApp",
+ Host = Environment.MachineName,
+ Security = "ask",
+ Ask = "always",
+ AgentId = "claude-3.5-sonnet",
+ ResolvedPath = @"C:\Program Files\nodejs\npm.cmd",
+ SessionKey = "session-demo-123"
+ },
+ CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ ExpiresAtMs = DateTimeOffset.UtcNow.AddSeconds(60).ToUnixTimeMilliseconds()
+ };
+
+ var decision = ExecApprovalDialog.ShowApproval(testRequest);
+ AppLogger.Info($"Test dialog result: {decision}");
+
+ ShowNotification("Exec Approval Test", $"Decision: {decision.ToWireFormat()}", BalloonIcon.Info);
+ }
+ #endif
+
+ public void Dispose()
+ {
+ _trayIcon.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/apps/windows/tests/Clawdbot.Windows.Tests/Clawdbot.Windows.Tests.csproj b/apps/windows/tests/Clawdbot.Windows.Tests/Clawdbot.Windows.Tests.csproj
new file mode 100644
index 000000000..bba8aa036
--- /dev/null
+++ b/apps/windows/tests/Clawdbot.Windows.Tests/Clawdbot.Windows.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net9.0-windows
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/windows/tests/Clawdbot.Windows.Tests/ExecApprovalTests.cs b/apps/windows/tests/Clawdbot.Windows.Tests/ExecApprovalTests.cs
new file mode 100644
index 000000000..31449ead8
--- /dev/null
+++ b/apps/windows/tests/Clawdbot.Windows.Tests/ExecApprovalTests.cs
@@ -0,0 +1,167 @@
+using Clawdbot.Windows.Core;
+
+namespace Clawdbot.Windows.Tests;
+
+///
+/// Phase 1 tests for Exec Approval functionality
+///
+[Trait("Category", "Phase1")]
+public class ExecApprovalTests
+{
+ [Fact]
+ public void ExecApprovalDecision_AllowOnce_ToWireFormat_ReturnsAllowOnce()
+ {
+ // Act
+ var wireFormat = ExecApprovalDecision.AllowOnce.ToWireFormat();
+
+ // Assert
+ Assert.Equal("allow-once", wireFormat);
+ }
+
+ [Fact]
+ public void ExecApprovalDecision_AllowAlways_ToWireFormat_ReturnsAllowAlways()
+ {
+ // Act
+ var wireFormat = ExecApprovalDecision.AllowAlways.ToWireFormat();
+
+ // Assert
+ Assert.Equal("allow-always", wireFormat);
+ }
+
+ [Fact]
+ public void ExecApprovalDecision_Deny_ToWireFormat_ReturnsDeny()
+ {
+ // Act
+ var wireFormat = ExecApprovalDecision.Deny.ToWireFormat();
+
+ // Assert
+ Assert.Equal("deny", wireFormat);
+ }
+
+ [Fact]
+ public void GatewayExecApprovalRequest_IsExpired_WhenPastExpiryTime_ReturnsTrue()
+ {
+ // Arrange
+ var request = new GatewayExecApprovalRequest
+ {
+ Id = "test-id",
+ Request = new ExecApprovalPromptRequest
+ {
+ Command = "echo hello",
+ Cwd = "/tmp",
+ Host = "localhost",
+ Security = "ask",
+ Ask = "always"
+ },
+ CreatedAtMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(),
+ ExpiresAtMs = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeMilliseconds() // Expired 1 min ago
+ };
+
+ // Act & Assert
+ Assert.True(request.IsExpired);
+ }
+
+ [Fact]
+ public void GatewayExecApprovalRequest_IsExpired_WhenNotPastExpiryTime_ReturnsFalse()
+ {
+ // Arrange
+ var request = new GatewayExecApprovalRequest
+ {
+ Id = "test-id",
+ Request = new ExecApprovalPromptRequest
+ {
+ Command = "echo hello",
+ Cwd = "/tmp",
+ Host = "localhost",
+ Security = "ask",
+ Ask = "always"
+ },
+ CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ ExpiresAtMs = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds() // Expires in 5 min
+ };
+
+ // Act & Assert
+ Assert.False(request.IsExpired);
+ }
+
+ [Fact]
+ public void GatewayExecApprovalRequest_TimeRemaining_WhenNotExpired_ReturnsPositiveTimeSpan()
+ {
+ // Arrange
+ var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
+ var request = new GatewayExecApprovalRequest
+ {
+ Id = "test-id",
+ Request = new ExecApprovalPromptRequest
+ {
+ Command = "echo hello",
+ Cwd = "/tmp",
+ Host = "localhost",
+ Security = "ask",
+ Ask = "always"
+ },
+ CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ ExpiresAtMs = expiresAt.ToUnixTimeMilliseconds()
+ };
+
+ // Act
+ var remaining = request.TimeRemaining;
+
+ // Assert
+ Assert.True(remaining.TotalSeconds > 0);
+ Assert.True(remaining.TotalMinutes <= 5);
+ }
+
+ [Fact]
+ public void GatewayExecApprovalRequest_TimeRemaining_WhenExpired_ReturnsZero()
+ {
+ // Arrange
+ var request = new GatewayExecApprovalRequest
+ {
+ Id = "test-id",
+ Request = new ExecApprovalPromptRequest
+ {
+ Command = "echo hello",
+ Cwd = "/tmp",
+ Host = "localhost",
+ Security = "ask",
+ Ask = "always"
+ },
+ CreatedAtMs = DateTimeOffset.UtcNow.AddMinutes(-10).ToUnixTimeMilliseconds(),
+ ExpiresAtMs = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds() // Expired 5 min ago
+ };
+
+ // Act
+ var remaining = request.TimeRemaining;
+
+ // Assert
+ Assert.Equal(TimeSpan.Zero, remaining);
+ }
+
+ [Fact]
+ public void ExecApprovalPromptRequest_Properties_CanBeSetAndRetrieved()
+ {
+ // Arrange & Act
+ var request = new ExecApprovalPromptRequest
+ {
+ Command = "npm install",
+ Cwd = "C:\\Projects\\MyApp",
+ Host = "DESKTOP-123",
+ Security = "sandbox",
+ Ask = "once",
+ AgentId = "agent-456",
+ ResolvedPath = "C:\\Program Files\\nodejs\\npm.cmd",
+ SessionKey = "session-789"
+ };
+
+ // Assert
+ Assert.Equal("npm install", request.Command);
+ Assert.Equal("C:\\Projects\\MyApp", request.Cwd);
+ Assert.Equal("DESKTOP-123", request.Host);
+ Assert.Equal("sandbox", request.Security);
+ Assert.Equal("once", request.Ask);
+ Assert.Equal("agent-456", request.AgentId);
+ Assert.Equal("C:\\Program Files\\nodejs\\npm.cmd", request.ResolvedPath);
+ Assert.Equal("session-789", request.SessionKey);
+ }
+}
diff --git a/apps/windows/tests/Clawdbot.Windows.Tests/Phase0ValidationTests.cs b/apps/windows/tests/Clawdbot.Windows.Tests/Phase0ValidationTests.cs
new file mode 100644
index 000000000..95d825388
--- /dev/null
+++ b/apps/windows/tests/Clawdbot.Windows.Tests/Phase0ValidationTests.cs
@@ -0,0 +1,286 @@
+using Clawdbot.Windows.Core;
+using Clawdbot.Windows.Protocol;
+
+namespace Clawdbot.Windows.Tests;
+
+///
+/// Phase 0 validation tests - must ALL pass before proceeding with development.
+/// These tests require a running Gateway at ws://127.0.0.1:18789/
+///
+/// Run with: dotnet test --filter "Category=Phase0"
+///
+[Trait("Category", "Phase0")]
+public class Phase0ValidationTests
+{
+ private const string GatewayUrl = "ws://127.0.0.1:18789/";
+
+ ///
+ /// Checkpoint 0.1: Gateway Connectivity
+ /// Test that we can establish a WebSocket connection and receive hello response
+ ///
+ [Fact(Skip = "Requires running Gateway")]
+ public async Task Checkpoint01_GatewayConnects_ReceivesHello()
+ {
+ // Arrange
+ await using var gateway = new GatewayChannel(GatewayUrl);
+
+ // Act
+ await gateway.ConnectAsync();
+
+ // Assert
+ Assert.Equal(GatewayState.Connected, gateway.State);
+ Assert.NotNull(gateway.HelloResponse);
+ Assert.Equal(GatewayProtocol.Version, gateway.HelloResponse.Protocol);
+ Assert.NotNull(gateway.Snapshot);
+ }
+
+ ///
+ /// Checkpoint 0.2a: RPC Round-Trip - config.get
+ ///
+ [Fact(Skip = "Requires running Gateway")]
+ public async Task Checkpoint02a_ConfigGet_ReturnsConfigAndHash()
+ {
+ // Arrange
+ await using var gateway = new GatewayChannel(GatewayUrl);
+ await gateway.ConnectAsync();
+
+ // Act
+ var response = await gateway.RequestAsync("config.get");
+
+ // Assert
+ Assert.NotNull(response);
+ Assert.NotEmpty(response.Hash);
+ Assert.NotEmpty(response.Path);
+ // Raw may be empty if no config file exists, but should not be null
+ Assert.NotNull(response.Raw);
+ }
+
+ ///
+ /// Checkpoint 0.2b: RPC Round-Trip - channels.status
+ ///
+ [Fact(Skip = "Requires running Gateway")]
+ public async Task Checkpoint02b_ChannelsStatus_ReturnsChannelList()
+ {
+ // Arrange
+ await using var gateway = new GatewayChannel(GatewayUrl);
+ await gateway.ConnectAsync();
+
+ // Act
+ var response = await gateway.RequestAsync("channels.status");
+
+ // Assert
+ Assert.NotNull(response);
+ Assert.NotNull(response.ChannelOrder);
+ Assert.NotNull(response.ChannelLabels);
+ Assert.True(response.Ts > 0, "Timestamp should be positive");
+ }
+
+ ///
+ /// Checkpoint 0.2c: RPC Round-Trip - sessions.list
+ ///
+ [Fact(Skip = "Requires running Gateway")]
+ public async Task Checkpoint02c_SessionsList_ReturnsSessions()
+ {
+ // Arrange
+ await using var gateway = new GatewayChannel(GatewayUrl);
+ await gateway.ConnectAsync();
+
+ // Act
+ var response = await gateway.RequestAsync>("sessions.list", new SessionsListParams
+ {
+ Limit = 10
+ });
+
+ // Assert
+ Assert.NotNull(response);
+ // Sessions list may be empty, but should not be null
+ }
+
+ ///
+ /// Checkpoint 0.2d: Error responses parse correctly
+ ///
+ [Fact(Skip = "Requires running Gateway")]
+ public async Task Checkpoint02d_InvalidRequest_ReturnsStructuredError()
+ {
+ // Arrange
+ await using var gateway = new GatewayChannel(GatewayUrl);
+ await gateway.ConnectAsync();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await gateway.RequestAsync