Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
121 commits
Select commit Hold shift + click to select a range
cf8f933
feat: extract merge operation to core/operations/merge.ts
2witstudios Feb 27, 2026
1940e2d
feat: add ppg serve command and Fastify server scaffold
2witstudios Feb 27, 2026
e5e771f
feat: extract restart operation to core/operations/restart.ts
2witstudios Feb 27, 2026
5fad5dd
feat: implement Keychain token storage
2witstudios Feb 27, 2026
34cafd5
feat: implement centralized error handler with PpgError dispatch table
2witstudios Feb 27, 2026
81d4680
feat: extract spawn operation to core/operations/spawn.ts
2witstudios Feb 27, 2026
dbab02f
feat: implement iOS data models for manifest, agent variants, and ser…
2witstudios Feb 27, 2026
9f0bbbc
feat: implement token auth with hashing and rate limiting
2witstudios Feb 27, 2026
ef03a1d
feat: create Xcode project with XcodeGen
2witstudios Feb 27, 2026
3653d9b
feat: implement serve daemon mode with stop/status subcommands
2witstudios Feb 27, 2026
e003595
feat: implement REST client PPGClient with typed errors and TLS pinning
2witstudios Feb 27, 2026
7e8375e
feat: implement WebSocket manager with auto-reconnect and keepalive
2witstudios Feb 27, 2026
2579eb0
feat: extract kill operation to core/operations/kill.ts
2witstudios Feb 27, 2026
c5d3ae4
feat: implement QR scanner with camera permissions and session lifecycle
2witstudios Feb 27, 2026
9843c5b
feat: implement Dashboard views for iOS app
2witstudios Feb 27, 2026
928e0a7
feat: implement manifest watcher with dual-source change detection
2witstudios Feb 27, 2026
5cf06b7
feat: implement Terminal views with WebSocket streaming and input bar
2witstudios Feb 27, 2026
3e30c6c
feat: implement read-only status routes for manifest data and live ag…
2witstudios Feb 27, 2026
6c7a92c
feat: implement iOS state management (AppState + ManifestStore)
2witstudios Feb 27, 2026
54625f8
feat: implement Spawn view for iOS app
2witstudios Feb 27, 2026
3794c39
feat: implement terminal streaming with diff algorithm
2witstudios Feb 27, 2026
0f9a606
feat: implement TLS certificate generation for HTTPS serving
2witstudios Feb 27, 2026
4d1bc14
feat: implement spawn route for HTTP server
2witstudios Feb 27, 2026
4cc651f
feat: implement config routes for agent definitions, templates, and p…
2witstudios Feb 27, 2026
5587293
feat: implement agent routes for REST API
2witstudios Feb 27, 2026
2bd6cb4
feat: implement WebSocket handler and event system
2witstudios Feb 27, 2026
8c1a2b8
feat: implement Settings views with server management and QR pairing
2witstudios Feb 27, 2026
615ff85
feat: implement worktree routes for merge, kill, and PR creation
2witstudios Feb 27, 2026
2781a53
feat: add QR code display on ppg serve startup
2witstudios Feb 27, 2026
2a8ef45
feat: add Fastify global setErrorHandler plugin
2witstudios Feb 27, 2026
dd9f355
fix: address code review findings for iOS state management
2witstudios Feb 27, 2026
f250caa
fix: address code review findings for error handler plugin
2witstudios Feb 27, 2026
936b3b0
fix: address review findings in PPGClient
2witstudios Feb 27, 2026
a9aab02
fix: address code review findings in SpawnView
2witstudios Feb 27, 2026
3ba691e
fix: address code review findings for Dashboard views
2witstudios Feb 27, 2026
90d24c3
fix: address code review findings for Settings views
2witstudios Feb 27, 2026
1fd6501
fix: address code review findings for serve daemon
2witstudios Feb 27, 2026
10c8b83
fix: address review findings — remove dead code and fix test issues
2witstudios Feb 27, 2026
2064261
fix: address code review findings for error handler
2witstudios Feb 27, 2026
a111a3a
fix: address code review findings for serve command
2witstudios Feb 27, 2026
6317193
fix: address review findings for restart operation extraction
2witstudios Feb 27, 2026
bc3ec2e
fix: address code review findings for XcodeGen setup
2witstudios Feb 27, 2026
f82e855
fix: address code review findings for TokenStorage
2witstudios Feb 27, 2026
cd5a489
fix: address code review findings for token auth
2witstudios Feb 27, 2026
8413043
fix: address code review findings for manifest watcher
2witstudios Feb 27, 2026
b155441
fix: address code review findings for Terminal views
2witstudios Feb 27, 2026
82e39cb
fix: address review findings for terminal streaming
2witstudios Feb 27, 2026
95ac012
fix: address code review findings for WebSocket handler
2witstudios Feb 27, 2026
97913e4
fix: address review findings for QR scanner
2witstudios Feb 27, 2026
ddaf28f
fix: address code review findings for serve command
2witstudios Feb 27, 2026
d0acdf4
fix: address code review findings for status routes
2witstudios Feb 27, 2026
6367900
refactor: extract shared prompt/metadata modules and fix test isolation
2witstudios Feb 27, 2026
772f4b3
fix: address code review findings for spawn extraction
2witstudios Feb 27, 2026
148dc81
refactor: extract shared spawn logic, harden route
2witstudios Feb 27, 2026
837e0f8
fix: address code review findings for TLS cert generation
2witstudios Feb 27, 2026
41e180b
fix: address code review findings for agent routes
2witstudios Feb 27, 2026
ba3234e
fix: address code review findings for iOS data models
2witstudios Feb 27, 2026
c94c746
fix: address code review findings for worktree routes
2witstudios Feb 27, 2026
9349e98
fix: address code review findings for kill operation extraction
2witstudios Feb 27, 2026
406e01f
fix: address review findings in WebSocketManager
2witstudios Feb 27, 2026
fbce7a3
test: fix manifest typing in spawn command test
2witstudios Feb 27, 2026
8087a17
test: fix manifest typing in spawn test
2witstudios Feb 27, 2026
b7de016
test: fix manifest typing in spawn mock
2witstudios Feb 27, 2026
d028f52
fix tests manifest typing in spawn command spec
2witstudios Feb 27, 2026
b7d9857
test: fix manifest typing in spawn test
2witstudios Feb 27, 2026
cb61615
test: fix spawn manifest typing for typecheck
2witstudios Feb 27, 2026
d435674
Fix manifest typing in spawn test
2witstudios Feb 27, 2026
76bd873
test: fix spawn manifest mock type inference
2witstudios Feb 27, 2026
d8f5d32
test: fix manifest typing in spawn command tests
2witstudios Feb 27, 2026
b31c7e9
Fix spawn test manifest typing for typecheck
2witstudios Feb 27, 2026
5e97158
Harden auth storage and fix strict test typing
2witstudios Feb 27, 2026
81129df
Fix QR parsing safety and scanner retry behavior
2witstudios Feb 27, 2026
2ee6544
Harden TLS cert reuse validation and fix spawn test typing
2witstudios Feb 27, 2026
9c95be1
test: fix manifest typing in spawn test
2witstudios Feb 27, 2026
ae11701
Fix serve runtime/version resolution and auth edge case
2witstudios Feb 27, 2026
a997446
test: type manifest fixture in spawn tests
2witstudios Feb 27, 2026
8e4159b
fix ws handler robustness and unblock strict typecheck
2witstudios Feb 27, 2026
d603078
Fix status route error handling and test typing
2witstudios Feb 27, 2026
15ef3b9
test: fix manifest mock typing in spawn tests
2witstudios Feb 27, 2026
c3b197d
Fix spawn test manifest typing for strict typecheck
2witstudios Feb 27, 2026
8001339
fix: harden dashboard store/actions and repair typecheck
2witstudios Feb 27, 2026
72b1224
Fix terminal WebSocket handler multiplexing
2witstudios Feb 27, 2026
7c1a319
Strengthen typing in status diff handler
2witstudios Feb 27, 2026
583e545
Fix websocket reconnect race and typecheck manifest typing
2witstudios Feb 27, 2026
52e61fd
test: fix strict manifest typing in spawn test
2witstudios Feb 27, 2026
6600b84
Fix typecheck and merge cleanup context handling
2witstudios Feb 27, 2026
75ec34a
fix review findings in settings views and spawn test typing
2witstudios Feb 27, 2026
dae3278
Fix spawn test manifest typing for typecheck
2witstudios Feb 27, 2026
13f3a47
Fix spawn route error mapping and preflight manifest check
2witstudios Feb 27, 2026
74e8924
Fix state persistence error handling and test manifest typing
2witstudios Feb 27, 2026
aefca80
Harden serve TLS setup and fix typecheck regression
2witstudios Feb 27, 2026
7bb076d
Merge remote-tracking branch 'origin/ppg/issue-60-restart-op' into pp…
2witstudios Feb 27, 2026
4f9b5c1
Merge remote-tracking branch 'origin/ppg/issue-61-kill-op' into ppg/i…
2witstudios Feb 27, 2026
9f6a487
Merge remote-tracking branch 'origin/ppg/issue-62-spawn-op' into ppg/…
2witstudios Feb 27, 2026
c2a4549
Merge remote-tracking branch 'origin/ppg/issue-63-serve-cmd' into ppg…
2witstudios Feb 27, 2026
3debe66
Merge remote-tracking branch 'origin/ppg/issue-64-token-auth' into pp…
2witstudios Feb 27, 2026
acaaa5b
Merge remote-tracking branch 'origin/ppg/issue-65-tls-certs' into ppg…
2witstudios Feb 27, 2026
8c3a518
Merge remote-tracking branch 'origin/ppg/issue-66-error-handler' into…
2witstudios Feb 27, 2026
1208891
Merge origin/ppg/issue-67-serve-daemon into ppg/integration
2witstudios Feb 27, 2026
a82d829
Merge remote-tracking branch 'origin/ppg/issue-76-xcodegen' into ppg/…
2witstudios Feb 27, 2026
6dd3220
Merge remote-tracking branch 'origin/ppg/issue-77-ios-models' into pp…
2witstudios Feb 27, 2026
284772f
Merge remote-tracking branch 'origin/ppg/issue-78-rest-client' into p…
2witstudios Feb 27, 2026
41fa34b
Merge origin/ppg/issue-79-ws-manager
2witstudios Feb 27, 2026
a6c3541
Merge origin/ppg/issue-80-keychain
2witstudios Feb 27, 2026
1a7caf0
Merge origin/ppg/issue-81-qr-scanner
2witstudios Feb 27, 2026
76801bb
Merge origin/ppg/issue-68-status-routes
2witstudios Feb 27, 2026
f8d54e0
Merge origin/ppg/issue-69-agent-routes
2witstudios Feb 27, 2026
1b345ec
Merge origin/ppg/issue-70-spawn-route
2witstudios Feb 27, 2026
bf07414
Merge origin/ppg/issue-71-worktree-routes
2witstudios Feb 27, 2026
9e8176d
Merge origin/ppg/issue-72-config-routes
2witstudios Feb 27, 2026
e19795d
Merge origin/ppg/issue-73-ws-handler
2witstudios Feb 27, 2026
e94defb
Merge origin/ppg/issue-74-manifest-watcher
2witstudios Feb 27, 2026
d1903e7
Merge origin/ppg/issue-75-terminal-stream
2witstudios Feb 27, 2026
71d4624
Merge origin/ppg/issue-82-ios-state
2witstudios Feb 27, 2026
92934a4
Merge origin/ppg/issue-83-dashboard
2witstudios Feb 27, 2026
b7dac40
Merge remote-tracking branch 'origin/ppg/issue-84-terminal-views' int…
2witstudios Feb 27, 2026
b460279
Merge origin/ppg/issue-85-spawn-view
2witstudios Feb 27, 2026
6dcdf5a
Merge origin/ppg/issue-86-settings-views
2witstudios Feb 27, 2026
4e7119f
Merge origin/ppg/issue-87-global-error
2witstudios Feb 27, 2026
cff05ae
Merge origin/ppg/issue-88-qr-display
2witstudios Feb 27, 2026
debe708
fix: resolve typecheck errors from integration merge
2witstudios Feb 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ DerivedData/
**/xcuserdata/
*.xcuserstate
*.profraw
*.xcodeproj
*.xcworkspace
383 changes: 383 additions & 0 deletions PPG CLI/PPG CLI/WebSocketManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,383 @@
import Foundation

