Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 0 additions & 4 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ DerivedData/
.swiftpm/
Package.resolved

# Agent bridge build artifacts
agent-bridge/node_modules/
agent-bridge/dist/

# Xcode
*.pbxuser
*.mode1v3
Expand Down
31 changes: 31 additions & 0 deletions desktop/CHANGELOG.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
{
"releases": [
{
"version": "0.10.4",
"date": "2026-02-21",
"changes": [
"Fixed staging channel Option+click gesture being immediately overwritten by beta",
"Verification script no longer kills running Dev and Production app instances"
]
},
{
"version": "0.10.3",
"date": "2026-02-21",
"changes": [
"ACP bridge bundled in release builds: AI chat now works reliably without external dependencies",
"Update channel label shown in Settings so you can see if you're on Beta or Staging",
"Native binaries in ACP bridge node_modules are now properly code-signed"
]
},
{
"version": "0.10.2",
"date": "2026-02-20",
"changes": [
"Recurring task scheduler: AI automatically investigates recurring tasks when they're due",
"Parallel task agents: multiple tasks can be investigated by AI simultaneously",
"Claude OAuth browser login: sign in to your Claude account via browser instead of token paste",
"Separate screen capture and audio recording toggles in Settings for independent control",
"Improved task drag-and-drop with cross-category moves and visual drop indicators",
"Unified AI bridge: removed legacy Agent SDK, all chat uses the faster ACP bridge",
"Session pre-warming for both Opus and Sonnet models for faster first responses",
"Fixed stale message desync that could cause AI chat to hang after interruptions"
]
},
{
"version": "0.10.1",
"date": "2026-02-20",
Expand Down
35 changes: 27 additions & 8 deletions desktop/Desktop/Sources/AgentSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ actor AgentSyncService {
// MARK: - State

private var cursors: [String: SyncCursor] = [:]
private var cachedTableColumns: [String: [String]] = [:]
private var vmIP: String?
private var authToken: String?
private var isRunning = false
Expand Down Expand Up @@ -223,16 +224,30 @@ actor AgentSyncService {

let cursor = cursors[spec.name] ?? SyncCursor(lastId: 0, lastUpdatedAt: "1970-01-01T00:00:00")

do {
let rows: [[String: Any]] = try await dbPool.read { db in
// Get actual column names from the table
let columnInfos = try Row.fetchAll(db, sql: "PRAGMA table_info('\(spec.name)')")
let allColumns = columnInfos.compactMap { $0["name"] as? String }
let columns = allColumns.filter { !spec.excludedColumns.contains($0) }
// Resolve columns once and cache — PRAGMA table_info is static at runtime
let columns: [String]
if let cached = cachedTableColumns[spec.name] {
columns = cached
} else {
do {
let fetched: [String] = try await dbPool.read { db in
let columnInfos = try Row.fetchAll(db, sql: "PRAGMA table_info('\(spec.name)')")
let allColumns = columnInfos.compactMap { $0["name"] as? String }
return allColumns.filter { !spec.excludedColumns.contains($0) }
}
cachedTableColumns[spec.name] = fetched
columns = fetched
} catch {
log("AgentSync: error fetching schema for \(spec.name) — \(error.localizedDescription)")
return 0
}
}

guard !columns.isEmpty else { return [] }
guard !columns.isEmpty else { return 0 }

let selectCols = columns.map { "\"\($0)\"" }.joined(separator: ", ")
do {
let selectCols = columns.map { "\"\($0)\"" }.joined(separator: ", ")
let rows: [[String: Any]] = try await dbPool.read { db in
let sql: String
let args: [any DatabaseValueConvertible]

Expand Down Expand Up @@ -335,6 +350,10 @@ actor AgentSyncService {

if httpResponse.statusCode == 200 {
return .success
} else if httpResponse.statusCode >= 500 {
let body = String(data: data, encoding: .utf8) ?? ""
log("AgentSync: push \(table) failed — HTTP \(httpResponse.statusCode): \(body)")
return .networkError // 5xx = server not ready, trigger backoff
} else {
let body = String(data: data, encoding: .utf8) ?? ""
log("AgentSync: push \(table) failed — HTTP \(httpResponse.statusCode): \(body)")
Expand Down
11 changes: 11 additions & 0 deletions desktop/Desktop/Sources/AnalyticsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,11 @@ class AnalyticsManager {
PostHogManager.shared.tierChanged(tier: tier, reason: reason)
}

func chatBridgeModeChanged(from oldMode: String, to newMode: String) {
MixpanelManager.shared.chatBridgeModeChanged(from: oldMode, to: newMode)
PostHogManager.shared.chatBridgeModeChanged(from: oldMode, to: newMode)
}

// MARK: - Settings State

/// Track the current state of key settings (screenshots, memory extraction, notifications)
Expand Down Expand Up @@ -802,6 +807,9 @@ class AnalyticsManager {
props["rewind_retention_days"] = ud.object(forKey: "rewindRetentionDays") as? Double ?? 7.0
props["rewind_capture_interval"] = ud.object(forKey: "rewindCaptureInterval") as? Double ?? 1.0

// -- AI Chat Mode --
props["chat_bridge_mode"] = ud.string(forKey: "chatBridgeMode") ?? "agentSDK"

// -- UI Preferences --
props["multi_chat_enabled"] = ud.bool(forKey: "multiChatEnabled")
props["conversations_compact_view"] = ud.object(forKey: "conversationsCompactView") as? Bool ?? true
Expand All @@ -819,6 +827,9 @@ class AnalyticsManager {
props["floating_bar_enabled"] = FloatingControlBarManager.shared.isEnabled
props["floating_bar_visible"] = FloatingControlBarManager.shared.isVisible

// -- Dev Mode --
props["dev_mode_enabled"] = ud.bool(forKey: "devModeEnabled")

return props
}

Expand Down
89 changes: 61 additions & 28 deletions desktop/Desktop/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -870,22 +870,46 @@ class AppState: ObservableObject {
let result = AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &focusedWindow)

// .success or .noValue (app has no windows) both mean AX is working
// .cannotComplete or .apiDisabled mean the permission is stuck
switch result {
case .success, .noValue, .notImplemented, .attributeUnsupported:
return true
case .apiDisabled:
// System-wide AX is disabled — unambiguous, no confirmation needed
log("ACCESSIBILITY_CHECK: AXError.apiDisabled — permission stuck (tested against pid \(frontApp.processIdentifier), app: \(frontApp.localizedName ?? "unknown"))")
return false
case .cannotComplete:
log("ACCESSIBILITY_CHECK: AXError.cannotComplete — permission may be stuck (tested against pid \(frontApp.processIdentifier), app: \(frontApp.localizedName ?? "unknown"))")
return false
// cannotComplete is ambiguous: it can mean our permission is broken, OR that the
// frontmost app doesn't implement AX (e.g. Qt, OpenGL, Python-based apps like PyMOL).
// Confirm against Finder before concluding the permission is truly broken.
return confirmAccessibilityBrokenViaFinder(suspectApp: frontApp.localizedName ?? "unknown")
default:
log("ACCESSIBILITY_CHECK: AXError code \(result.rawValue) from app \(frontApp.localizedName ?? "unknown") — not permission-related, treating as OK")
return true
}
}

/// Secondary AX check against Finder to disambiguate cannotComplete errors.
/// If Finder (a known AX-compliant app) also fails, the permission is truly broken.
/// If Finder succeeds, the original failure was app-specific, not a permission issue.
private func confirmAccessibilityBrokenViaFinder(suspectApp: String) -> Bool {
if let finder = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first {
let finderElement = AXUIElementCreateApplication(finder.processIdentifier)
var finderWindow: CFTypeRef?
let finderResult = AXUIElementCopyAttributeValue(finderElement, kAXFocusedWindowAttribute as CFString, &finderWindow)
if finderResult == .cannotComplete || finderResult == .apiDisabled {
log("ACCESSIBILITY_CHECK: AXError.cannotComplete confirmed by Finder — permission is truly stuck (original app: \(suspectApp))")
return false
} else {
log("ACCESSIBILITY_CHECK: AXError.cannotComplete from \(suspectApp) but Finder OK — app-specific AX incompatibility, permission is fine")
return true
}
} else {
// Finder not running — fall back to event tap probe as tie-breaker
log("ACCESSIBILITY_CHECK: AXError.cannotComplete from \(suspectApp), Finder not running — using event tap probe")
return probeAccessibilityViaEventTap()
}
}

/// Probe accessibility permission by attempting to create a CGEvent tap.
/// Unlike AXIsProcessTrusted(), event tap creation checks the live TCC database,
/// bypassing the per-process cache that can go stale on macOS 26 (Tahoe).
Expand Down Expand Up @@ -1106,6 +1130,7 @@ class AppState: ObservableObject {
)

isTranscribing = true
AssistantSettings.shared.transcriptionEnabled = true
audioSource = effectiveSource
currentTranscript = ""
speakerSegments = []
Expand Down Expand Up @@ -1170,12 +1195,12 @@ class AppState: ObservableObject {
await startBleAudioCapture()
} else {
// Use microphone (+ optional system audio)
startMicrophoneAudioCapture()
await startMicrophoneAudioCapture()
}
}

/// Start microphone audio capture (original implementation)
private func startMicrophoneAudioCapture() {
private func startMicrophoneAudioCapture() async {
guard let audioCaptureService = audioCaptureService,
let audioMixer = audioMixer else { return }

Expand All @@ -1186,7 +1211,7 @@ class AppState: ObservableObject {

do {
// Start microphone capture - sends to mixer channel 0 (left/user)
try audioCaptureService.startCapture(
try await audioCaptureService.startCapture(
onAudioChunk: { [weak self] audioData in
self?.audioMixer?.setMicAudio(audioData)
},
Expand All @@ -1201,7 +1226,7 @@ class AppState: ObservableObject {
if #available(macOS 14.4, *) {
if let systemService = systemAudioCaptureService as? SystemAudioCaptureService {
do {
try systemService.startCapture(
try await systemService.startCapture(
onAudioChunk: { [weak self] audioData in
self?.audioMixer?.setSystemAudio(audioData)
},
Expand Down Expand Up @@ -1615,8 +1640,14 @@ class AppState: ObservableObject {
}
}
} catch {
// Silently ignore errors during auto-refresh — cached data stays visible
logError("Conversations: Auto-refresh failed", error: error)
// Silently ignore errors during auto-refresh — cached data stays visible.
// Auth errors (notSignedIn) are transient: token refresh may fail momentarily
// while the user is still signed in. Don't send these to Sentry.
if case AuthError.notSignedIn = error {
log("Conversations: Auto-refresh skipped (auth token temporarily unavailable)")
} else {
logError("Conversations: Auto-refresh failed", error: error)
}
}

// Update total count
Expand Down Expand Up @@ -2489,30 +2520,32 @@ class AppState: ObservableObject {
// Create a test capture service
let testService = SystemAudioCaptureService()

do {
// Try to start capture - this will fail if permission is not granted
try testService.startCapture { _ in
// We don't need the audio data, just testing if it works
}
Task {
do {
// Try to start capture - this will fail if permission is not granted
try await testService.startCapture { _ in
// We don't need the audio data, just testing if it works
}

// If we get here, capture started successfully
log("System audio: Test capture started successfully")
// If we get here, capture started successfully
log("System audio: Test capture started successfully")

// Stop the test capture
testService.stopCapture()
log("System audio: Test capture stopped")
// Stop the test capture
testService.stopCapture()
log("System audio: Test capture stopped")

// Mark permission as granted
hasSystemAudioPermission = true
log("System audio: Permission verified")
// Mark permission as granted
hasSystemAudioPermission = true
log("System audio: Permission verified")

} catch {
logError("System audio: Test capture failed", error: error)
hasSystemAudioPermission = false
} catch {
logError("System audio: Test capture failed", error: error)
hasSystemAudioPermission = false

// Open System Settings to Screen Recording section
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
NSWorkspace.shared.open(url)
// Open System Settings to Screen Recording section
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
NSWorkspace.shared.open(url)
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions desktop/Desktop/Sources/Audio/AudioSourceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ final class AudioSourceManager: ObservableObject {
}

// Start microphone capture
try audioCaptureService?.startCapture(
try await audioCaptureService?.startCapture(
onAudioChunk: { [weak self] audioData in
self?.audioMixer?.setMicAudio(audioData)
},
Expand All @@ -241,7 +241,7 @@ final class AudioSourceManager: ObservableObject {

// Start system audio capture if available
if #available(macOS 14.4, *), let systemCapture = systemAudioCaptureService as? SystemAudioCaptureService {
try systemCapture.startCapture(
try await systemCapture.startCapture(
onAudioChunk: { [weak self] audioData in
self?.audioMixer?.setSystemAudio(audioData)
},
Expand Down
26 changes: 24 additions & 2 deletions desktop/Desktop/Sources/AudioCaptureService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import CoreAudio
/// Uses CoreAudio IOProc directly on the default input device to avoid
/// AVAudioEngine's implicit aggregate device creation, which degrades
/// system audio output quality (especially Bluetooth A2DP → SCO switch).
class AudioCaptureService {
class AudioCaptureService: @unchecked Sendable {

// MARK: - Types

Expand Down Expand Up @@ -105,7 +105,7 @@ class AudioCaptureService {
/// - Parameters:
/// - onAudioChunk: Callback receiving 16-bit PCM audio data chunks at 16kHz
/// - onAudioLevel: Optional callback receiving normalized audio level (0.0 - 1.0)
func startCapture(onAudioChunk: @escaping AudioChunkHandler, onAudioLevel: AudioLevelHandler? = nil) throws {
func startCapture(onAudioChunk: @escaping AudioChunkHandler, onAudioLevel: AudioLevelHandler? = nil) async throws {
guard !isCapturing else {
log("AudioCapture: Already capturing")
return
Expand All @@ -114,6 +114,28 @@ class AudioCaptureService {
self.onAudioChunk = onAudioChunk
self.onAudioLevel = onAudioLevel

// All CoreAudio HAL calls (AudioObjectGetPropertyData, AudioDeviceStart, etc.) are
// synchronous IPC to coreaudiod via mach_msg. After wake from sleep the daemon can
// take seconds to respond, blocking the caller. Dispatch the entire setup to audioQueue,
// mirroring the pattern already used in stopCapture() and handleConfigurationChange().
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
audioQueue.async { [weak self] in
guard let self else {
continuation.resume()
return
}
do {
try self.startCaptureOnQueue()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}

/// Performs all blocking CoreAudio HAL setup. Must be called on audioQueue, not the main thread.
private func startCaptureOnQueue() throws {
// 1. Get default input device
var inputDeviceID: AudioDeviceID = kAudioObjectUnknown
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
Expand Down
Loading