Skip to content

feat: add native Windows desktop app (C#/WPF/.NET 8)#142

Open
2witstudios wants to merge 2 commits intomainfrom
ppg/windows-app
Open

feat: add native Windows desktop app (C#/WPF/.NET 8)#142
2witstudios wants to merge 2 commits intomainfrom
ppg/windows-app

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Feb 27, 2026

Summary

  • Native Windows desktop application for PPG built with C#/WPF/.NET 8
  • Full MVVM architecture with CommunityToolkit.Mvvm source generators
  • Connects to ppg serve via REST API + WebSocket for real-time updates
  • Replicates macOS app functionality: sidebar tree, terminal panes, dashboard, settings

Architecture

42 files, ~3000 lines across:

Layer Files Purpose
Models Manifest.cs, AgentVariant.cs, AppSettings.cs, ServerConnection.cs Data types matching ppg-cli TypeScript definitions
Services PpgApiClient.cs, WebSocketService.cs, SettingsService.cs, TokenStorage.cs REST client, reconnecting WS, DPAPI credential storage
ViewModels Main, Sidebar, Terminal, HomeDashboard, SpawnDialog, Settings MVVM state with ObservableProperty/RelayCommand
Views Sidebar, Terminal, HomeDashboard, WorktreeDetail, SpawnDialog, Settings XAML views with dark theme
Terminal terminal.html, TerminalBridge.cs WebView2 + xterm.js with C# bridge
Converters StatusToColor, BoolToVisibility, InverseBool Data binding converters

Key Features

  • Sidebar tree: Projects > Worktrees > Agents hierarchy with status color badges
  • Terminal: WebView2 hosting xterm.js with bidirectional I/O via ppg serve WebSocket
  • Dashboard: Agent stats cards, commit heatmap (13x7 grid, 5 color levels)
  • Spawn dialog: Two-phase agent variant picker + prompt input
  • Settings: Connection config, terminal font/size, appearance mode
  • WebSocket: Auto-reconnect with exponential backoff, ping keepalive
  • Security: DPAPI-encrypted bearer token storage

Prerequisites

  • .NET 8 SDK
  • WebView2 Runtime (included with Windows 10+ / Edge)
  • download-xterm.ps1 to install xterm.js dependencies

Test plan

  • Verify dotnet build compiles cleanly on Windows with .NET 8
  • Run download-xterm.ps1 to install xterm.js, verify terminal renders
  • Test connection to a running ppg serve instance
  • Verify sidebar tree populates from WebSocket manifest events
  • Test spawn dialog creates worktrees via REST API
  • Verify terminal I/O with an active agent
  • Test settings save/load to %APPDATA%/PPG Desktop/
  • Test DPAPI token storage and retrieval

Summary by CodeRabbit

Release Notes

New Features

  • iOS Mobile App: Fully functional mobile client with dashboard view showing worktrees and agents, spawn interface for creating new agents, terminal view for real-time interaction, and settings for server connection management via manual entry or QR code scanning.
  • Windows Desktop App: Native desktop application for Windows with dark theme UI, server connectivity, and dashboard navigation.
  • Server Daemon: New serve command to start/stop a persistent PPG server with optional token-based authentication and pairing URL generation.
  • WebSocket Support: Real-time agent status updates and terminal streaming via WebSocket connections with automatic reconnection and keep-alive pings.
  • TLS Certificates: Automatic self-signed certificate generation for secure server connections with CA and server certificate validation.
  • Improved Authentication: Token-based authentication system with rate limiting and persistent keychain/credential storage.

Refactoring

  • Consolidated spawn, merge, kill, and restart operations into unified core modules for consistency across CLI, mobile, and desktop clients.
  • Centralized prompt and template metadata enrichment across platforms.

Lines Changed: +7,700 (substantial new codebase)

Complete WPF desktop application for PPG with MVVM architecture:
- Models: Manifest types matching ppg-cli TypeScript definitions, API request/response records
- Services: REST API client, reconnecting WebSocket service, DPAPI token storage, JSON settings
- ViewModels: Main, Sidebar (tree hierarchy), Terminal, HomeDashboard (stats + heatmap), Spawn, Settings
- Views: Sidebar tree, WebView2 terminal (xterm.js), dashboard with stats cards + commit heatmap, spawn dialog, settings panel
- Terminal: WebView2 + xterm.js bridge with bidirectional message passing
- Converters: Status-to-color, bool-to-visibility, inverse-bool for data binding
- DI container via Microsoft.Extensions.DependencyInjection
- CommunityToolkit.Mvvm source generators for ObservableProperty/RelayCommand
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

This PR implements a comprehensive, multi-platform version of the ppg tool featuring: an iOS mobile app (SwiftUI) with REST and WebSocket clients; a Windows desktop app (WPF); an enhanced Node.js/Fastify server with TLS, authentication, and WebSocket support; and refactored CLI commands that delegate to centralized core operations for kill, merge, restart, and spawn workflows.

Changes

Cohort / File(s) Summary
iOS Models
ios/PPGMobile/.../Manifest.swift, ios/PPGMobile/.../AgentVariant.swift, ios/PPGMobile/.../ServerConnection.swift, ios/PPGMobile/.../DashboardModels.swift
New data models for manifest structure (AgentStatus, WorktreeStatus, AgentEntry, WorktreeEntry), agent variants with UI properties, server connection configuration with URL/QR code generation, and UI-facing enums (ConnectionState, DiffStats) with computed properties.
iOS Networking
ios/PPGMobile/.../PPGClient.swift, ios/PPGMobile/.../TokenStorage.swift, ios/PPGMobile/.../WebSocketManager.swift
REST client with TLS pinning and bearer token auth; keychain-backed token storage; WebSocket manager with connection state, event types (manifest, agent status, terminal), reconnection with exponential backoff, keep-alive pings, and terminal input support.
iOS State Management & Store
ios/PPGMobile/.../AppState.swift, ios/PPGMobile/.../ManifestStore.swift
AppState orchestrates server connections, token persistence, REST/WS lifecycle, and manifest synchronization via WebSocket; ManifestStore caches manifest, supports incremental updates from WebSocket events, and refreshes via REST.
iOS Dashboard Views
ios/PPGMobile/.../DashboardView.swift, ios/PPGMobile/.../WorktreeCard.swift, ios/PPGMobile/.../WorktreeDetailView.swift, ios/PPGMobile/.../AgentRow.swift
Dashboard displays worktrees and agents with status badges; WorktreeCard renders metadata (agent counts, creation date); WorktreeDetailView shows agent list and merge/kill/PR actions; AgentRow displays agent status with kill/restart confirmations.
iOS Settings & Connection Views
ios/PPGMobile/.../SettingsView.swift, ios/PPGMobile/.../AddServerView.swift, ios/PPGMobile/.../QRScannerView.swift
SettingsView manages saved connections, test connection, and shows current server; AddServerView provides manual server configuration form; QRScannerView uses AVCaptureSession for QR code scanning with ppg:// URL parsing.
iOS Terminal & Spawn Views
ios/PPGMobile/.../TerminalView.swift, ios/PPGMobile/.../TerminalInputBar.swift, ios/PPGMobile/.../SpawnView.swift
TerminalView fetches logs via REST and streams live output via WebSocket with a TerminalViewModel; TerminalInputBar provides monospaced input field; SpawnView renders form for worktree spawning with validation and agent variant selection.
iOS App Structure
ios/PPGMobile/.../PPGMobileApp.swift, ios/PPGMobile/.../ContentView.swift, ios/PPGMobile/project.yml, ios/PPGMobile/.../AppIcon.appiconset/Contents.json, ios/PPGMobile/.../Assets.xcassets/Contents.json
Main app entry point with tab-based navigation (Dashboard, Spawn, Settings); ContentView placeholder; Xcode project configuration with iOS 17 deployment target; app icon and asset catalog metadata.
iOS Tests
ios/PPGMobile/PPGMobileTests/*.swift
Tests for AgentVariant resolution, Manifest decoding (aliases, flattening), ServerConnection (URL builders, QR round-trip parsing), and model validation.
Windows Desktop App
windows/PPGDesktop/App.xaml*, windows/PPGDesktop/MainWindow.xaml*, windows/PPGDesktop/Models/*, windows/PPGDesktop/Converters/*, windows/PPGDesktop.sln
WPF application with DI setup; App.xaml defines dark theme resources (brushes, colors, converters); MainWindow wires sidebar navigation and dialog flows; models include AppSettings and AgentVariant; converters for status/bool/visibility binding.
CLI Serve Commands & Server
src/commands/serve.ts, src/commands/serve.test.ts, src/core/serve.ts, src/core/serve.test.ts
New serve command group (start/stop/status/daemon) managing a ppg server daemon via tmux; core module provides PID/state file management, logging, and daemon lifecycle; tests validate state transitions and log handling.
CLI Command Refactoring
src/commands/kill.ts, src/commands/merge.ts, src/commands/restart.ts, src/commands/spawn.ts, src/commands/status.ts, src/commands/list.ts, src/commands/pr.ts, src/commands/init.ts
Commands refactored to delegate to centralized core operations (performKill, performMerge, performRestart, performSpawn) reducing inline orchestration; removed duplicated logic for manifest mutations and state transitions.
CLI Core Operations
src/core/operations/kill.ts, src/core/operations/kill.test.ts, src/core/operations/merge.ts, src/core/operations/merge.test.ts, src/core/operations/restart.ts, src/core/operations/restart.test.ts, src/core/operations/spawn.ts, src/core/operations/spawn.test.ts
New operations layer with performKill (agent/worktree/all), performMerge (with dry-run and cleanup), performRestart, performSpawn (new/existing branch/worktree paths); comprehensive test suites validate manifest updates, error handling, and control flow.
CLI Core Utilities
src/core/agent.ts, src/core/kill.ts, src/core/merge.ts, src/core/lifecycle.ts, src/core/prompt.ts, src/core/prompt.test.ts, src/core/pr.ts, src/core/spawn.ts, src/core/tls.ts
New modules for agent restart (restartAgent), worktree agent termination (killWorktreeAgents), merge logic, lifecycle state computation, prompt discovery/enrichment, PR creation (buildBodyFromResults, createWorktreePr), agent batch spawning, and TLS cert management (ensureTlsCerts, getCertFingerprint).
CLI Test & Configuration
src/commands/spawn.test.ts, src/test-fixtures.ts, package.json, .gitignore
Tests for spawn delegation; makeManifest test helper; added dependencies (fastify, ws, qrcode-terminal); added .gitignore patterns for Xcode artifacts.
CLI Paths & Errors
src/lib/paths.ts, src/lib/paths.test.ts, src/lib/errors.ts
New path helpers for serve/TLS directories and files (serveDir, tlsDir, servePidPath, etc.); new error classes (PromptNotFoundError, DuplicateTokenError, AuthCorruptError).
Server Auth & Error Handling
src/server/auth.ts, src/server/auth.test.ts, src/server/error-handler.ts, src/server/error-handler.test.ts
Token generation/hashing, rate limiting, persistent auth store with corruption handling, Fastify auth hook with token validation; error response builder mapping PpgError codes to HTTP statuses with structured payloads.
Server Routes
src/server/routes/agents.ts, src/server/routes/agents.test.ts, src/server/routes/config.ts, src/server/routes/config.test.ts, src/server/routes/spawn.ts, src/server/routes/spawn.test.ts, src/server/routes/status.ts, src/server/routes/status.test.ts, src/server/routes/worktrees.ts, src/server/routes/worktrees.test.ts
RESTful routes for agent logs/send/kill/restart, config/templates/prompts, spawn, status/worktree status/diff, and worktree merge/kill/PR operations; schema validation, error mapping, and comprehensive test coverage.
Server WebSocket
src/server/ws/events.ts, src/server/ws/handler.ts, src/server/ws/handler.test.ts, src/server/ws/terminal.ts, src/server/ws/terminal.test.ts, src/server/ws/watcher.ts, src/server/ws/watcher.test.ts
WebSocket event contracts (client commands, server events), connection handler with token validation and message routing, terminal streamer with diff-based line emission and per-agent polling, manifest watcher with debounce and agent status broadcasting; extensive test coverage for all components.
Server Infrastructure
src/server/index.ts, src/server/index.test.ts, src/server/tls.ts, src/server/tls.test.ts
Fastify server bootstrap with optional auth, health/status endpoints, route plugin registration, state file management; TLS bundle generation with self-signed CA, server cert with SANs, certificate validation, and pairing URL generation; helper functions for LAN IP detection and timing-safe token comparison.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 A mobile quest through networks spun,
With Swift and Fastify, REST and WebSocket run,
From server's TLS kiss to iOS embrace,
The ppg tool blooms in every place! 🌸
Refactored commands dance in harmony,
Multi-platform magic, wild and free!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: add native Windows desktop app (C#/WPF/.NET 8)' clearly and specifically describes the main change—adding a Windows desktop application built with C#, WPF, and .NET 8.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes a 'Summary' section describing the application, an 'Architecture' section with a table of layers/files/purposes, 'Key Features' outlining functionality, 'Prerequisites' for setup, and a detailed 'Test plan' checklist. All major sections from the template are covered with substantial detail.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ppg/windows-app

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (11)
ios/PPGMobile/PPGMobile/Assets.xcassets/AppIcon.appiconset/Contents.json-2-8 (1)

2-8: ⚠️ Potential issue | 🟡 Minor

App icon image file not referenced.

The images entry specifies the 1024x1024 icon slot but lacks a "filename" property pointing to an actual image file. The app will build and run, but will display a blank/default app icon.

If this is intentional (placeholder for later), consider adding a TODO. Otherwise, add the icon image to this directory and update the entry:

Example with filename reference
   "images": [
     {
       "idiom": "universal",
+      "filename": "AppIcon.png",
       "platform": "ios",
       "size": "1024x1024"
     }
   ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Assets.xcassets/AppIcon.appiconset/Contents.json`
around lines 2 - 8, The images entry for the 1024x1024 app icon is missing a
"filename" property, so the icon slot is not pointing to an actual image; fix by
adding the icon image file into the app icon asset folder and updating the
images array entry for the "1024x1024" size to include "filename":
"<your-icon-filename.png>" (or, if this is intentionally a placeholder, add a
clear TODO/comment in the asset JSON indicating the missing file and why it's
omitted).
windows/PPGDesktop/Models/AppSettings.cs-10-11 (1)

10-11: ⚠️ Potential issue | 🟡 Minor

Consider adding [JsonIgnore] to prevent accidental token serialization.

The Token property is populated from DPAPI secure storage (per SettingsService.Load()), but having [JsonPropertyName("token")] means it could be serialized to the JSON settings file if Save() is called while Token is set. If the intent is to keep the token exclusively in DPAPI-protected storage, consider:

🛡️ Proposed fix
-    [JsonPropertyName("token")]
+    [JsonIgnore]
     public string? Token { get; set; }

If the token should be persisted to JSON as a fallback, the current implementation is fine, but verify this aligns with the security model described in the PR (DPAPI-encrypted bearer token storage).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@windows/PPGDesktop/Models/AppSettings.cs` around lines 10 - 11, The Token
property on AppSettings (public string? Token) is marked with JsonPropertyName
and can be written to settings JSON; to prevent accidental serialization move
token storage exclusively to DPAPI by adding the [JsonIgnore] attribute to the
Token property in AppSettings, and ensure SettingsService.Load()/Save()
continues to read/write token from DPAPI only; if you intentionally want a JSON
fallback, document that behavior in AppSettings and SettingsService instead of
leaving JsonPropertyName alone.
src/server/routes/spawn.test.ts-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Add afterEach teardown to close the Fastify app and prevent resource leaks.

The app is created in beforeEach (line 55–58) but never closed. This mirrors the pattern in config.test.ts (line 28–31), which correctly includes afterEach(async () => { await app?.close(); }) to release handles.

Proposed fix
-import { describe, test, expect, vi, beforeEach } from 'vitest';
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';

Then add after the beforeEach block:

   beforeEach(async () => {
     vi.clearAllMocks();
     app = await buildApp();
   });
+
+  afterEach(async () => {
+    await app.close();
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/spawn.test.ts` at line 1, The tests create a Fastify app in
the beforeEach block but never close it, causing resource leaks; add an
afterEach teardown that awaits closing the app (use the same app variable
created in beforeEach) by adding an afterEach(async () => { await app?.close();
}) to mirror config.test.ts and ensure the Fastify instance is properly closed
between tests.
src/core/spawn.ts-127-129 (1)

127-129: ⚠️ Potential issue | 🟡 Minor

Validate count before creating the worktree.

count <= 0 currently still creates the worktree/tmux resources and returns with no agents. Reject invalid values up front.

🧪 Guard invalid spawn counts
   const count = opts.count ?? 1;
+  if (!Number.isInteger(count) || count < 1) {
+    throw new PpgError('count must be an integer >= 1', 'INVALID_ARGS');
+  }
   const userVars = opts.userVars ?? {};
Based on learnings, use `PpgError` hierarchy with typed error codes including `INVALID_ARGS`.

Also applies to: 171-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/spawn.ts` around lines 127 - 129, Validate the spawn `count`
immediately after reading `opts.count` (the `count` variable) and before calling
`requireManifest` or creating any worktree/tmux resources: if count is not a
positive integer (<= 0 or NaN), throw a PpgError with error code INVALID_ARGS
and a clear message (e.g., "invalid count: must be >= 1"). Ensure the same guard
is applied in the other creation path referenced around the block for lines
171-179 (the agent/worktree creation logic) so no resources are allocated when
count is invalid.
src/server/auth.ts-261-269 (1)

261-269: ⚠️ Potential issue | 🟡 Minor

Authorization scheme parsing should be case-insensitive.

HTTP auth scheme tokens are case-insensitive; strict startsWith('Bearer ') can reject valid clients.

🔧 Robust Authorization parsing
-    if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
+    if (typeof authHeader !== 'string' || !/^Bearer\s+/i.test(authHeader)) {
       rateLimiter.record(ip);
       reply.code(401).send({ error: 'Missing or malformed Authorization header' });
       return;
     }

-    const token = authHeader.slice(7).trim();
+    const token = authHeader.replace(/^Bearer\s+/i, '').trim();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/auth.ts` around lines 261 - 269, The Authorization header check in
auth.ts uses a case-sensitive startsWith('Bearer ') which can reject valid
headers; update the parsing around authHeader (and the subsequent token
extraction into token / TokenEntry logic) to perform a case-insensitive scheme
check (for example compare the scheme lowercased or use a case-insensitive
regex) and then extract the token string safely (trim and validate non-empty)
before proceeding to rateLimiter.record(ip) and reply.code(401). Ensure you
still handle missing/malformed headers the same way but accept variants like
"bearer" or "BEARER".
src/core/serve.test.ts-47-56 (1)

47-56: ⚠️ Potential issue | 🟡 Minor

Use platform-independent path expectations in assertions.

These assertions hardcode POSIX separators, which can fail on Windows.

✅ Cross-platform expectation fix
-    expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.pid');
+    expect(fs.unlink).toHaveBeenCalledWith(path.join('/fake/project', '.ppg', 'serve.pid'));
...
-    expect(fs.unlink).toHaveBeenCalledWith('/fake/project/.ppg/serve.pid');
+    expect(fs.unlink).toHaveBeenCalledWith(path.join('/fake/project', '.ppg', 'serve.pid'));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/serve.test.ts` around lines 47 - 56, The test assertions use a
hardcoded POSIX path string; update them to build the expected PID filepath
platform-independently (e.g., use path.join or path.resolve) when calling
getServePid and in the expect for fs.unlink so the tests work on Windows as
well—locate the tests around getServePid in src/core/serve.test.ts and replace
literal '/fake/project/.ppg/serve.pid' with a path.join-based expression that
constructs the same segments (project root, '.ppg', 'serve.pid').
src/server/routes/status.ts-16-21 (1)

16-21: ⚠️ Potential issue | 🟡 Minor

Timing side-channel on token length.

The early return when buffer lengths differ allows an attacker to determine the token length via timing analysis. While the actual comparison uses crypto.timingSafeEqual, the length check leaks information.

🛡️ Proposed fix to mitigate length oracle
 function timingSafeEqual(a: string, b: string): boolean {
   const aBuffer = Buffer.from(a);
   const bBuffer = Buffer.from(b);
-  if (aBuffer.length !== bBuffer.length) return false;
-  return crypto.timingSafeEqual(aBuffer, bBuffer);
+  // Pad to same length to prevent length oracle, then compare
+  const maxLen = Math.max(aBuffer.length, bBuffer.length);
+  const aPadded = Buffer.alloc(maxLen);
+  const bPadded = Buffer.alloc(maxLen);
+  aBuffer.copy(aPadded);
+  bBuffer.copy(bPadded);
+  // Both comparisons must pass
+  return crypto.timingSafeEqual(aPadded, bPadded) && aBuffer.length === bBuffer.length;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/status.ts` around lines 16 - 21, The timingSafeEqual
function leaks token length because it returns early when buffer lengths differ;
change it to produce fixed-size, equal-length inputs before calling
crypto.timingSafeEqual. For example, instead of comparing raw buffers and
early-returning, normalize both inputs to a constant-length representation
(e.g., compute a fixed-length digest such as SHA-256 for each string using
crypto.createHash('sha256').update(...).digest()) and then call
crypto.timingSafeEqual on those digests so comparison time does not reveal the
original lengths; update the timingSafeEqual function to implement this change.
windows/PPGDesktop/MainWindow.xaml.cs-18-23 (1)

18-23: ⚠️ Potential issue | 🟡 Minor

Fire-and-forget async call may swallow exceptions.

The discard _ = terminalVm.AttachAsync(...) will silently swallow any exceptions thrown by AttachAsync. Consider awaiting or adding error handling to surface failures to the user.

💡 Suggested fix with error handling
         viewModel.Sidebar.AgentSelected += agent =>
         {
             var terminalVm = App.Services.GetRequiredService<TerminalViewModel>();
             viewModel.CurrentView = terminalVm;
-            _ = terminalVm.AttachAsync(agent.Id, agent.DisplayName);
+            _ = terminalVm.AttachAsync(agent.Id, agent.DisplayName)
+                .ContinueWith(t =>
+                {
+                    if (t.IsFaulted)
+                    {
+                        // Log or surface error to user
+                        System.Diagnostics.Debug.WriteLine($"AttachAsync failed: {t.Exception?.InnerException?.Message}");
+                    }
+                }, System.Threading.Tasks.TaskScheduler.FromCurrentSynchronizationContext());
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@windows/PPGDesktop/MainWindow.xaml.cs` around lines 18 - 23, The event
handler for viewModel.Sidebar.AgentSelected is fire-and-forget and discards
exceptions from TerminalViewModel.AttachAsync; change the lambda to an async
handler and await AttachAsync inside a try/catch so errors are not swallowed.
Specifically, update the Sidebar.AgentSelected handler to set
viewModel.CurrentView to the TerminalViewModel, then await
terminalVm.AttachAsync(agent.Id, agent.DisplayName) inside a try block and
handle failures in the catch by logging and/or surfacing an error to the user
(e.g., via the view model or a user-visible notification).
ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift-16-16 (1)

16-16: ⚠️ Potential issue | 🟡 Minor

Navigation to spawned worktree is not wired up.

spawnedWorktreeId is set on line 169 after a successful spawn, and navigationDestination(for: String.self) is defined on lines 62-64, but there's no mechanism to trigger the navigation. The state variable is set but never consumed.

To navigate programmatically in SwiftUI, you'd typically use a NavigationPath or a binding that triggers the destination.

🔧 Possible fix using NavigationPath
 struct SpawnView: View {
     `@Environment`(AppState.self) private var appState
+    `@State` private var navigationPath = NavigationPath()

     // ...

     var body: some View {
-        NavigationStack {
+        NavigationStack(path: $navigationPath) {
             Form {
                 // ...
             }
             // ...
         }
     }

     `@MainActor`
     private func spawnWorktree() async {
         // ...
         do {
             let response = try await appState.client.spawn(...)
             await appState.manifestStore.refresh()
             clearForm()
-            spawnedWorktreeId = response.worktreeId
+            navigationPath.append(response.worktreeId)
         } catch {
             // ...
         }
         // ...
     }

Also applies to: 62-64, 169-169

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift` at line 16,
spawnedWorktreeId is assigned after a successful spawn but never drives
navigation; wire it into a NavigationStack by adding a NavigationPath (or a
`@State` var path: NavigationPath) in SpawnView and, inside the success branch
where spawnedWorktreeId is set, push the id onto the path (e.g.,
path.append(spawnedWorktreeId!)) so navigationDestination(for: String.self) is
triggered; update the view to use NavigationStack(path: $path) instead of a
simple NavigationView and remove/stop relying on the unused spawnedWorktreeId
alone.
ios/PPGMobile/PPGMobileTests/ServerConnectionTests.swift-110-115 (1)

110-115: ⚠️ Potential issue | 🟡 Minor

Incorrect test expectation: missing port should default to 7700, not return nil.

Based on the fromQRCode implementation in the relevant code snippet:

let port = params["port"].flatMap(Int.init) ?? 7700

A missing port parameter defaults to 7700, so the test on line 113 (ppg://connect?host=x&token=t) should succeed and return a connection with port 7700, not return nil.

🐛 Proposed fix
     `@Test`("fromQRCode returns nil when required fields are missing")
     func rejectsMissingFields() {
         `#expect`(ServerConnection.fromQRCode("ppg://connect?host=x&port=1") == nil)  // no token
-        `#expect`(ServerConnection.fromQRCode("ppg://connect?host=x&token=t") == nil)  // no port
         `#expect`(ServerConnection.fromQRCode("ppg://connect?port=1&token=t") == nil)  // no host
     }
+
+    `@Test`("fromQRCode defaults port to 7700 when missing")
+    func defaultsPort() {
+        let parsed = ServerConnection.fromQRCode("ppg://connect?host=x&token=t")
+        `#expect`(parsed?.port == 7700)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobileTests/ServerConnectionTests.swift` around lines 110 -
115, The test incorrectly expects ServerConnection.fromQRCode to return nil when
the port parameter is missing; update the test in ServerConnectionTests.swift to
reflect that fromQRCode (in ServerConnection.fromQRCode) defaults port to 7700:
change the assertion for "ppg://connect?host=x&token=t" to expect a non-nil
ServerConnection and verify its port equals 7700 (leave the other two
expectations for missing token and missing host as nil).
src/core/operations/merge.test.ts-327-328 (1)

327-328: ⚠️ Potential issue | 🟡 Minor

Timestamp validity assertion is ineffective.

Line 328 uses new Date(...) with not.toThrow(), but invalid dates don’t throw in JS. This can hide bad mergedAt values.

✅ Tighten the assertion
-    // Should be a valid ISO date
-    expect(() => new Date(latestManifest.worktrees['wt-abc123'].mergedAt!)).not.toThrow();
+    // Should be a valid ISO date
+    expect(Number.isNaN(Date.parse(latestManifest.worktrees['wt-abc123'].mergedAt!))).toBe(false);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/operations/merge.test.ts` around lines 327 - 328, The current test
uses expect(() => new
Date(latestManifest.worktrees['wt-abc123'].mergedAt!)).not.toThrow(), which is
ineffective because Date construction never throws; instead assert the timestamp
is a valid ISO/date by checking the parsed time is not NaN — e.g. replace the
throw-based check with an assertion that
Date.parse(latestManifest.worktrees['wt-abc123'].mergedAt!) (or new
Date(...).getTime()) is not NaN/finite so the test actually fails for invalid
timestamps.
🧹 Nitpick comments (27)
src/lib/paths.ts (1)

114-128: Avoid drift between serveJsonPath and serveStatePath.

Both helpers currently build the same literal path. Consider delegating one to the other so future path layout changes can’t diverge.

♻️ Optional deduplication
 export function serveStatePath(projectRoot: string): string {
-  return path.join(ppgDir(projectRoot), 'serve.json');
+  return serveJsonPath(projectRoot);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/paths.ts` around lines 114 - 128, The two helpers duplicate the same
path logic; update serveStatePath to delegate to serveJsonPath (or vice versa)
so they share a single implementation and cannot drift—replace the body of
serveStatePath with a call to serveJsonPath(projectRoot) (retaining ppgDir usage
via the existing serveJsonPath) so future changes to the serve.json location
only need to be made in one place.
src/core/serve.test.ts (1)

5-7: Use vi.importActual for partial mocks to match repo convention.

Switching to vi.importActual keeps this aligned with the project’s Vitest mocking pattern.

♻️ Mock factory adjustment
-vi.mock('../lib/paths.js', async (importOriginal) => {
-  const actual = await importOriginal() as Record<string, unknown>;
+vi.mock('../lib/paths.js', async () => {
+  const actual = await vi.importActual<Record<string, unknown>>('../lib/paths.js');
   return {
     ...actual,
As per coding guidelines, use `vi.mock` with `vi.importActual` for partial mocks in tests.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/serve.test.ts` around lines 5 - 7, The test's partial mock uses the
mock factory param importOriginal; replace that with the repo-conventional
vi.importActual call inside the vi.mock factory: in the
vi.mock('../lib/paths.js', async (importOriginal) => { ... }) block, stop using
the importOriginal parameter and instead call vi.importActual to obtain the
actual module (assign to the existing actual variable) before returning the
partial mock; keep the rest of the factory return unchanged so the partial mock
behavior remains the same.
src/lib/paths.test.ts (1)

90-146: Align new test names with the repo’s given X, should Y convention.

The newly added test cases use short names; please rename them to the required behavior-driven format for consistency.

As per coding guidelines, use describe('unitName')test('given X, should Y') pattern for test organization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/paths.test.ts` around lines 90 - 146, Rename the flat short-named
tests to follow the repo convention by grouping related assertions under
describe blocks (e.g., describe('paths') or describe('tls paths') as
appropriate) and changing each test invocation to the behavior-driven style
test('given <input>, should <expected outcome>') — for example replace
test('serveDir', ...) with test('given ROOT, should return .ppg/serve path') and
similarly rename tests for tlsDir, tlsCaKeyPath, tlsCaCertPath,
tlsServerKeyPath, tlsServerCertPath, worktreeBaseDir, worktreePath,
globalPpgDir, globalPromptsDir, globalTemplatesDir, globalSwarmsDir,
serveStatePath, and servePidPath while keeping the same assertions and function
calls (serveDir, tlsDir, tlsCaKeyPath, tlsCaCertPath, tlsServerKeyPath,
tlsServerCertPath, worktreeBaseDir, worktreePath, globalPpgDir,
globalPromptsDir, globalTemplatesDir, globalSwarmsDir, serveStatePath,
servePidPath).
src/server/routes/status.ts (1)

49-71: Inconsistent error handling with centralized error-handler.ts.

This route defines its own ppgErrorToStatus mapping and error handler that diverges from the centralized error-handler.ts:

  • NOT_INITIALIZED: 503 here vs 409 in error-handler.ts
  • MANIFEST_LOCK: 503 here vs 409 in error-handler.ts
  • Response shape differs: { error, code } vs { error: { code, message } }

Consider using registerErrorHandler(fastify) from error-handler.ts for consistency, or document why status routes intentionally use different semantics.

♻️ Suggested refactor to use centralized error handler
+import { registerErrorHandler } from '../error-handler.js';
 import { NotInitializedError, PpgError } from '../../lib/errors.js';

-const ppgErrorToStatus: Record<string, number> = {
-  NOT_INITIALIZED: 503,
-  MANIFEST_LOCK: 503,
-  WORKTREE_NOT_FOUND: 404,
-  AGENT_NOT_FOUND: 404,
-};

 export default async function statusRoutes(
   fastify: FastifyInstance,
   options: StatusRouteOptions,
 ): Promise<void> {
   const { projectRoot, bearerToken } = options;

   fastify.addHook('onRequest', authenticate(bearerToken));

-  fastify.setErrorHandler((error, _request, reply) => {
-    if (error instanceof PpgError) {
-      const status = ppgErrorToStatus[error.code] ?? 500;
-      reply.code(status).send({ error: error.message, code: error.code });
-      return;
-    }
-    reply.code(500).send({ error: 'Internal server error' });
-  });
+  registerErrorHandler(fastify);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/status.ts` around lines 49 - 71, The route-level error
handling in statusRoutes duplicates ppgErrorToStatus and returns a different
response shape than the centralized handler; replace the custom handler by
registering the shared handler from error-handler.ts: call
registerErrorHandler(fastify) inside statusRoutes (and remove the
ppgErrorToStatus mapping and setErrorHandler block) so PpgError codes and
response shape ({ error: { code, message } }) are consistent across the app; if
there is an intentional divergence, document the reason in a comment above
statusRoutes and ensure the mapping and response shape exactly match the
centralized registerErrorHandler implementation.
ios/PPGMobile/PPGMobile/Models/DashboardModels.swift (1)

91-107: Consider caching ISO8601DateFormatter to avoid repeated allocations.

Creating a new ISO8601DateFormatter() instance on every property access is inefficient, especially when rendering lists of agents or worktrees. The formatter is thread-safe and can be shared.

♻️ Suggested refactor to cache the date formatter
+// MARK: - Shared Date Formatter
+
+private let iso8601Formatter: ISO8601DateFormatter = {
+    let formatter = ISO8601DateFormatter()
+    return formatter
+}()
+
 // MARK: - AgentEntry UI Extensions

 extension AgentEntry {
     var startDate: Date? {
-        ISO8601DateFormatter().date(from: startedAt)
+        iso8601Formatter.date(from: startedAt)
     }
 }

 // MARK: - WorktreeEntry UI Extensions

 extension WorktreeEntry {
     var createdDate: Date? {
-        ISO8601DateFormatter().date(from: createdAt)
+        iso8601Formatter.date(from: createdAt)
     }

     var mergedDate: Date? {
-        mergedAt.flatMap { ISO8601DateFormatter().date(from: $0) }
+        mergedAt.flatMap { iso8601Formatter.date(from: $0) }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Models/DashboardModels.swift` around lines 91 - 107,
Cache a shared ISO8601DateFormatter instead of allocating one on every property
access: add a single shared formatter (e.g., a static let iso8601Formatter =
ISO8601DateFormatter() somewhere accessible) and update AgentEntry.startDate,
WorktreeEntry.createdDate, and WorktreeEntry.mergedDate to use that shared
formatter rather than creating new ISO8601DateFormatter() instances each call;
ensure the formatter is declared in a thread-safe static context (for example as
a static property on an extension or a small DateFormatter helper type) so all
three computed properties reference it.
src/commands/serve.ts (2)

66-95: Redundant manifest read.

requireManifest (line 68) already reads and returns the manifest, but it's called without using the return value. Then readManifest is called again at line 93 to get the sessionName. This results in reading the same file twice.

♻️ Proposed fix to eliminate redundant read
 export async function serveStartCommand(options: ServeStartOptions): Promise<void> {
   const projectRoot = await getRepoRoot();
-  await requireManifest(projectRoot);
+  const manifest = await requireManifest(projectRoot);

   const port = options.port!;
   const host = options.host!;
   ...
   // Start daemon in a tmux window
-  const manifest = await readManifest(projectRoot);
   const sessionName = manifest.sessionName;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/serve.ts` around lines 66 - 95, In serveStartCommand, avoid the
double manifest read: change the call to requireManifest(projectRoot) so you
capture and reuse its returned manifest (e.g., const manifest = await
requireManifest(projectRoot)) and then remove the subsequent
readManifest(projectRoot) call; use manifest.sessionName where sessionName is
needed (keep getRepoRoot, tmux.ensureSession, and other logic unchanged).

6-6: Unused import: runServeDaemon is imported but never used.

The runServeDaemon function is imported but serveDaemonCommand (line 197-200) calls startServer directly instead. This import appears to be dead code.

🧹 Proposed fix to remove unused import
-import { runServeDaemon, isServeRunning, getServePid, getServeInfo, readServeLog } from '../core/serve.js';
+import { isServeRunning, getServePid, getServeInfo, readServeLog } from '../core/serve.js';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/serve.ts` at line 6, Remove the dead import runServeDaemon from
the import list in serve.ts since serveDaemonCommand calls startServer directly;
update the import statement that currently includes runServeDaemon (from
'../core/serve.js') to only import the actually used symbols (isServeRunning,
getServePid, getServeInfo, readServeLog) and run a quick lint/TS check to ensure
no other references to runServeDaemon remain.
PPG CLI/PPG CLI/WebSocketManager.swift (1)

109-118: Potential race condition in deinit.

The deinit accesses and modifies instance properties (intentionalDisconnect, pingTimer, task, session) without synchronization on the queue. While deinit is called when there's only one reference holder, there could still be in-flight async operations on the queue that reference these properties.

Consider wrapping the cleanup in a synchronous queue operation, or ensure all async work is cancelled first.

♻️ Safer deinit with queue synchronization
 deinit {
-    // Synchronous cleanup — safe because we're the last reference holder.
-    intentionalDisconnect = true
-    pingTimer?.cancel()
-    pingTimer = nil
-    task?.cancel(with: .goingAway, reason: nil)
-    task = nil
-    session?.invalidateAndCancel()
-    session = nil
+    // Synchronous cleanup on queue to avoid races with in-flight operations.
+    queue.sync {
+        intentionalDisconnect = true
+        pingTimer?.cancel()
+        pingTimer = nil
+        task?.cancel(with: .goingAway, reason: nil)
+        task = nil
+        session?.invalidateAndCancel()
+        session = nil
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PPG` CLI/PPG CLI/WebSocketManager.swift around lines 109 - 118, deinit is
mutating instance state off the protected queue which can race with in-flight
async operations; wrap the entire cleanup in a synchronous call to the object's
serial queue (e.g., perform a queue.sync { ... } block) and move the mutations
there so intentionalDisconnect, pingTimer, task and session are all
read/modified on the same queue; inside that sync block first set
intentionalDisconnect = true, cancel and nil the pingTimer, cancel the task with
.goingAway and nil it, then invalidateAndCancel the session and nil it, ensuring
all async work is cancelled or observes intentionalDisconnect before returning.
src/commands/spawn.test.ts (1)

20-31: Consider using the shared makeAgent fixture from src/test-fixtures.ts.

A makeAgent helper already exists in src/test-fixtures.ts with the same structure. Using it would reduce duplication and keep test fixtures consistent.

♻️ Proposed refactor
-import type { AgentEntry } from '../types/manifest.js';
+import { makeAgent } from '../test-fixtures.js';

 // ... (remove local makeAgent function)

-function makeAgent(id: string, target: string): AgentEntry {
-  return {
-    id,
-    name: 'claude',
-    agentType: 'claude',
-    status: 'running',
-    tmuxTarget: target,
-    prompt: 'Do work',
-    startedAt: '2026-02-27T00:00:00.000Z',
-    sessionId: 'session-1',
-  };
-}

 function makeResult(overrides?: Partial<SpawnResult>): SpawnResult {
   return {
     worktree: { /* ... */ },
-    agents: [makeAgent('ag-1', 'ppg-test:1')],
+    agents: [makeAgent({ id: 'ag-1', tmuxTarget: 'ppg-test:1', sessionId: 'session-1' })],
     ...overrides,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/spawn.test.ts` around lines 20 - 31, Replace the local makeAgent
helper in spawn.test.ts with the shared fixture: remove the local function named
makeAgent and import the exported makeAgent fixture from the test-fixtures
module, then use that shared makeAgent wherever the local helper was used;
ensure the test still supplies the same id and target args to makeAgent and that
any required types (e.g., AgentEntry) remain satisfied by the shared fixture.
ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift (1)

34-43: Hardcoded "main" branch may not suit all repositories.

Line 36 always inserts "main" as an available base branch. Repositories using "master" or other default branch names won't have their actual default branch listed unless it appears in existing worktrees.

Consider fetching the repository's default branch from the server or manifest instead of hardcoding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift` around lines 34 - 43,
The availableBranches computed property currently inserts a hardcoded "main";
change it to use the repository's real default branch from the manifest or app
state instead: in availableBranches (and where you reference
appState.manifestStore.manifest and manifest.worktrees / wt.baseBranch) look up
manifest.defaultBranch (or appState.repository.defaultBranch) and insert that
value if present, falling back to "main" only if no default branch is available;
if the manifest does not include a default branch, call the existing
server/manifest API to obtain the repo default and use that value instead of the
literal "main".
src/core/operations/restart.test.ts (1)

86-96: Consider using shared makeManifest from test-fixtures.

This local makeManifest helper duplicates the one already exported from src/test-fixtures.ts. Using the shared fixture would reduce duplication and ensure consistency across tests.

♻️ Suggested change
-import { makeAgent, makeWorktree } from '../../test-fixtures.js';
+import { makeAgent, makeWorktree, makeManifest } from '../../test-fixtures.js';
 import type { Manifest } from '../../types/manifest.js';
 import type { AgentStatus } from '../../types/manifest.js';

 // ... mocks ...

-const PROJECT_ROOT = '/tmp/project';
-
-function makeManifest(overrides?: Partial<Manifest>): Manifest {
-  return {
-    version: 1,
-    projectRoot: PROJECT_ROOT,
-    sessionName: 'ppg',
-    worktrees: {},
-    createdAt: '2026-01-01T00:00:00.000Z',
-    updatedAt: '2026-01-01T00:00:00.000Z',
-    ...overrides,
-  };
-}
+const PROJECT_ROOT = '/tmp/project';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/operations/restart.test.ts` around lines 86 - 96, The local
makeManifest function in restart.test.ts duplicates the shared helper; remove
the local makeManifest and import the shared makeManifest exported from
src/test-fixtures.ts, then update any references in the file to use that
imported helper (ensuring the import brings in the correct Manifest type or
adjust the call sites if needed) so tests reuse the canonical fixture and avoid
duplication.
src/core/agent.ts (1)

247-327: Refactor performRestart to use restartAgent to eliminate code duplication.

performRestart in src/core/operations/restart.ts duplicates the core agent restart logic that restartAgent in src/core/agent.ts now provides. Both kill the old agent, create a tmux window, render the prompt template, spawn the new agent, and update the manifest identically.

Refactor performRestart to prepare its parameters (projectRoot, worktree, oldAgent, sessionName, agentConfig, promptText) and delegate to restartAgent for the core restart operation. This preserves the separation of concerns—orchestration and parameter loading in the operations layer, core agent primitives in the agent layer—while eliminating duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/agent.ts` around lines 247 - 327, The performRestart function
duplicates the restart logic now centralized in restartAgent; change
performRestart (in src/core/operations/restart.ts) so it gathers the same
parameters (projectRoot, worktree, oldAgent, sessionName, agentConfig,
promptText) and calls restartAgent({ projectRoot, worktree, oldAgent,
sessionName, agentConfig, promptText }) to perform the
kill/create-window/render/spawn/update-manifest flow, then return or adapt
restartAgent's RestartAgentResult as performRestart's result—remove the
duplicated logic and any local calls to killAgent, tmux.createWindow,
renderTemplate, spawnAgent, and updateManifest in performRestart so restartAgent
owns the core behavior.
windows/PPGDesktop/App.xaml.cs (1)

47-59: Unobserved exceptions from fire-and-forget async startup.

StartServicesAsync() is called with discard (_ =), meaning any exceptions will be unobserved. If WebSocketService.ConnectAsync throws (e.g., network unreachable, invalid URL), the exception may silently terminate or cause unpredictable behavior depending on .NET's unobserved task exception settings.

Consider wrapping the call or handling exceptions:

♻️ Proposed fix to handle startup exceptions
-        // Start WebSocket connection
-        _ = StartServicesAsync();
+        // Start WebSocket connection (fire-and-forget with error handling)
+        _ = StartServicesAsync().ContinueWith(t =>
+        {
+            if (t.IsFaulted)
+            {
+                // Log or handle startup failure gracefully
+                System.Diagnostics.Debug.WriteLine($"WebSocket startup failed: {t.Exception?.InnerException?.Message}");
+            }
+        }, TaskScheduler.Default);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@windows/PPGDesktop/App.xaml.cs` around lines 47 - 59, StartServicesAsync is
invoked fire-and-forget using "_ = StartServicesAsync();" so exceptions from
WebSocketService.ConnectAsync could go unobserved; modify this by handling
exceptions either at the call site or inside StartServicesAsync: wrap the await
ws.ConnectAsync(settings.ServerUrl, settings.Token) in a try/catch and log the
exception (use your app logger/Services.GetRequiredService<...Logger>() or
similar), or capture the Task returned by StartServicesAsync and attach a
continuation (Task.ContinueWith) to log any faults; ensure StartServicesAsync or
its caller does not let exceptions escape unobserved.
ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift (1)

10-11: showQRError alert path appears unreachable from this view.

Line 10 defines the state and Lines 52-56 render the alert, but nothing in this file sets showQRError = true. Either wire an invalid-scan callback from QRScannerView or remove the dead state/alert path.

Also applies to: 52-56

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift` around lines 10 -
11, The SettingsView declares `@State` private var showQRError and renders an
alert for it but never sets it, making the alert unreachable; either remove the
unused state/alert code or wire an invalid-scan callback from the QRScannerView
to toggle it. To fix, locate SettingsView and either (A) remove showQRError and
the .alert(...) block, or (B) add a parameter/closure on QRScannerView (e.g.,
onInvalidScan: () -> Void) and pass a closure that sets showQRError = true when
an invalid QR is detected so the alert path becomes reachable.
src/server/routes/worktrees.test.ts (1)

69-75: Close Fastify instances after each test to prevent open-handle buildup.

Line 69 creates a fresh app for each test, but the suite never closes them. This can leave hanging handles and make test runs flaky.

♻️ Proposed cleanup pattern
-import { describe, test, expect, vi, beforeEach } from 'vitest';
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
 ...
 const PROJECT_ROOT = '/tmp/project';
+const createdApps: FastifyInstance[] = [];

 async function buildApp(): Promise<FastifyInstance> {
   const app = Fastify();
+  createdApps.push(app);
   app.decorate('projectRoot', PROJECT_ROOT);
   await app.register(worktreeRoutes, { prefix: '/api' });
   await app.ready();
   return app;
 }

 describe('worktreeRoutes', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockManifest.worktrees = {};
   });