// MARK: - Notifications

extension Notification.Name {
static let webSocketStateDidChange = Notification.Name("PPGWebSocketStateDidChange")
static let webSocketDidReceiveEvent = Notification.Name("PPGWebSocketDidReceiveEvent")
}

// MARK: - Connection State

nonisolated enum WebSocketConnectionState: Equatable, Sendable {
case disconnected
case connecting
case connected
case reconnecting(attempt: Int)

var isConnected: Bool { self == .connected }

var isReconnecting: Bool {
if case .reconnecting = self { return true }
return false
}
}

// MARK: - Server Events

nonisolated enum WebSocketEvent: Sendable {
case manifestUpdated(ManifestModel)
case agentStatusChanged(agentId: String, status: AgentStatus)
case worktreeStatusChanged(worktreeId: String, status: String)
case pong
case unknown(type: String, payload: String)
}

// MARK: - Client Commands

nonisolated enum WebSocketCommand: Sendable {
case subscribe(channel: String)
case unsubscribe(channel: String)
case terminalInput(agentId: String, data: String)

var jsonString: String {
let dict: [String: String]
switch self {
case .subscribe(let channel):
dict = ["type": "subscribe", "channel": channel]
case .unsubscribe(let channel):
dict = ["type": "unsubscribe", "channel": channel]
case .terminalInput(let agentId, let data):
dict = ["type": "terminal_input", "agentId": agentId, "data": data]
}
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]),
let str = String(data: data, encoding: .utf8) else {
return "{}"
}
return str
}
}