+
+  afterEach(async () => {
+    await Promise.all(createdApps.splice(0).map((app) => app.close()));
+  });

Also applies to: 77-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/worktrees.test.ts` around lines 69 - 75, Tests create
Fastify instances via buildApp (which returns a FastifyInstance) but never close
them, causing open-handle buildup; update the test suite to ensure each created
app is closed after use by calling app.close() (or await app.close()) in an
afterEach or finally block where buildApp() is used (references: buildApp,
FastifyInstance, app.ready, app.close) so every test that awaits buildApp()
cleans up the Fastify instance to prevent flaky tests.
src/commands/kill.ts (1)

23-23: Consider extracting the inline type import for clarity.

The inline type import works but is unusual. For better readability, consider importing PaneInfo at the top of the file.

♻️ Optional refactor
 import { performKill, type KillResult } from '../core/operations/kill.js';
 import { getCurrentPaneId } from '../core/self.js';
 import { readManifest } from '../core/manifest.js';
 import { getRepoRoot } from '../core/worktree.js';
-import { listSessionPanes } from '../core/tmux.js';
+import { listSessionPanes, type PaneInfo } from '../core/tmux.js';
 import { output, success, info, warn } from '../lib/output.js';

Then on line 23:

-  let paneMap: Map<string, import('../core/tmux.js').PaneInfo> | undefined;
+  let paneMap: Map<string, PaneInfo> | undefined;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/kill.ts` at line 23, Extract the inline type import by adding a
top-level type import for PaneInfo and then use that named type for the paneMap
declaration; specifically, import { PaneInfo } from the module currently
referenced inline (replace the inline import in the declaration of paneMap) and
change the variable declaration to use Map<string, PaneInfo> | undefined so the
code uses the named PaneInfo type instead of the inline import.
windows/PPGDesktop/MainWindow.xaml (2)

88-98: Consider using StaticResource for status indicator colors.

The connection indicator uses hard-coded hex colors (#9ca3af for disconnected, #34d399 for connected) while other parts of the UI use StaticResource brushes. For theme consistency and maintainability, consider defining these as named resources.

♻️ Optional: Define status colors as resources

Add to a resource dictionary:

<SolidColorBrush x:Key="StatusDisconnectedBrush" Color="#9ca3af" />
<SolidColorBrush x:Key="StatusConnectedBrush" Color="#34d399" />

Then update the style:

<Setter Property="Fill" Value="{StaticResource StatusDisconnectedBrush}" />
...
<Setter Property="Fill" Value="{StaticResource StatusConnectedBrush}" />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@windows/PPGDesktop/MainWindow.xaml` around lines 88 - 98, Replace the
hard-coded hex colors in the Ellipse.Style (the Style targeting Ellipse with the
Setter Property="Fill" and the DataTrigger binding to ConnectionState) with
StaticResource brushes: define SolidColorBrush resources named e.g.
StatusDisconnectedBrush and StatusConnectedBrush in your ResourceDictionary,
then change the base Setter Fill to "{StaticResource StatusDisconnectedBrush}"
and the DataTrigger Setter Fill to "{StaticResource StatusConnectedBrush}" so
the indicator follows theme resources.

124-131: Hard-coded hover/press colors in ToolbarButton style.

Similar to the status indicator, the button hover (#3f3f46) and press (#52525b) colors are hard-coded. If theme support is planned, these should be extracted to resources.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@windows/PPGDesktop/MainWindow.xaml` around lines 124 - 131, The ToolbarButton
ControlTemplate currently hard-codes hover and pressed colors in the
ControlTemplate.Triggers (Trigger Property="IsMouseOver" Value="True" and
Trigger Property="IsPressed" Value="True"); change these Setters to use
themeable resource keys (e.g., DynamicResource or StaticResource like
ToolbarButtonHoverBrush and ToolbarButtonPressedBrush) instead of literal hex
values, and add those brush resources to the resource dictionary (or existing
theme resource files) so themes can override them.
src/server/routes/spawn.ts (1)

64-73: Status mapping could be more comprehensive.

The statusForPpgError function only handles INVALID_ARGS and NOT_INITIALIZED. Other error codes like TMUX_NOT_FOUND or TEMPLATE_NOT_FOUND (mentioned in the AI summary) will fall through to 500, which may not be the most appropriate status. Consider aligning with mapErrorToStatus in agents.ts.

♻️ Suggested expansion
 function statusForPpgError(code: string): number {
   switch (code) {
     case 'INVALID_ARGS':
       return 400;
     case 'NOT_INITIALIZED':
-      return 409;
+      return 503;
+    case 'TMUX_NOT_FOUND':
+      return 503;
+    case 'TEMPLATE_NOT_FOUND':
+      return 404;
     default:
       return 500;
   }
 }

Note: NOT_INITIALIZED returning 409 (Conflict) is debatable; 503 (Service Unavailable) may be more semantically correct, as used in agents.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/spawn.ts` around lines 64 - 73, statusForPpgError currently
only maps 'INVALID_ARGS' and 'NOT_INITIALIZED' to HTTP statuses; expand this
function (statusForPpgError) to include the other PPG error codes referenced in
the repo (for example 'TMUX_NOT_FOUND' and 'TEMPLATE_NOT_FOUND' -> 404) and
align the semantics with mapErrorToStatus in agents.ts (e.g., change or add
handling for 'NOT_INITIALIZED' to return 503 instead of 409 if you want
consistency). Update statusForPpgError's switch to explicitly map all known
error codes used by the ppg layer to the corresponding HTTP codes used in
mapErrorToStatus and keep a default 500 fallback. Ensure you reference and
mirror the mappings defined in agents.ts so the two functions remain consistent.
src/server/routes/agents.ts (1)

211-288: Potential duplication with performRestart in src/core/operations/restart.ts.

The restart route (lines 230-283) manually implements prompt resolution, config loading, and calls restartAgent, which duplicates logic in performRestart from src/core/operations/restart.ts. Consider delegating to performRestart for consistency, as the kill command now delegates to performKill.

♻️ Suggested refactor to use performRestart
+import { performRestart } from '../../core/operations/restart.js';
 
 // In the restart route handler:
-      const manifest = await requireManifest(projectRoot);
-      const config = await loadConfig(projectRoot);
-
-      const found = findAgent(manifest, id);
-      if (!found) throw new AgentNotFoundError(id);
-
-      const { worktree: wt, agent: oldAgent } = found;
-
-      // Read original prompt or use override
-      let promptText: string;
-      if (promptOverride) {
-        promptText = promptOverride;
-      } else {
-        const pFile = agentPromptFile(projectRoot, oldAgent.id);
-        try {
-          promptText = await fs.readFile(pFile, 'utf-8');
-        } catch {
-          throw new PpgError(
-            `Could not read original prompt for agent ${oldAgent.id}. Provide a prompt in the request body.`,
-            'PROMPT_NOT_FOUND',
-          );
-        }
-      }
-
-      const agentConfig = resolveAgentConfig(config, agentType ?? oldAgent.agentType);
-
-      const result = await restartAgent({
-        projectRoot,
-        agentId: oldAgent.id,
-        worktree: wt,
-        oldAgent,
-        sessionName: manifest.sessionName,
-        agentConfig,
-        promptText,
-      });
+      const result = await performRestart({
+        agentRef: id,
+        prompt: promptOverride,
+        agentType,
+        projectRoot,
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/agents.ts` around lines 211 - 288, The route handler
duplicates restart logic; replace the manual manifest/config loading, prompt
resolution, and call to restartAgent with a call to performRestart to centralize
behavior (similar to how performKill is used). Locate the POST
/agents/:id/restart handler and remove the inline use of requireManifest,
loadConfig, prompt file reading (agentPromptFile), resolveAgentConfig and
restartAgent; instead call performRestart with the same inputs (projectRoot,
agent id, optional prompt override, optional agent type) and map its result into
the existing response shape, preserving AgentNotFoundError and PpgError handling
and using existing helpers like findAgent only if needed for pre-validation.
Ensure performRestart is imported from src/core/operations/restart.ts and return
the newAgent fields from performRestart’s output.
src/server/ws/watcher.ts (2)

86-91: Same silent error pattern on directory watcher.

Line 88 also swallows errors. For consistency, consider forwarding to onError.

🔧 Proposed fix
-      dirWatcher.on('error', () => {});
+      dirWatcher.on('error', (err) => onError?.(err));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/ws/watcher.ts` around lines 86 - 91, The directory watcher
currently swallows errors in dirWatcher.on('error', () => {}); — change it to
forward the error to the existing error handler (call onError with the error) so
directory watcher failures are surfaced consistently; locate dirWatcher in
watcher.ts and replace the empty error handler with a callback that passes the
error to onError (or the appropriate error-dispatching function used elsewhere
in this file).

65-73: Swallowed fs.watch errors may hide issues.

The error handler on line 68 silently ignores all watcher errors. Consider forwarding these to the onError callback to aid debugging, especially for permission issues or watch limit exhaustion.

🔧 Proposed fix
 function watchManifestFile(): boolean {
   try {
     fileWatcher = fs.watch(mPath, onFsChange);
-    fileWatcher.on('error', () => {});
+    fileWatcher.on('error', (err) => onError?.(err));
     return true;
   } catch {
     return false;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/ws/watcher.ts` around lines 65 - 73, watchManifestFile currently
attaches an 'error' handler to fileWatcher that swallows errors; update the
handler on the fileWatcher created in watchManifestFile (and keep the try/catch)
to accept the error parameter and call the module's onError callback (or
propagate it) instead of ignoring it so permission/watch-limit and other
fs.watch errors are surfaced; reference fileWatcher, watchManifestFile,
onFsChange and onError when making the change.
ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift (3)