// MARK: - WebSocketManager

nonisolated class WebSocketManager: NSObject, @unchecked Sendable, URLSessionWebSocketDelegate {

/// Notification userInfo key for connection state.
static let stateUserInfoKey = "PPGWebSocketState"
/// Notification userInfo key for received event.
static let eventUserInfoKey = "PPGWebSocketEvent"

// MARK: - Configuration

private let url: URL
private let maxReconnectDelay: TimeInterval = 30.0
private let baseReconnectDelay: TimeInterval = 1.0
private let pingInterval: TimeInterval = 30.0

// MARK: - State

private let queue = DispatchQueue(label: "ppg.websocket-manager", qos: .utility)

/// Internal state — only read/write on `queue`.
private var _state: WebSocketConnectionState = .disconnected

/// Thread-safe read of the current connection state.
var state: WebSocketConnectionState {
queue.sync { _state }
}

private var session: URLSession?
private var task: URLSessionWebSocketTask?
private var pingTimer: DispatchSourceTimer?
private var reconnectWorkItem: DispatchWorkItem?
private var reconnectAttempt = 0
private var intentionalDisconnect = false
private var isHandlingConnectionLoss = false

// MARK: - Init

init(url: URL) {
self.url = url
super.init()
}

convenience init?(urlString: String) {
guard let url = URL(string: urlString) else { return nil }
self.init(url: url)
}

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
}

// MARK: - Public API

func connect() {
queue.async { [weak self] in
self?.doConnect()
}
}

func disconnect() {
queue.async { [weak self] in
self?.doDisconnect()
}
}

func send(_ command: WebSocketCommand) {
queue.async { [weak self] in
self?.doSend(command.jsonString)
}
}

// MARK: - Connection Lifecycle

private func doConnect() {
guard _state == .disconnected || _state.isReconnecting else { return }

intentionalDisconnect = false
isHandlingConnectionLoss = false
reconnectWorkItem?.cancel()
reconnectWorkItem = nil

if _state.isReconnecting {
// Already in reconnect flow — keep the attempt counter
} else {
reconnectAttempt = 0
setState(.connecting)
}

let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

let wsTask = session!.webSocketTask(with: url)
task = wsTask
wsTask.resume()
}

private func doDisconnect() {
intentionalDisconnect = true
isHandlingConnectionLoss = false
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
stopPingTimer()
task?.cancel(with: .goingAway, reason: nil)
task = nil
session?.invalidateAndCancel()
session = nil
reconnectAttempt = 0
setState(.disconnected)
}

/// Set state on the queue and post a notification on main.
private func setState(_ newState: WebSocketConnectionState) {
guard _state != newState else { return }
_state = newState
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .webSocketStateDidChange,
object: nil,
userInfo: [WebSocketManager.stateUserInfoKey: newState]
)
}
}

// MARK: - Sending

private func doSend(_ text: String) {
guard _state == .connected, let task = task else { return }
task.send(.string(text)) { error in
if let error = error {
NSLog("[WebSocketManager] send error: \(error.localizedDescription)")
}
}
}

// MARK: - Receiving

private func listenForMessages(for expectedTask: URLSessionWebSocketTask) {
expectedTask.receive { [weak self] result in
guard let self = self else { return }
self.queue.async {
guard self.task === expectedTask else { return }
switch result {
case .success(let message):
self.handleMessage(message)
self.listenForMessages(for: expectedTask)
case .failure(let error):
if !self.intentionalDisconnect {
NSLog("[WebSocketManager] receive error: \(error.localizedDescription)")
self.handleConnectionLost()
}
}
}
}
}

private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
let text: String
switch message {
case .string(let s):
text = s
case .data(let d):
guard let s = String(data: d, encoding: .utf8) else { return }
text = s
@unknown default:
return
}

guard let event = parseEvent(text) else { return }

DispatchQueue.main.async {
NotificationCenter.default.post(
name: .webSocketDidReceiveEvent,
object: nil,
userInfo: [WebSocketManager.eventUserInfoKey: event]
)
}
}

// MARK: - Event Parsing

/// Parse a JSON text message into a typed event. Internal for testability.
func parseEvent(_ text: String) -> WebSocketEvent? {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else {
return nil
}

switch type {
case "manifest_updated":
if let payloadData = json["manifest"],
let payloadJSON = try? JSONSerialization.data(withJSONObject: payloadData),
let manifest = try? JSONDecoder().decode(ManifestModel.self, from: payloadJSON) {
return .manifestUpdated(manifest)
}
return .unknown(type: type, payload: text)

case "agent_status_changed":
if let agentId = json["agentId"] as? String,
let statusRaw = json["status"] as? String,
let status = AgentStatus(rawValue: statusRaw) {
return .agentStatusChanged(agentId: agentId, status: status)
}
return .unknown(type: type, payload: text)

case "worktree_status_changed":
if let worktreeId = json["worktreeId"] as? String,
let status = json["status"] as? String {
return .worktreeStatusChanged(worktreeId: worktreeId, status: status)
}
return .unknown(type: type, payload: text)

case "pong":
return .pong

default:
return .unknown(type: type, payload: text)
}
}

// MARK: - Keepalive Ping

private func startPingTimer() {
stopPingTimer()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + pingInterval, repeating: pingInterval)
timer.setEventHandler { [weak self] in
self?.sendPing()
}
timer.resume()
pingTimer = timer
}

private func stopPingTimer() {
pingTimer?.cancel()
pingTimer = nil
}

private func sendPing() {
task?.sendPing { [weak self] error in
if let error = error {
NSLog("[WebSocketManager] ping error: \(error.localizedDescription)")
self?.queue.async { self?.handleConnectionLost() }
}
}
}

// MARK: - Reconnect

private func handleConnectionLost() {
guard !intentionalDisconnect else { return }
guard !isHandlingConnectionLoss else { return }
isHandlingConnectionLoss = true
stopPingTimer()
task?.cancel(with: .abnormalClosure, reason: nil)
task = nil
session?.invalidateAndCancel()
session = nil
scheduleReconnect()
}

private func scheduleReconnect() {
reconnectAttempt += 1
setState(.reconnecting(attempt: reconnectAttempt))

let delay = min(baseReconnectDelay * pow(2.0, Double(reconnectAttempt - 1)), maxReconnectDelay)
NSLog("[WebSocketManager] reconnecting in %.1fs (attempt %d)", delay, reconnectAttempt)

let workItem = DispatchWorkItem { [weak self] in
guard let self = self, !self.intentionalDisconnect else { return }
self.reconnectWorkItem = nil
self.doConnect()
}
reconnectWorkItem?.cancel()
reconnectWorkItem = workItem
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
}

// MARK: - URLSessionWebSocketDelegate

func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
queue.async { [weak self] in
guard let self = self else { return }
guard self.task === webSocketTask else { return }
self.reconnectAttempt = 0
self.isHandlingConnectionLoss = false
self.setState(.connected)
self.startPingTimer()
self.listenForMessages(for: webSocketTask)
}
}

func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
queue.async { [weak self] in
guard let self = self else { return }
guard self.task === webSocketTask else { return }
if self.intentionalDisconnect {
self.setState(.disconnected)
} else {
self.handleConnectionLost()
}
}
}

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
guard error != nil else { return }
queue.async { [weak self] in
guard let self = self, !self.intentionalDisconnect else { return }
guard let webSocketTask = task as? URLSessionWebSocketTask,
self.task === webSocketTask else { return }
self.handleConnectionLost()
}
}
}
Loading
Loading