26-45: Silent error handling may leave users without feedback.

Lines 28 and 39 use try? which silently discards errors. If mergeWorktree or killWorktree fails, the user sees no error message and the UI simply refreshes to the unchanged state.

Consider propagating errors to appState.errorMessage for user feedback, similar to how AppState.killAgent handles errors (per the relevant snippet from AppState.swift).

🔧 Example fix for merge action
 Button("Squash Merge") {
     Task {
-        try? await appState.client.mergeWorktree(worktreeId: worktreeId)
-        await appState.manifestStore.refresh()
+        do {
+            try await appState.client.mergeWorktree(worktreeId: worktreeId)
+            await appState.manifestStore.refresh()
+        } catch {
+            appState.errorMessage = "Merge failed: \(error.localizedDescription)"
+        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift` around
lines 26 - 45, In WorktreeDetailView replace the silent `try? await` calls for
appState.client.mergeWorktree(worktreeId:) and
appState.client.killWorktree(worktreeId:) with explicit do/catch blocks: call
the async method inside a do, await appState.manifestStore.refresh() on success,
and in the catch set appState.errorMessage (or a localized-friendly string) to
the caught error’s description so the user gets feedback; reference the Button
actions that currently call mergeWorktree and killWorktree and mirror the
error-handling pattern used by AppState.killAgent.

149-156: Same silent error pattern for PR creation.

Line 151 uses try? which discards PR creation errors. Consider the same error handling pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift` around
lines 149 - 156, The Task currently swallows errors from
appState.client.createPR by using try?, so change it to use do/catch: call try
await appState.client.createPR(worktreeId: worktreeId) inside a do block, await
appState.manifestStore.refresh() on success, and in the catch block log the
error and surface it to the user (e.g., via your existing appState error/alert
mechanism) so creation failures are not silently ignored; update the Button's
Task closure around createPR and manifestStore.refresh accordingly.

108-117: Same silent error pattern for agent restart.

Line 113 uses try? which discards restart errors. Consider the same error handling pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift` around
lines 108 - 117, The onRestart closure currently swallows errors with try? when
calling appState.client.restartAgent(agentId:), so change it to use the same
explicit error-handling pattern as onKill: wrap the restart call in a Task
containing a do { try await appState.client.restartAgent(agentId: agent.id);
await appState.manifestStore.refresh() } catch { await
appState.handleError(error) } (or, if you already have an
appState.restartAgent(agent.id) helper that handles errors, call that instead)
so restart failures are surfaced rather than silently discarded.
src/core/operations/spawn.test.ts (1)

21-32: Align tmux/git test doubles with the repo’s mock contract.

These tests fully replace worktree/tmux modules instead of mocking execa and partially mocking modules, which can hide integration regressions at the command boundary.

As per coding guidelines, src/**/*.test.ts: “Mock execa for tmux/git calls and use vi.mock with vi.importActual for partial mocks in tests”.

Also applies to: 47-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/operations/spawn.test.ts` around lines 21 - 32, Tests currently
fully replace the worktree/tmux modules which hides command-boundary
regressions; instead, switch to partial module mocks using vi.mock(..., async ()
=> ({ ...await vi.importActual('../worktree.js'), <overrideFns> })) for worktree
and tmux so real implementations stay intact except for the specific functions
you override (e.g., readManifest, updateManifest, resolveWorktree, getRepoRoot,
getCurrentBranch, createWorktree, adoptWorktree), and stop stubbing tmux/git
behavior directly—mock execa (the command runner) to simulate tmux/git CLI
results in spawn.test.ts so the tests exercise the module integration while
controlling external command outputs.
src/server/tls.ts (1)

335-348: Avoid duplicating pairing URL assembly logic.

buildPairingUrl here overlaps with src/commands/serve.ts (same protocol/query contract). Consolidating into one shared helper reduces drift risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/tls.ts` around lines 335 - 348, The pairing URL assembly
implemented by buildPairingUrl duplicates the same logic used in the serve
command; extract this logic into a single exported helper (preserve the
signature: buildPairingUrl(params: {host,string; port:number;
caFingerprint:string; token:string}): string) in a shared module and replace the
local implementation here and in the serve command with imports to that helper,
updating all callers to use the shared buildPairingUrl so the
ppg://connect?host=...&port=...&ca=...&token=... contract remains consistent.

Comment on lines +39 to +45
final class WebSocketManager: NSObject, @unchecked Sendable, URLSessionWebSocketDelegate {

// MARK: - Callbacks

var onStateChange: ((WebSocketConnectionState) -> Void)?
var onEvent: ((WebSocketEvent) -> Void)?
var onMessage: ((ServerMessage) -> Void)?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Inspect callback declarations/usages in WebSocketManager ==="
rg -n --type=swift -C2 '@unchecked Sendable|onStateChange|onEvent|onMessage|queue\.async|DispatchQueue\.main\.async' ios/PPGMobile/PPGMobile/Networking/WebSocketManager.swift

echo ""
echo "=== Find callback assignments in app code ==="
rg -n --type=swift -C2 '\.onStateChange\s*=|\.onEvent\s*=|\.onMessage\s*=' ios/PPGMobile

echo ""
echo "=== Check whether assignment sites are actor/queue constrained ==="
rg -n --type=swift -C3 '@MainActor|MainActor\.run|DispatchQueue\.main' ios/PPGMobile

Repository: 2witstudios/ppg-cli

Length of output: 13346


Synchronize callback storage on one executor (or actor-isolate this type).

With @unchecked Sendable, mutable callback vars are shared state accessed from different queues without synchronization. Callbacks are written on the main thread (within @MainActor contexts in AppState and TerminalView) but read on the internal queue (lines 177, 229, 237). While the reads capture to a local variable before invocation, the var itself is still subject to data races under concurrent access.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Networking/WebSocketManager.swift` around lines 39 -
45, The mutable callback vars onStateChange/onEvent/onMessage in
WebSocketManager are currently unsynchronized (class marked `@unchecked` Sendable)
and are written from MainActor contexts but read on the internal queue, risking
data races; fix by making callback storage exclusively owned by the internal
queue: make the vars private (e.g. private var onStateChange/onEvent/onMessage)
and provide setter methods (or public properties) that dispatch writes to the
internal queue (using queue.async/queue.sync as appropriate), leaving all reads
on the same queue so all access to those symbols is queue-synchronized;
alternatively, actor-isolate the whole type (remove `@unchecked` Sendable and
annotate WebSocketManager with `@MainActor`) if you want main-thread-only access
instead.

Comment on lines +101 to +114
private func addServer() {
guard let validatedPort = parsedPort else { return }
let connection = ServerConnection(
name: trimmedName.isEmpty ? "My Mac" : trimmedName,
host: trimmedHost,
port: validatedPort,
token: trimmedToken
)
appState.addConnection(connection)
Task {
await appState.connect(to: connection)
}
dismiss()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Dismiss called before connection completes — user may miss errors.

The view dismisses immediately after starting the connection task, so users won't see the errorMessage set by connect(to:) if the connection fails. Consider awaiting the connection result before dismissing, or showing errors via a toast/alert on the parent view.

💡 Proposed fix to await connection result
     private func addServer() {
         guard let validatedPort = parsedPort else { return }
         let connection = ServerConnection(
             name: trimmedName.isEmpty ? "My Mac" : trimmedName,
             host: trimmedHost,
             port: validatedPort,
             token: trimmedToken
         )
         appState.addConnection(connection)
         Task {
             await appState.connect(to: connection)
+            await MainActor.run { dismiss() }
         }
-        dismiss()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift` around lines 101
- 114, In addServer(), do not call dismiss() immediately after spawning the
connection Task; instead await the result of appState.connect(to:) and only
dismiss on success, otherwise keep the view visible and surface the failure
(e.g., via appState.errorMessage or an alert/toast). Concretely: after
appState.addConnection(connection), call await appState.connect(to: connection)
inside the Task, check the returned success/failure (or the error state) from
connect(to:) and call dismiss() only when connection succeeds; if it fails,
present the error via the existing errorMessage or trigger a UI alert so the
user can see and correct the issue. Ensure references to addServer(),
appState.addConnection(_:), and appState.connect(to:) are used to locate and
update the logic.

Comment on lines +42 to +46
.task { await viewModel.subscribe(agentId: agentId, appState: appState) }
.onDisappear {
if let ws = appState.wsManager {
viewModel.unsubscribe(agentId: agentId, wsManager: ws)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix subscription lifecycle when wsManager is nil to avoid stuck state and missed cleanup.

subscribe marks isSubscribed before confirming WS availability, and onDisappear skips unsubscribe when wsManager is already nil. That can leave the view model in a false “subscribed” state and bypass router cleanup.

💡 Proposed fix
 `@Observable`
 `@MainActor`
 final class TerminalViewModel {
     var output = ""
     var hasError = false
     private(set) var isSubscribed = false
 
     private static let maxOutputLength = 50_000
     private var subscriptionID: UUID?
+    private weak var subscribedWSManager: WebSocketManager?
 
     func subscribe(agentId: String, appState: AppState) async {
         guard !isSubscribed else { return }
-        isSubscribed = true
 
         // Fetch initial log content via REST
         do {
             let logs = try await appState.client.fetchAgentLogs(agentId: agentId, lines: 200)
             output = logs.output
             trimOutput()
         } catch {
             output = "Failed to load logs: \(error.localizedDescription)"
             hasError = true
         }
 
         // Subscribe to live WebSocket updates
-        guard let ws = appState.wsManager else { return }
+        guard let ws = appState.wsManager else {
+            isSubscribed = false
+            return
+        }
+        isSubscribed = true
+        subscribedWSManager = ws
         subscriptionID = TerminalMessageRouter.shared.addSubscriber(wsManager: ws) { [weak self] message in
             guard message.type == "terminal:output", message.agentId == agentId, let data = message.data else {
                 return
             }
             Task { `@MainActor` [weak self] in
                 guard let self else { return }
                 self.output += data
                 self.trimOutput()
             }
         }
         ws.subscribeTerminal(agentId: agentId)
     }
 
-    func unsubscribe(agentId: String, wsManager: WebSocketManager) {
+    func unsubscribe(agentId: String, wsManager: WebSocketManager? = nil) {
         guard isSubscribed else { return }
         isSubscribed = false
-        wsManager.unsubscribeTerminal(agentId: agentId)
+        let ws = wsManager ?? subscribedWSManager
+        ws?.unsubscribeTerminal(agentId: agentId)
         if let subscriptionID {
-            TerminalMessageRouter.shared.removeSubscriber(wsManager: wsManager, subscriberID: subscriptionID)
+            if let ws {
+                TerminalMessageRouter.shared.removeSubscriber(wsManager: ws, subscriberID: subscriptionID)
+            }
             self.subscriptionID = nil
         }
+        subscribedWSManager = nil
     }
 }
-        .onDisappear {
-            if let ws = appState.wsManager {
-                viewModel.unsubscribe(agentId: agentId, wsManager: ws)
-            }
-        }
+        .onDisappear {
+            viewModel.unsubscribe(agentId: agentId, wsManager: appState.wsManager)
+        }

Also applies to: 117-133, 146-153

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift` around lines 42 -
46, The subscribe flow sets viewModel.isSubscribed before confirming
appState.wsManager exists, and onDisappear skips unsubscribe when wsManager is
nil—fix by making subscribe(agentId:appState:) only mark isSubscribed after a
successful WS subscription (i.e., check/apply appState.wsManager != nil inside
subscribe and return early on failure), and make unsubscribe(agentId:wsManager:)
robust to a nil wsManager by performing local cleanup (clearing isSubscribed and
router/VM state) even when wsManager is nil; then change TerminalView’s
.onDisappear to always call viewModel.unsubscribe(agentId: agentId, wsManager:
appState.wsManager) (or call a new viewModel.forceUnsubscribe(agentId:) that
performs cleanup) so cleanup never gets skipped.

Comment on lines +291 to +306
describe('serveDaemonCommand', () => {
test('given initialized project, should call runServeDaemon with correct args', async () => {
await serveDaemonCommand({ port: 4000, host: '0.0.0.0' });

expect(requireManifest).toHaveBeenCalledWith('/fake/project');
expect(runServeDaemon).toHaveBeenCalledWith('/fake/project', 4000, '0.0.0.0');
});

test('given project not initialized, should throw', async () => {
const err = Object.assign(new Error('Not initialized'), { code: 'NOT_INITIALIZED' });
vi.mocked(requireManifest).mockRejectedValue(err);

await expect(serveDaemonCommand({ port: 3000, host: 'localhost' })).rejects.toThrow('Not initialized');
expect(runServeDaemon).not.toHaveBeenCalled();
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test expects runServeDaemon but implementation calls startServer.

The test at line 296 asserts runServeDaemon was called with specific arguments, but the actual implementation in serve.ts (line 200) calls startServer instead. This test will fail.

Either the implementation should call runServeDaemon, or the test should mock and verify startServer.

🐛 If the implementation is correct, update the test
 vi.mock('../core/serve.js', async (importOriginal) => {
   const actual = await importOriginal() as Record<string, unknown>;
   return {
     ...actual,
     isServeRunning: vi.fn(() => false),
     getServePid: vi.fn(() => null),
     getServeInfo: vi.fn(() => null),
     readServeLog: vi.fn(() => []),
-    runServeDaemon: vi.fn(),
   };
 });
+
+vi.mock('../server/index.js', () => ({
+  startServer: vi.fn(),
+}));

// Then in the test:
+const { startServer } = await import('../server/index.js');

 describe('serveDaemonCommand', () => {
   test('given initialized project, should call runServeDaemon with correct args', async () => {
     await serveDaemonCommand({ port: 4000, host: '0.0.0.0' });

     expect(requireManifest).toHaveBeenCalledWith('/fake/project');
-    expect(runServeDaemon).toHaveBeenCalledWith('/fake/project', 4000, '0.0.0.0');
+    expect(startServer).toHaveBeenCalledWith({
+      projectRoot: '/fake/project',
+      port: 4000,
+      host: '0.0.0.0',
+      token: undefined,
+    });
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/serve.test.ts` around lines 291 - 306, The test asserts
runServeDaemon was called but the implementation in serveDaemonCommand calls
startServer; update the test to mock/expect startServer (replace
expect(runServeDaemon)... with expect(startServer)... and ensure startServer is
properly imported/mocked), or alternatively change serveDaemonCommand to call
runServeDaemon instead of startServer so the test and implementation match;
locate serveDaemonCommand, requireManifest, runServeDaemon and startServer to
make the corresponding change.

Comment on lines +197 to +201
export async function serveDaemonCommand(options: { port: number; host: string; token?: string }): Promise<void> {
const projectRoot = await getRepoRoot();
await requireManifest(projectRoot);
await startServer({ projectRoot, port: options.port, host: options.host, token: options.token });
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if runServeDaemon and startServer exist
rg -n "export.*function startServer" --type ts
rg -n "export.*function runServeDaemon" --type ts

Repository: 2witstudios/ppg-cli

Length of output: 283


🏁 Script executed:

#!/bin/bash
# Check the actual implementation of serveDaemonCommand
sed -n '195,205p' src/commands/serve.ts

# Check what the test expects
rg -n -A 10 "expect.*runServeDaemon\|expect.*startServer" --type ts src/commands/serve.test.ts | head -30

Repository: 2witstudios/ppg-cli

Length of output: 363


🏁 Script executed:

#!/bin/bash
# Find and check the test file
fd "serve.test.ts" --type f

# Check the test around line 296
if [ -f "src/commands/serve.test.ts" ]; then
  sed -n '290,310p' src/commands/serve.test.ts
fi

Repository: 2witstudios/ppg-cli

Length of output: 1002


Implementation/test mismatch: serveDaemonCommand calls startServer, but the test expects runServeDaemon.

The function at line 200 calls startServer, but the test at src/commands/serve.test.ts line 296 asserts that runServeDaemon was called. One of these must be corrected to match the intended implementation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/serve.ts` around lines 197 - 201, The implementation calls
startServer but tests expect runServeDaemon; update serveDaemonCommand to call
runServeDaemon with the same arguments (projectRoot, port, host, token) instead
of startServer and ensure runServeDaemon is imported/available where
serveDaemonCommand is defined so the function signature and behavior match the
test's expectation.

Comment on lines +141 to +144
const token = url.searchParams.get('token');
if (!token) {
rejectUpgrade(socket, 'HTTP/1.1 401 Unauthorized');
return;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid query-string bearer tokens for WebSocket auth.

Line 141 accepts token from the URL query, which is commonly captured in logs and intermediaries. Prefer header/subprotocol or short-lived one-time upgrade tokens, with query-token support only as a compatibility fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/ws/handler.ts` around lines 141 - 144, The handler currently reads
bearer tokens from the URL query (url.searchParams.get('token')) which is
insecure; change the authentication order in the WebSocket upgrade handling (in
the same handler function in src/server/ws/handler.ts) to first look for an
Authorization header (or Sec-WebSocket-Protocol subprotocol if your client uses
that) for the bearer token, validate that, and only fall back to
url.searchParams.get('token') if no header/subprotocol token is present and the
token is known to be short-lived/one-time; when falling back emit a
deprecation/warning log line so usage is visible. Keep the rejection behavior
using rejectUpgrade unchanged when no token is found or validation fails.

Comment on lines +86 to +99
export class TerminalStreamer {
private streams = new Map<string, AgentStream>();
private nextSubscriberId = 1;
private readonly pollIntervalMs: number;
/** Injectable capture function — defaults to tmux capturePane. */
private readonly capture: (target: string, lines?: number) => Promise<string>;

constructor(options?: {
pollIntervalMs?: number;
capture?: (target: string, lines?: number) => Promise<string>;
}) {
this.pollIntervalMs = options?.pollIntervalMs ?? POLL_INTERVAL_MS;
this.capture = options?.capture ?? capturePane;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Refactor TerminalStreamer away from class-based architecture.

This file is under src/**/*.ts, where repository rules require functional style and disallow classes except PpgError.

As per coding guidelines, "Use functional style: pure functions, composition, const declarations, destructuring, and no classes except PpgError."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/ws/terminal.ts` around lines 86 - 99, The TerminalStreamer class
violates the repository rule against classes; refactor it into a functional
module exposing equivalent functions and state held in const closures: replace
the class TerminalStreamer with a factory function (e.g.,
createTerminalStreamer) that returns an API object with methods corresponding to
the class methods and uses internal const-closed variables for streams
(Map<string,AgentStream>), nextSubscriberId, pollIntervalMs (defaulting to
POLL_INTERVAL_MS), and capture (defaulting to capturePane). Ensure all usages of
the constructor and instance methods are updated to call
createTerminalStreamer(...) and its returned methods, preserve types for
AgentStream and the capture signature, and export only the factory and its API
so no class remains except PpgError.

Comment on lines +179 to +183
private scheduleNextPoll(agentId: string, stream: AgentStream): void {
stream.timer = setTimeout(() => {
void this.poll(agentId, stream);
}, this.pollIntervalMs);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Protect against stale in-flight polls deleting a newer stream.

A previous poll() can outlive unsubscribe/re-subscribe cycles for the same agentId; current catch cleanup can remove the newly created stream entry.

🛠️ Suggested guard to keep cleanup scoped to the active stream instance
   private async poll(agentId: string, stream: AgentStream): Promise<void> {
+    if (this.streams.get(agentId) !== stream) return;
     try {
       const raw = await this.capture(stream.tmuxTarget);
       const currentLines = raw.split('\n');
@@
-      if (stream.subscribers.size > 0) {
+      if (this.streams.get(agentId) === stream && stream.subscribers.size > 0) {
         this.scheduleNextPoll(agentId, stream);
       }
     } catch (err) {
+      if (this.streams.get(agentId) !== stream) return;
       // Pane gone / tmux error — notify subscribers and clean up
       const errorMsg = JSON.stringify({
         type: 'terminal:error',
         agentId,
         error: 'Pane no longer available',
       } satisfies TerminalError);
@@
       stream.timer = null;
       stream.subscribers.clear();
       this.streams.delete(agentId);
     }
   }

Also applies to: 214-238

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/ws/terminal.ts` around lines 179 - 183, The scheduled poll can
outlive unsubscribe/re-subscribe and accidentally clean up a new stream; update
poll-related cleanup logic (used by scheduleNextPoll, poll, and the similar
block around lines 214-238) to guard against deleting the active stream by
comparing the currently stored stream against the captured instance before
modifying or deleting it (e.g., check this.streams.get(agentId) === stream) and
only clear timers or remove the entry when the stored reference matches the
captured AgentStream instance.

Comment on lines +187 to +188
const raw = await this.capture(stream.tmuxTarget);
const currentLines = raw.split('\n');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bound capture size; full-pane polling every tick is unbounded.

Polling currently captures the entire pane buffer each cycle. Under long-running sessions this creates avoidable CPU/IO overhead.

⚙️ Suggested bounded capture
 const POLL_INTERVAL_MS = 500;
+const CAPTURE_LINES = 300;
@@
-      const raw = await this.capture(stream.tmuxTarget);
+      const raw = await this.capture(stream.tmuxTarget, CAPTURE_LINES);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/ws/terminal.ts` around lines 187 - 188, The polling currently
calls this.capture(stream.tmuxTarget) and splits the entire pane buffer, which
is unbounded; change to a bounded capture by limiting the number of lines read
each tick (e.g., capture only the last N lines) and/or by changing the capture
API call to accept start/end or maxLines (e.g., this.capture(stream.tmuxTarget,
{ maxLines: 200 })) and then operate on that slice instead of raw.split('\n');
alternatively implement incremental polling in the surrounding class (track
lastSeenLine or lastCaptureIndex on the stream object and request only newer
content) so Terminal polling methods (capture and the consumer that uses
currentLines) never pull the full pane buffer each cycle.

Comment on lines +18 to +23
// Bool with format parameter "TrueText|FalseText"
if (value is bool bVal && parameter is string format && format.Contains('|'))
{
var parts = format.Split('|');
return bVal ? parts[1] : parts[0];
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

"TrueText|FalseText" mapping is reversed in Convert.

At Line 22, true returns parts[1] and false returns parts[0], which is opposite of the documented format and will swap UI labels/states.

💡 Proposed fix
-            return bVal ? parts[1] : parts[0];
+            return bVal ? parts[0] : parts[1];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Bool with format parameter "TrueText|FalseText"
if (value is bool bVal && parameter is string format && format.Contains('|'))
{
var parts = format.Split('|');
return bVal ? parts[1] : parts[0];
}
// Bool with format parameter "TrueText|FalseText"
if (value is bool bVal && parameter is string format && format.Contains('|'))
{
var parts = format.Split('|');
return bVal ? parts[0] : parts[1];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@windows/PPGDesktop/Converters/InverseBoolConverter.cs` around lines 18 - 23,
The Convert implementation in InverseBoolConverter reverses the
"TrueText|FalseText" mapping: when parameter is a string format containing '|'
and split into parts, swap the returned indices so that true returns parts[0]
and false returns parts[1]; update the conditional branch (the block that checks
"if (value is bool bVal && parameter is string format && format.Contains('|'))"
and uses the parts variable) to return parts[0] for bVal == true and parts[1]
for false.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant