mageagent-local 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1337 @@
1
+ import Cocoa
2
+ import UserNotifications
3
+
4
+ // MARK: - MageAgent Menu Bar Application Delegate
5
+ // Production-grade macOS menu bar application for MageAgent server management
6
+ // Implements NSMenuItemValidation for proper menu item state management
7
+
8
+ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
9
+
10
+ // MARK: - Properties
11
+
12
+ /// Status bar item - retained for entire app lifecycle
13
+ private var statusItem: NSStatusItem!
14
+
15
+ /// Main menu - stored as strong reference
16
+ private var menu: NSMenu!
17
+
18
+ /// Status checking timer
19
+ private var statusTimer: Timer?
20
+
21
+ /// Server status for menu item state management
22
+ private var isServerRunning: Bool = false
23
+
24
+ // MARK: - Menu Item Tags (for identification)
25
+
26
+ private enum MenuItemTag: Int {
27
+ case status = 100
28
+ case models = 200
29
+ case startServer = 300
30
+ case stopServer = 301
31
+ case restartServer = 302
32
+ case warmupModels = 303
33
+ case openDocs = 400
34
+ case viewLogs = 401
35
+ case runTest = 402
36
+ case settings = 500
37
+ }
38
+
39
+ // MARK: - Configuration
40
+
41
+ private struct Config {
42
+ static let mageagentScript = "\(NSHomeDirectory())/.claude/scripts/mageagent-server.sh"
43
+ static let mageagentURL = "http://localhost:3457"
44
+ static let logFile = "\(NSHomeDirectory())/.claude/debug/mageagent.log"
45
+ static let debugLogFile = "\(NSHomeDirectory())/.claude/debug/mageagent-menubar-debug.log"
46
+ static let iconPath = "\(NSHomeDirectory())/.claude/mageagent-menubar/icons/icon_18x18@2x.png"
47
+ static let statusCheckInterval: TimeInterval = 10.0
48
+ static let requestTimeout: TimeInterval = 2.0
49
+ }
50
+
51
+ // MARK: - Model and Pattern Definitions
52
+
53
+ private struct ModelInfo {
54
+ let modelId: String
55
+ let displayName: String
56
+ let memorySize: String
57
+ }
58
+
59
+ private struct PatternInfo {
60
+ let patternId: String
61
+ let displayName: String
62
+ let requiredModels: [String] // Model IDs required for this pattern
63
+ let description: String
64
+ }
65
+
66
+ /// Available models that can be loaded
67
+ private let availableModels: [ModelInfo] = [
68
+ ModelInfo(modelId: "mageagent:primary", displayName: "Qwen-72B Q8 (77GB) - Reasoning", memorySize: "77GB"),
69
+ ModelInfo(modelId: "mageagent:tools", displayName: "Hermes-3 8B Q8 (9GB) - Tool Calling", memorySize: "9GB"),
70
+ ModelInfo(modelId: "mageagent:validator", displayName: "Qwen-Coder 7B (5GB) - Fast Validation", memorySize: "5GB"),
71
+ ModelInfo(modelId: "mageagent:competitor", displayName: "Qwen-Coder 32B (18GB) - Coding", memorySize: "18GB")
72
+ ]
73
+
74
+ /// Available orchestration patterns with their required models
75
+ private let availablePatterns: [PatternInfo] = [
76
+ PatternInfo(
77
+ patternId: "mageagent:auto",
78
+ displayName: "auto",
79
+ requiredModels: ["mageagent:validator"], // Uses validator for classification, loads others on demand
80
+ description: "Intelligent task routing - classifies task and routes to best model"
81
+ ),
82
+ PatternInfo(
83
+ patternId: "mageagent:execute",
84
+ displayName: "execute",
85
+ requiredModels: ["mageagent:primary", "mageagent:tools"],
86
+ description: "Real tool execution - ReAct loop with Qwen-72B + Hermes tool calling"
87
+ ),
88
+ PatternInfo(
89
+ patternId: "mageagent:hybrid",
90
+ displayName: "hybrid",
91
+ requiredModels: ["mageagent:primary", "mageagent:tools"],
92
+ description: "Reasoning + tools - Qwen-72B for thinking, Hermes for tool extraction"
93
+ ),
94
+ PatternInfo(
95
+ patternId: "mageagent:validated",
96
+ displayName: "validated",
97
+ requiredModels: ["mageagent:primary", "mageagent:validator"],
98
+ description: "Generate + validate - Qwen-72B generates, 7B validates and revises"
99
+ ),
100
+ PatternInfo(
101
+ patternId: "mageagent:compete",
102
+ displayName: "compete",
103
+ requiredModels: ["mageagent:primary", "mageagent:competitor", "mageagent:validator"],
104
+ description: "Multi-model judge - 72B + 32B generate, 7B judges best response"
105
+ )
106
+ ]
107
+
108
+ /// Track which models are currently loaded
109
+ private var loadedModels: Set<String> = []
110
+
111
+ /// Currently selected pattern
112
+ private var selectedPattern: String = "mageagent:auto"
113
+
114
+ // MARK: - Lifecycle
115
+
116
+ func applicationDidFinishLaunching(_ notification: Notification) {
117
+ debugLog("Application launched - initializing MageAgent Menu Bar")
118
+
119
+ // Configure as menu bar only app (no dock icon)
120
+ NSApp.setActivationPolicy(.accessory)
121
+
122
+ // Request notification permissions
123
+ requestNotificationPermission()
124
+
125
+ // Initialize UI components
126
+ setupStatusItem()
127
+ setupMenu()
128
+
129
+ // Start monitoring server status
130
+ checkServerStatus()
131
+ startStatusTimer()
132
+
133
+ debugLog("Application initialization complete")
134
+ }
135
+
136
+ func applicationWillTerminate(_ notification: Notification) {
137
+ debugLog("Application terminating - cleaning up")
138
+ statusTimer?.invalidate()
139
+ statusTimer = nil
140
+ }
141
+
142
+ // MARK: - UI Setup
143
+
144
+ private func setupStatusItem() {
145
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
146
+
147
+ guard let button = statusItem.button else {
148
+ debugLog("ERROR: Failed to get status item button")
149
+ return
150
+ }
151
+
152
+ // Load custom icon or fall back to system symbol
153
+ if let iconImage = NSImage(contentsOfFile: Config.iconPath) {
154
+ iconImage.size = NSSize(width: 25, height: 25)
155
+ iconImage.isTemplate = true // Adapts to light/dark mode
156
+ button.image = iconImage
157
+ debugLog("Custom icon loaded successfully")
158
+ } else if let symbolImage = NSImage(systemSymbolName: "brain.head.profile", accessibilityDescription: "MageAgent") {
159
+ symbolImage.isTemplate = true
160
+ button.image = symbolImage
161
+ debugLog("Using fallback system symbol")
162
+ } else {
163
+ // Ultimate fallback - text
164
+ button.title = "MA"
165
+ debugLog("Using text fallback for status item")
166
+ }
167
+
168
+ button.toolTip = "MageAgent - Multi-Model AI Orchestration"
169
+ }
170
+
171
+ private func setupMenu() {
172
+ menu = NSMenu()
173
+ menu.autoenablesItems = false // We manage enabled state manually
174
+
175
+ // Status display (non-interactive)
176
+ let statusMenuItem = NSMenuItem(title: "Status: Checking...", action: nil, keyEquivalent: "")
177
+ statusMenuItem.tag = MenuItemTag.status.rawValue
178
+ statusMenuItem.isEnabled = false
179
+ menu.addItem(statusMenuItem)
180
+
181
+ menu.addItem(NSMenuItem.separator())
182
+
183
+ // Server control section
184
+ addMenuItem(title: "Start Server", action: #selector(startServerAction(_:)),
185
+ keyEquivalent: "s", tag: .startServer)
186
+ addMenuItem(title: "Stop Server", action: #selector(stopServerAction(_:)),
187
+ keyEquivalent: "", tag: .stopServer)
188
+ addMenuItem(title: "Restart Server", action: #selector(restartServerAction(_:)),
189
+ keyEquivalent: "r", tag: .restartServer)
190
+ addMenuItem(title: "Warmup Models", action: #selector(warmupModelsAction(_:)),
191
+ keyEquivalent: "w", tag: .warmupModels)
192
+
193
+ menu.addItem(NSMenuItem.separator())
194
+
195
+ // Models submenu - clickable to load individual models
196
+ let modelsItem = NSMenuItem(title: "Load Models", action: nil, keyEquivalent: "")
197
+ modelsItem.tag = MenuItemTag.models.rawValue
198
+ let modelsSubmenu = NSMenu()
199
+
200
+ // Add header
201
+ let headerItem = NSMenuItem(title: "Click to load into memory:", action: nil, keyEquivalent: "")
202
+ headerItem.isEnabled = false
203
+ modelsSubmenu.addItem(headerItem)
204
+ modelsSubmenu.addItem(NSMenuItem.separator())
205
+
206
+ // Add each model as a clickable item
207
+ for (index, model) in availableModels.enumerated() {
208
+ let item = NSMenuItem(title: model.displayName, action: #selector(loadModelAction(_:)), keyEquivalent: "")
209
+ item.target = self
210
+ item.tag = 1000 + index // Tags starting at 1000 for models
211
+ item.representedObject = model.modelId
212
+ modelsSubmenu.addItem(item)
213
+ }
214
+
215
+ modelsSubmenu.addItem(NSMenuItem.separator())
216
+
217
+ // Load all models option
218
+ let loadAllItem = NSMenuItem(title: "Load All Models", action: #selector(warmupModelsAction(_:)), keyEquivalent: "w")
219
+ loadAllItem.target = self
220
+ loadAllItem.keyEquivalentModifierMask = [.command]
221
+ modelsSubmenu.addItem(loadAllItem)
222
+
223
+ modelsItem.submenu = modelsSubmenu
224
+ menu.addItem(modelsItem)
225
+
226
+ // Patterns submenu - each pattern shows required models as a submenu
227
+ let patternsItem = NSMenuItem(title: "Patterns", action: nil, keyEquivalent: "")
228
+ let patternsSubmenu = NSMenu()
229
+
230
+ // Add header
231
+ let patternHeader = NSMenuItem(title: "Select pattern (click to load required models):", action: nil, keyEquivalent: "")
232
+ patternHeader.isEnabled = false
233
+ patternsSubmenu.addItem(patternHeader)
234
+ patternsSubmenu.addItem(NSMenuItem.separator())
235
+
236
+ // Add each pattern with its own submenu showing required models
237
+ for (index, pattern) in availablePatterns.enumerated() {
238
+ let patternItem = NSMenuItem(title: pattern.displayName, action: nil, keyEquivalent: "")
239
+ patternItem.tag = 2000 + index
240
+
241
+ // Create submenu for this pattern
242
+ let patternDetailsSubmenu = NSMenu()
243
+
244
+ // Description header
245
+ let descItem = NSMenuItem(title: pattern.description, action: nil, keyEquivalent: "")
246
+ descItem.isEnabled = false
247
+ patternDetailsSubmenu.addItem(descItem)
248
+ patternDetailsSubmenu.addItem(NSMenuItem.separator())
249
+
250
+ // Required models header
251
+ let reqHeader = NSMenuItem(title: "Required Models:", action: nil, keyEquivalent: "")
252
+ reqHeader.isEnabled = false
253
+ patternDetailsSubmenu.addItem(reqHeader)
254
+
255
+ // List each required model
256
+ for modelId in pattern.requiredModels {
257
+ if let modelInfo = availableModels.first(where: { $0.modelId == modelId }) {
258
+ let modelItem = NSMenuItem(title: " • \(modelInfo.displayName)", action: nil, keyEquivalent: "")
259
+ modelItem.isEnabled = false
260
+ patternDetailsSubmenu.addItem(modelItem)
261
+ }
262
+ }
263
+
264
+ patternDetailsSubmenu.addItem(NSMenuItem.separator())
265
+
266
+ // "Use this pattern" action - loads all required models
267
+ let usePatternItem = NSMenuItem(title: "Use \(pattern.displayName) Pattern", action: #selector(selectPatternAction(_:)), keyEquivalent: "")
268
+ usePatternItem.target = self
269
+ usePatternItem.representedObject = pattern
270
+ patternDetailsSubmenu.addItem(usePatternItem)
271
+
272
+ patternItem.submenu = patternDetailsSubmenu
273
+ patternsSubmenu.addItem(patternItem)
274
+ }
275
+
276
+ patternsItem.submenu = patternsSubmenu
277
+ menu.addItem(patternsItem)
278
+
279
+ menu.addItem(NSMenuItem.separator())
280
+
281
+ // Utility actions
282
+ addMenuItem(title: "Open API Docs", action: #selector(openDocsAction(_:)),
283
+ keyEquivalent: "d", tag: .openDocs)
284
+ addMenuItem(title: "View Logs", action: #selector(viewLogsAction(_:)),
285
+ keyEquivalent: "l", tag: .viewLogs)
286
+ addMenuItem(title: "Run Test", action: #selector(runTestAction(_:)),
287
+ keyEquivalent: "t", tag: .runTest)
288
+
289
+ menu.addItem(NSMenuItem.separator())
290
+
291
+ // Settings
292
+ addMenuItem(title: "Settings...", action: #selector(showSettingsAction(_:)),
293
+ keyEquivalent: ",", tag: .settings)
294
+
295
+ menu.addItem(NSMenuItem.separator())
296
+
297
+ // Quit
298
+ let quitItem = NSMenuItem(title: "Quit MageAgent Menu", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
299
+ menu.addItem(quitItem)
300
+
301
+ // Assign menu to status item
302
+ statusItem.menu = menu
303
+
304
+ debugLog("Menu setup complete with \(menu.items.count) items")
305
+ }
306
+
307
+ /// Helper to create menu items with proper target/action binding
308
+ private func addMenuItem(title: String, action: Selector, keyEquivalent: String, tag: MenuItemTag) {
309
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
310
+ item.target = self
311
+ item.tag = tag.rawValue
312
+ if !keyEquivalent.isEmpty {
313
+ item.keyEquivalentModifierMask = [.command]
314
+ }
315
+ menu.addItem(item)
316
+ }
317
+
318
+ // MARK: - NSMenuItemValidation
319
+
320
+ /// Called by AppKit to determine if menu items should be enabled
321
+ func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
322
+ guard let tag = MenuItemTag(rawValue: menuItem.tag) else {
323
+ return true // Allow untagged items
324
+ }
325
+
326
+ switch tag {
327
+ case .startServer:
328
+ return !isServerRunning
329
+ case .stopServer, .restartServer, .warmupModels:
330
+ return isServerRunning
331
+ case .openDocs:
332
+ return isServerRunning
333
+ case .viewLogs, .runTest, .settings:
334
+ return true
335
+ default:
336
+ return true
337
+ }
338
+ }
339
+
340
+ // MARK: - Server Control Actions
341
+
342
+ @objc func startServerAction(_ sender: NSMenuItem) {
343
+ debugLog("Start Server action triggered")
344
+ executeServerCommand("start", successMessage: "Server started on port 3457",
345
+ failureMessage: "Failed to start server")
346
+ }
347
+
348
+ @objc func stopServerAction(_ sender: NSMenuItem) {
349
+ debugLog("Stop Server action triggered")
350
+ executeServerCommand("stop", successMessage: "Server stopped",
351
+ failureMessage: "Failed to stop server")
352
+ }
353
+
354
+ @objc func restartServerAction(_ sender: NSMenuItem) {
355
+ debugLog("Restart Server action triggered")
356
+ executeServerCommand("restart", successMessage: "Server restarted",
357
+ failureMessage: "Failed to restart server")
358
+ }
359
+
360
+ @objc func warmupModelsAction(_ sender: NSMenuItem) {
361
+ debugLog("Warmup Models action triggered")
362
+ sendNotification(title: "MageAgent", body: "Warming up models - this may take a few minutes...")
363
+
364
+ // Models to warm up - each will load into GPU/RAM
365
+ let modelsToWarmup = [
366
+ ("mageagent:primary", "Qwen-72B Q8 (77GB)"),
367
+ ("mageagent:tools", "Hermes-3 8B Q8 (9GB)"),
368
+ ("mageagent:validator", "Qwen-Coder 7B (5GB)"),
369
+ ("mageagent:competitor", "Qwen-Coder 32B (18GB)")
370
+ ]
371
+
372
+ // Warm up each model sequentially
373
+ warmupModels(modelsToWarmup, index: 0)
374
+ }
375
+
376
+ // MARK: - Model Loading Actions
377
+
378
+ @objc func loadModelAction(_ sender: NSMenuItem) {
379
+ guard let modelId = sender.representedObject as? String else {
380
+ debugLog("ERROR: loadModelAction - no modelId in representedObject")
381
+ return
382
+ }
383
+
384
+ // Find model info
385
+ let modelName = availableModels.first { $0.modelId == modelId }?.displayName ?? modelId
386
+ debugLog("Load Model action triggered for: \(modelName)")
387
+
388
+ // Show loading notification
389
+ sendNotification(title: "MageAgent", body: "Loading \(modelName)...")
390
+
391
+ // Update status to show loading
392
+ DispatchQueue.main.async {
393
+ if let statusItem = self.menu.item(withTag: MenuItemTag.status.rawValue) {
394
+ statusItem.title = "Loading: \(modelName)..."
395
+ }
396
+ }
397
+
398
+ // Load the model via warmup request
399
+ warmupModel(modelId: modelId) { [weak self] success in
400
+ guard let self = self else { return }
401
+
402
+ if success {
403
+ self.loadedModels.insert(modelId)
404
+ self.sendNotification(title: "MageAgent", body: "\(modelName) loaded successfully!")
405
+ self.debugLog("Model \(modelName) loaded into memory")
406
+
407
+ // Update menu item to show checkmark
408
+ DispatchQueue.main.async {
409
+ sender.state = .on
410
+ }
411
+ } else {
412
+ self.sendNotification(title: "MageAgent", body: "Failed to load \(modelName)")
413
+ self.debugLog("Failed to load model \(modelName)")
414
+ }
415
+
416
+ // Restore status display
417
+ self.checkServerStatus()
418
+ }
419
+ }
420
+
421
+ // MARK: - Pattern Selection Actions
422
+
423
+ @objc func selectPatternAction(_ sender: NSMenuItem) {
424
+ guard let pattern = sender.representedObject as? PatternInfo else {
425
+ debugLog("ERROR: selectPatternAction - no PatternInfo in representedObject")
426
+ return
427
+ }
428
+
429
+ debugLog("Pattern selection triggered: \(pattern.displayName)")
430
+
431
+ // Update selected pattern
432
+ selectedPattern = pattern.patternId
433
+
434
+ // Check which required models are not yet loaded
435
+ let missingModels = pattern.requiredModels.filter { !loadedModels.contains($0) }
436
+
437
+ if missingModels.isEmpty {
438
+ // All models already loaded
439
+ sendNotification(title: "MageAgent", body: "Pattern '\(pattern.displayName)' active - all required models already loaded!")
440
+ updatePatternMenuCheckmarks(selectedPatternId: pattern.patternId)
441
+ } else {
442
+ // Need to load missing models
443
+ let modelNames = missingModels.compactMap { modelId in
444
+ availableModels.first { $0.modelId == modelId }?.displayName
445
+ }.joined(separator: ", ")
446
+
447
+ sendNotification(title: "MageAgent", body: "Loading models for '\(pattern.displayName)':\n\(modelNames)")
448
+
449
+ // Load missing models sequentially
450
+ loadModelsForPattern(pattern: pattern, modelIds: missingModels, index: 0)
451
+ }
452
+ }
453
+
454
+ /// Load required models for a pattern sequentially
455
+ private func loadModelsForPattern(pattern: PatternInfo, modelIds: [String], index: Int) {
456
+ guard index < modelIds.count else {
457
+ // All models loaded
458
+ debugLog("All models for pattern '\(pattern.displayName)' loaded successfully")
459
+ sendNotification(title: "MageAgent", body: "Pattern '\(pattern.displayName)' ready!")
460
+ updatePatternMenuCheckmarks(selectedPatternId: pattern.patternId)
461
+ checkServerStatus()
462
+ return
463
+ }
464
+
465
+ let modelId = modelIds[index]
466
+ let modelName = availableModels.first { $0.modelId == modelId }?.displayName ?? modelId
467
+
468
+ // Update status
469
+ DispatchQueue.main.async {
470
+ if let statusItem = self.menu.item(withTag: MenuItemTag.status.rawValue) {
471
+ statusItem.title = "Loading: \(modelName)..."
472
+ }
473
+ }
474
+
475
+ debugLog("Loading model \(index + 1)/\(modelIds.count) for pattern: \(modelName)")
476
+
477
+ warmupModel(modelId: modelId) { [weak self] success in
478
+ guard let self = self else { return }
479
+
480
+ if success {
481
+ self.loadedModels.insert(modelId)
482
+ self.debugLog("Model \(modelName) loaded for pattern")
483
+
484
+ // Update checkmark on model menu item
485
+ self.updateModelMenuCheckmark(modelId: modelId, loaded: true)
486
+ } else {
487
+ self.debugLog("Failed to load model \(modelName) for pattern")
488
+ }
489
+
490
+ // Continue to next model
491
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
492
+ self.loadModelsForPattern(pattern: pattern, modelIds: modelIds, index: index + 1)
493
+ }
494
+ }
495
+ }
496
+
497
+ /// Update checkmark on a model menu item
498
+ private func updateModelMenuCheckmark(modelId: String, loaded: Bool) {
499
+ if let modelsItem = menu.item(withTag: MenuItemTag.models.rawValue),
500
+ let submenu = modelsItem.submenu {
501
+ for item in submenu.items {
502
+ if let itemModelId = item.representedObject as? String, itemModelId == modelId {
503
+ item.state = loaded ? .on : .off
504
+ break
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ /// Update checkmarks on pattern menu items
511
+ private func updatePatternMenuCheckmarks(selectedPatternId: String) {
512
+ if let patternsItem = findPatternsMenuItem(),
513
+ let submenu = patternsItem.submenu {
514
+ for item in submenu.items {
515
+ // Each pattern item has a submenu with details
516
+ if let patternSubmenu = item.submenu {
517
+ // Look for "Use X Pattern" item
518
+ for subItem in patternSubmenu.items {
519
+ if let pattern = subItem.representedObject as? PatternInfo {
520
+ subItem.state = (pattern.patternId == selectedPatternId) ? .on : .off
521
+ }
522
+ }
523
+ }
524
+ // Also check the parent pattern item
525
+ let patternId = availablePatterns.first { $0.displayName == item.title }?.patternId
526
+ item.state = (patternId == selectedPatternId) ? .on : .off
527
+ }
528
+ }
529
+ }
530
+
531
+ /// Find the Patterns menu item by searching menu items
532
+ private func findPatternsMenuItem() -> NSMenuItem? {
533
+ for item in menu.items {
534
+ if item.title == "Patterns" {
535
+ return item
536
+ }
537
+ }
538
+ return nil
539
+ }
540
+
541
+ private func warmupModels(_ models: [(String, String)], index: Int) {
542
+ guard index < models.count else {
543
+ // All models warmed up
544
+ debugLog("All models warmed up successfully")
545
+ sendNotification(title: "MageAgent", body: "All models loaded into memory!")
546
+ return
547
+ }
548
+
549
+ let (modelId, modelName) = models[index]
550
+ debugLog("Warming up model \(index + 1)/\(models.count): \(modelName)")
551
+
552
+ // Update status to show progress
553
+ DispatchQueue.main.async {
554
+ if let statusItem = self.menu.item(withTag: MenuItemTag.status.rawValue) {
555
+ statusItem.title = "Loading: \(modelName)..."
556
+ }
557
+ }
558
+
559
+ // Send a minimal inference request to load the model
560
+ warmupModel(modelId: modelId) { [weak self] success in
561
+ guard let self = self else { return }
562
+
563
+ if success {
564
+ self.debugLog("Model \(modelName) loaded successfully")
565
+ } else {
566
+ self.debugLog("Failed to load model \(modelName)")
567
+ }
568
+
569
+ // Continue to next model after a brief delay
570
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
571
+ self.warmupModels(models, index: index + 1)
572
+ }
573
+ }
574
+ }
575
+
576
+ private func warmupModel(modelId: String, completion: @escaping (Bool) -> Void) {
577
+ guard let url = URL(string: "\(Config.mageagentURL)/v1/chat/completions") else {
578
+ completion(false)
579
+ return
580
+ }
581
+
582
+ var request = URLRequest(url: url)
583
+ request.httpMethod = "POST"
584
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
585
+ request.timeoutInterval = 300 // 5 minutes for large models
586
+
587
+ // Minimal request to trigger model loading
588
+ let body: [String: Any] = [
589
+ "model": modelId,
590
+ "messages": [
591
+ ["role": "user", "content": "Hi"]
592
+ ],
593
+ "max_tokens": 5,
594
+ "temperature": 0.1
595
+ ]
596
+
597
+ do {
598
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
599
+ } catch {
600
+ debugLog("Failed to create warmup request: \(error)")
601
+ completion(false)
602
+ return
603
+ }
604
+
605
+ URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
606
+ DispatchQueue.main.async {
607
+ if let error = error {
608
+ self?.debugLog("Warmup request failed: \(error.localizedDescription)")
609
+ completion(false)
610
+ return
611
+ }
612
+
613
+ guard let httpResponse = response as? HTTPURLResponse else {
614
+ completion(false)
615
+ return
616
+ }
617
+
618
+ self?.debugLog("Warmup response status: \(httpResponse.statusCode)")
619
+ completion(httpResponse.statusCode == 200)
620
+
621
+ // Restore status display
622
+ self?.checkServerStatus()
623
+ }
624
+ }.resume()
625
+ }
626
+
627
+ private func executeServerCommand(_ command: String, successMessage: String, failureMessage: String) {
628
+ runServerScript(command) { [weak self] success, output in
629
+ guard let self = self else { return }
630
+
631
+ if success {
632
+ self.sendNotification(title: "MageAgent", body: successMessage)
633
+ // Delay status check to allow server to start/stop
634
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
635
+ self.checkServerStatus()
636
+ }
637
+ } else {
638
+ let errorDetail = output.isEmpty ? "" : "\n\(output.prefix(200))"
639
+ self.sendNotification(title: "MageAgent", body: "\(failureMessage)\(errorDetail)")
640
+ }
641
+ }
642
+ }
643
+
644
+ // MARK: - Utility Actions
645
+
646
+ @objc func openDocsAction(_ sender: NSMenuItem) {
647
+ debugLog("Open API Docs action triggered")
648
+ guard let url = URL(string: "\(Config.mageagentURL)/docs") else {
649
+ debugLog("ERROR: Invalid docs URL")
650
+ return
651
+ }
652
+ NSWorkspace.shared.open(url)
653
+ }
654
+
655
+ @objc func viewLogsAction(_ sender: NSMenuItem) {
656
+ debugLog("View Logs action triggered")
657
+
658
+ let logURL = URL(fileURLWithPath: Config.logFile)
659
+
660
+ // Try to open with Console.app first
661
+ if FileManager.default.fileExists(atPath: Config.logFile) {
662
+ NSWorkspace.shared.open(logURL)
663
+ } else {
664
+ // Create empty log file if it doesn't exist
665
+ let logDir = (Config.logFile as NSString).deletingLastPathComponent
666
+ try? FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true)
667
+ FileManager.default.createFile(atPath: Config.logFile, contents: nil)
668
+ NSWorkspace.shared.open(logURL)
669
+ }
670
+ }
671
+
672
+ @objc func runTestAction(_ sender: NSMenuItem) {
673
+ debugLog("Run Test action triggered")
674
+ showTestResultsWindow()
675
+ }
676
+
677
+ // MARK: - Test Results Window
678
+
679
+ /// Reference to keep test window alive
680
+ private var testResultsWindow: NSWindow?
681
+ private var testResultsTextView: NSTextView?
682
+
683
+ /// Show streaming test results in a window
684
+ private func showTestResultsWindow() {
685
+ // Create window
686
+ let window = NSWindow(
687
+ contentRect: NSRect(x: 0, y: 0, width: 600, height: 450),
688
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
689
+ backing: .buffered,
690
+ defer: false
691
+ )
692
+ window.title = "MageAgent Test Results"
693
+ window.center()
694
+ window.isReleasedWhenClosed = false
695
+
696
+ // Create scroll view with text view
697
+ let scrollView = NSScrollView(frame: NSRect(x: 0, y: 50, width: 600, height: 400))
698
+ scrollView.hasVerticalScroller = true
699
+ scrollView.hasHorizontalScroller = false
700
+ scrollView.autoresizingMask = [.width, .height]
701
+ scrollView.borderType = .noBorder
702
+
703
+ let textView = NSTextView(frame: scrollView.bounds)
704
+ textView.isEditable = false
705
+ textView.isSelectable = true
706
+ textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
707
+ textView.backgroundColor = NSColor.textBackgroundColor
708
+ textView.textColor = NSColor.textColor
709
+ textView.autoresizingMask = [.width]
710
+ textView.isVerticallyResizable = true
711
+ textView.isHorizontallyResizable = false
712
+ textView.textContainer?.widthTracksTextView = true
713
+ textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
714
+
715
+ scrollView.documentView = textView
716
+ window.contentView?.addSubview(scrollView)
717
+
718
+ // Create bottom bar with status and close button
719
+ let bottomBar = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 50))
720
+ bottomBar.autoresizingMask = [.width, .maxYMargin]
721
+
722
+ let statusLabel = NSTextField(labelWithString: "Running tests...")
723
+ statusLabel.frame = NSRect(x: 15, y: 15, width: 400, height: 20)
724
+ statusLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
725
+ statusLabel.tag = 9999 // Tag to find later
726
+ bottomBar.addSubview(statusLabel)
727
+
728
+ let closeButton = NSButton(title: "Close", target: self, action: #selector(closeTestWindow(_:)))
729
+ closeButton.frame = NSRect(x: 500, y: 10, width: 80, height: 30)
730
+ closeButton.bezelStyle = .rounded
731
+ closeButton.autoresizingMask = [.minXMargin]
732
+ bottomBar.addSubview(closeButton)
733
+
734
+ window.contentView?.addSubview(bottomBar)
735
+
736
+ // Store references
737
+ testResultsWindow = window
738
+ testResultsTextView = textView
739
+
740
+ // Show window
741
+ window.makeKeyAndOrderFront(nil)
742
+ NSApp.activate(ignoringOtherApps: true)
743
+
744
+ // Start running tests
745
+ runStreamingTests()
746
+ }
747
+
748
+ @objc private func closeTestWindow(_ sender: Any) {
749
+ testResultsWindow?.close()
750
+ testResultsWindow = nil
751
+ testResultsTextView = nil
752
+ }
753
+
754
+ /// Append text to test results with optional color
755
+ private func appendTestResult(_ text: String, color: NSColor = .textColor) {
756
+ guard let textView = testResultsTextView else { return }
757
+
758
+ DispatchQueue.main.async {
759
+ let attributedString = NSAttributedString(
760
+ string: text,
761
+ attributes: [
762
+ .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
763
+ .foregroundColor: color
764
+ ]
765
+ )
766
+ textView.textStorage?.append(attributedString)
767
+ textView.scrollToEndOfDocument(nil)
768
+ }
769
+ }
770
+
771
+ /// Update status label in test window
772
+ private func updateTestStatus(_ status: String, color: NSColor = .labelColor) {
773
+ DispatchQueue.main.async {
774
+ if let bottomBar = self.testResultsWindow?.contentView?.subviews.first(where: { $0.frame.height == 50 }),
775
+ let statusLabel = bottomBar.viewWithTag(9999) as? NSTextField {
776
+ statusLabel.stringValue = status
777
+ statusLabel.textColor = color
778
+ }
779
+ }
780
+ }
781
+
782
+ /// Run tests with streaming output
783
+ private func runStreamingTests() {
784
+ appendTestResult("MageAgent Test Suite\n", color: .systemBlue)
785
+ appendTestResult("=" .padding(toLength: 50, withPad: "=", startingAt: 0) + "\n\n")
786
+
787
+ // Define test cases
788
+ let tests: [(name: String, test: (@escaping (Bool, String) -> Void) -> Void)] = [
789
+ ("Server Health Check", testServerHealth),
790
+ ("Models Endpoint", testModelsEndpoint),
791
+ ("Validator Model (7B)", { self.testModel("mageagent:validator", completion: $0) }),
792
+ ("Tools Model (Hermes-3)", { self.testModel("mageagent:tools", completion: $0) }),
793
+ ("Primary Model (72B)", { self.testModel("mageagent:primary", completion: $0) }),
794
+ ("Competitor Model (32B)", { self.testModel("mageagent:competitor", completion: $0) })
795
+ ]
796
+
797
+ runTestSequence(tests: tests, index: 0, passed: 0, failed: 0)
798
+ }
799
+
800
+ /// Run tests sequentially
801
+ private func runTestSequence(tests: [(name: String, test: (@escaping (Bool, String) -> Void) -> Void)], index: Int, passed: Int, failed: Int) {
802
+ guard index < tests.count else {
803
+ // All tests complete
804
+ appendTestResult("\n" + "=".padding(toLength: 50, withPad: "=", startingAt: 0) + "\n")
805
+
806
+ let total = passed + failed
807
+ let summary = "Results: \(passed)/\(total) passed"
808
+
809
+ if failed == 0 {
810
+ appendTestResult("All tests passed!\n", color: .systemGreen)
811
+ updateTestStatus(summary, color: .systemGreen)
812
+ sendNotification(title: "MageAgent Tests", body: "All \(total) tests passed!")
813
+ } else {
814
+ appendTestResult("\(failed) test(s) failed\n", color: .systemRed)
815
+ updateTestStatus(summary, color: .systemRed)
816
+ sendNotification(title: "MageAgent Tests", body: "\(failed) of \(total) tests failed")
817
+ }
818
+ return
819
+ }
820
+
821
+ let (name, testFunc) = tests[index]
822
+ appendTestResult("[\(index + 1)/\(tests.count)] Testing: \(name)... ")
823
+ updateTestStatus("Running: \(name)...")
824
+
825
+ testFunc { [weak self] success, message in
826
+ guard let self = self else { return }
827
+
828
+ if success {
829
+ self.appendTestResult("PASS\n", color: .systemGreen)
830
+ if !message.isEmpty {
831
+ self.appendTestResult(" \(message)\n", color: .secondaryLabelColor)
832
+ }
833
+ self.runTestSequence(tests: tests, index: index + 1, passed: passed + 1, failed: failed)
834
+ } else {
835
+ self.appendTestResult("FAIL\n", color: .systemRed)
836
+ if !message.isEmpty {
837
+ self.appendTestResult(" Error: \(message)\n", color: .systemRed)
838
+ }
839
+ self.runTestSequence(tests: tests, index: index + 1, passed: passed, failed: failed + 1)
840
+ }
841
+ }
842
+ }
843
+
844
+ // MARK: - Individual Test Functions
845
+
846
+ private func testServerHealth(completion: @escaping (Bool, String) -> Void) {
847
+ guard let url = URL(string: "\(Config.mageagentURL)/health") else {
848
+ completion(false, "Invalid URL")
849
+ return
850
+ }
851
+
852
+ var request = URLRequest(url: url)
853
+ request.timeoutInterval = 5.0
854
+
855
+ URLSession.shared.dataTask(with: request) { data, response, error in
856
+ DispatchQueue.main.async {
857
+ if let error = error {
858
+ completion(false, error.localizedDescription)
859
+ return
860
+ }
861
+
862
+ guard let httpResponse = response as? HTTPURLResponse else {
863
+ completion(false, "No HTTP response")
864
+ return
865
+ }
866
+
867
+ if httpResponse.statusCode == 200 {
868
+ if let data = data,
869
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
870
+ let status = json["status"] as? String {
871
+ completion(true, "Status: \(status)")
872
+ } else {
873
+ completion(true, "")
874
+ }
875
+ } else {
876
+ completion(false, "HTTP \(httpResponse.statusCode)")
877
+ }
878
+ }
879
+ }.resume()
880
+ }
881
+
882
+ private func testModelsEndpoint(completion: @escaping (Bool, String) -> Void) {
883
+ guard let url = URL(string: "\(Config.mageagentURL)/v1/models") else {
884
+ completion(false, "Invalid URL")
885
+ return
886
+ }
887
+
888
+ var request = URLRequest(url: url)
889
+ request.timeoutInterval = 5.0
890
+
891
+ URLSession.shared.dataTask(with: request) { data, response, error in
892
+ DispatchQueue.main.async {
893
+ if let error = error {
894
+ completion(false, error.localizedDescription)
895
+ return
896
+ }
897
+
898
+ guard let httpResponse = response as? HTTPURLResponse else {
899
+ completion(false, "No HTTP response")
900
+ return
901
+ }
902
+
903
+ if httpResponse.statusCode == 200 {
904
+ if let data = data,
905
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
906
+ let models = json["data"] as? [[String: Any]] {
907
+ completion(true, "\(models.count) models available")
908
+ } else {
909
+ completion(true, "")
910
+ }
911
+ } else {
912
+ completion(false, "HTTP \(httpResponse.statusCode)")
913
+ }
914
+ }
915
+ }.resume()
916
+ }
917
+
918
+ private func testModel(_ modelId: String, completion: @escaping (Bool, String) -> Void) {
919
+ guard let url = URL(string: "\(Config.mageagentURL)/v1/chat/completions") else {
920
+ completion(false, "Invalid URL")
921
+ return
922
+ }
923
+
924
+ var request = URLRequest(url: url)
925
+ request.httpMethod = "POST"
926
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
927
+ request.timeoutInterval = 120 // 2 minutes for model loading
928
+
929
+ let body: [String: Any] = [
930
+ "model": modelId,
931
+ "messages": [["role": "user", "content": "Say 'test passed' in exactly 2 words."]],
932
+ "max_tokens": 10,
933
+ "temperature": 0.1
934
+ ]
935
+
936
+ do {
937
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
938
+ } catch {
939
+ completion(false, "Failed to create request")
940
+ return
941
+ }
942
+
943
+ let startTime = Date()
944
+
945
+ URLSession.shared.dataTask(with: request) { data, response, error in
946
+ DispatchQueue.main.async {
947
+ let elapsed = Date().timeIntervalSince(startTime)
948
+ let timeStr = String(format: "%.1fs", elapsed)
949
+
950
+ if let error = error {
951
+ completion(false, "\(error.localizedDescription) (\(timeStr))")
952
+ return
953
+ }
954
+
955
+ guard let httpResponse = response as? HTTPURLResponse else {
956
+ completion(false, "No HTTP response (\(timeStr))")
957
+ return
958
+ }
959
+
960
+ if httpResponse.statusCode == 200 {
961
+ if let data = data,
962
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
963
+ let choices = json["choices"] as? [[String: Any]],
964
+ let firstChoice = choices.first,
965
+ let message = firstChoice["message"] as? [String: Any],
966
+ let content = message["content"] as? String {
967
+ let preview = content.prefix(30).replacingOccurrences(of: "\n", with: " ")
968
+ completion(true, "\"\(preview)\" (\(timeStr))")
969
+ } else {
970
+ completion(true, "Response received (\(timeStr))")
971
+ }
972
+ } else {
973
+ var errorMsg = "HTTP \(httpResponse.statusCode)"
974
+ if let data = data,
975
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
976
+ let detail = json["detail"] as? String {
977
+ errorMsg += ": \(detail)"
978
+ }
979
+ completion(false, "\(errorMsg) (\(timeStr))")
980
+ }
981
+ }
982
+ }.resume()
983
+ }
984
+
985
+ @objc func showSettingsAction(_ sender: NSMenuItem) {
986
+ debugLog("Settings action triggered")
987
+
988
+ // Bring app to front for modal dialog
989
+ NSApp.activate(ignoringOtherApps: true)
990
+
991
+ let alert = NSAlert()
992
+ alert.messageText = "MageAgent Settings"
993
+ alert.informativeText = """
994
+ MageAgent Server Configuration
995
+
996
+ Port: 3457
997
+ API: \(Config.mageagentURL)
998
+ Script: \(Config.mageagentScript)
999
+ Logs: \(Config.logFile)
1000
+
1001
+ Loaded Models:
1002
+ • Primary: Qwen-72B Q8 (reasoning)
1003
+ • Tools: Hermes-3 8B Q8 (tool calling)
1004
+ • Validator: Qwen-Coder 7B (fast)
1005
+ • Competitor: Qwen-Coder 32B (coding)
1006
+
1007
+ Available Patterns:
1008
+ • auto: Intelligent task routing
1009
+ • execute: Real tool execution (ReAct)
1010
+ • hybrid: Reasoning + tool extraction
1011
+ • validated: Generate + validate
1012
+ • compete: Multi-model with judge
1013
+
1014
+ Status: \(isServerRunning ? "Running" : "Stopped")
1015
+ """
1016
+ alert.alertStyle = .informational
1017
+ alert.addButton(withTitle: "Close")
1018
+ alert.addButton(withTitle: "Open Logs Folder")
1019
+
1020
+ let response = alert.runModal()
1021
+ if response == .alertSecondButtonReturn {
1022
+ let logsFolder = (Config.logFile as NSString).deletingLastPathComponent
1023
+ NSWorkspace.shared.open(URL(fileURLWithPath: logsFolder))
1024
+ }
1025
+ }
1026
+
1027
+ // MARK: - Server Status Checking
1028
+
1029
+ private func startStatusTimer() {
1030
+ statusTimer?.invalidate()
1031
+ statusTimer = Timer.scheduledTimer(withTimeInterval: Config.statusCheckInterval, repeats: true) { [weak self] _ in
1032
+ self?.checkServerStatus()
1033
+ }
1034
+ // Add to common run loop mode to ensure it fires even during menu tracking
1035
+ RunLoop.current.add(statusTimer!, forMode: .common)
1036
+ }
1037
+
1038
+ private func checkServerStatus() {
1039
+ guard let url = URL(string: "\(Config.mageagentURL)/health") else {
1040
+ debugLog("ERROR: Invalid health check URL")
1041
+ return
1042
+ }
1043
+
1044
+ var request = URLRequest(url: url)
1045
+ request.timeoutInterval = Config.requestTimeout
1046
+ request.httpMethod = "GET"
1047
+
1048
+ URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
1049
+ DispatchQueue.main.async {
1050
+ guard let self = self else { return }
1051
+
1052
+ if let error = error {
1053
+ self.debugLog("Health check failed: \(error.localizedDescription)")
1054
+ self.updateServerStatus(running: false, version: nil, models: [])
1055
+ return
1056
+ }
1057
+
1058
+ guard let httpResponse = response as? HTTPURLResponse,
1059
+ httpResponse.statusCode == 200,
1060
+ let data = data else {
1061
+ self.updateServerStatus(running: false, version: nil, models: [])
1062
+ return
1063
+ }
1064
+
1065
+ do {
1066
+ if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
1067
+ let version = json["version"] as? String
1068
+ let models = json["loaded_models"] as? [String] ?? []
1069
+ self.updateServerStatus(running: true, version: version, models: models)
1070
+ }
1071
+ } catch {
1072
+ self.debugLog("Failed to parse health response: \(error)")
1073
+ self.updateServerStatus(running: false, version: nil, models: [])
1074
+ }
1075
+ }
1076
+ }.resume()
1077
+ }
1078
+
1079
+ private func updateServerStatus(running: Bool, version: String?, models: [String]) {
1080
+ isServerRunning = running
1081
+
1082
+ // Update status menu item
1083
+ if let statusItem = menu.item(withTag: MenuItemTag.status.rawValue) {
1084
+ if running {
1085
+ let versionStr = version ?? "?"
1086
+ statusItem.title = "Status: Running (v\(versionStr))"
1087
+ } else {
1088
+ statusItem.title = "Status: Stopped"
1089
+ }
1090
+ }
1091
+
1092
+ // Update loaded models set from server response
1093
+ loadedModels = Set(models.map { modelName in
1094
+ // Convert model names to our modelId format
1095
+ if modelName.contains("72B") || modelName.contains("primary") {
1096
+ return "mageagent:primary"
1097
+ } else if modelName.contains("Hermes") || modelName.contains("tools") {
1098
+ return "mageagent:tools"
1099
+ } else if modelName.contains("7B") || modelName.contains("validator") {
1100
+ return "mageagent:validator"
1101
+ } else if modelName.contains("32B") || modelName.contains("competitor") {
1102
+ return "mageagent:competitor"
1103
+ }
1104
+ return modelName
1105
+ })
1106
+
1107
+ // Update checkmarks on model menu items based on loaded status
1108
+ if let modelsItem = menu.item(withTag: MenuItemTag.models.rawValue),
1109
+ let submenu = modelsItem.submenu {
1110
+ for item in submenu.items {
1111
+ if let modelId = item.representedObject as? String {
1112
+ // Check if this model is loaded
1113
+ item.state = loadedModels.contains(modelId) ? .on : .off
1114
+ }
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ // MARK: - Script Execution
1120
+
1121
+ private func runServerScript(_ command: String, completion: @escaping (Bool, String) -> Void) {
1122
+ debugLog("Executing server script: \(command)")
1123
+
1124
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
1125
+ guard let self = self else {
1126
+ DispatchQueue.main.async { completion(false, "AppDelegate deallocated") }
1127
+ return
1128
+ }
1129
+
1130
+ let task = Process()
1131
+ task.executableURL = URL(fileURLWithPath: "/bin/bash")
1132
+ task.arguments = ["-l", "-c", "\(Config.mageagentScript) \(command)"]
1133
+
1134
+ // Configure environment
1135
+ var environment = ProcessInfo.processInfo.environment
1136
+ environment["HOME"] = NSHomeDirectory()
1137
+ environment["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
1138
+ // Ensure Python/UV paths are available
1139
+ if let pyenvRoot = environment["PYENV_ROOT"] {
1140
+ environment["PATH"] = "\(pyenvRoot)/shims:\(environment["PATH"] ?? "")"
1141
+ }
1142
+ task.environment = environment
1143
+
1144
+ let outputPipe = Pipe()
1145
+ let errorPipe = Pipe()
1146
+ task.standardOutput = outputPipe
1147
+ task.standardError = errorPipe
1148
+
1149
+ do {
1150
+ try task.run()
1151
+ task.waitUntilExit()
1152
+
1153
+ let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
1154
+ let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
1155
+ let output = String(data: outputData, encoding: .utf8) ?? ""
1156
+ let errorOutput = String(data: errorData, encoding: .utf8) ?? ""
1157
+
1158
+ let combinedOutput = output + (errorOutput.isEmpty ? "" : "\nErrors: \(errorOutput)")
1159
+
1160
+ self.debugLog("Script '\(command)' completed with status: \(task.terminationStatus)")
1161
+ self.debugLog("Output: \(combinedOutput.prefix(500))")
1162
+
1163
+ DispatchQueue.main.async {
1164
+ completion(task.terminationStatus == 0, combinedOutput)
1165
+ }
1166
+ } catch {
1167
+ self.debugLog("Script execution failed: \(error.localizedDescription)")
1168
+ DispatchQueue.main.async {
1169
+ completion(false, error.localizedDescription)
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ // MARK: - Notifications
1176
+
1177
+ /// Track if notifications are authorized
1178
+ private var notificationsAuthorized: Bool = false
1179
+
1180
+ private func requestNotificationPermission() {
1181
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in
1182
+ DispatchQueue.main.async {
1183
+ self?.notificationsAuthorized = granted
1184
+ if let error = error {
1185
+ self?.debugLog("Notification permission error: \(error.localizedDescription)")
1186
+ } else {
1187
+ self?.debugLog("Notification permission granted: \(granted)")
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ // Also check current authorization status
1193
+ UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
1194
+ DispatchQueue.main.async {
1195
+ self?.notificationsAuthorized = settings.authorizationStatus == .authorized
1196
+ self?.debugLog("Notification settings: \(settings.authorizationStatus.rawValue)")
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ private func sendNotification(title: String, body: String) {
1202
+ debugLog("Sending notification: \(title) - \(body)")
1203
+
1204
+ // Try modern UNUserNotificationCenter first
1205
+ let content = UNMutableNotificationContent()
1206
+ content.title = title
1207
+ content.body = body
1208
+ content.sound = .default
1209
+
1210
+ let request = UNNotificationRequest(
1211
+ identifier: UUID().uuidString,
1212
+ content: content,
1213
+ trigger: nil // Deliver immediately
1214
+ )
1215
+
1216
+ UNUserNotificationCenter.current().add(request) { [weak self] error in
1217
+ if let error = error {
1218
+ self?.debugLog("UNUserNotification failed: \(error.localizedDescription)")
1219
+ // Fallback to visual toast
1220
+ DispatchQueue.main.async {
1221
+ self?.showToastAlert(title: title, body: body)
1222
+ }
1223
+ } else {
1224
+ self?.debugLog("Notification sent successfully")
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ /// Fallback toast using a floating panel
1230
+ private func showToastAlert(title: String, body: String) {
1231
+ // Create a small floating window as a toast
1232
+ let toastWindow = NSPanel(
1233
+ contentRect: NSRect(x: 0, y: 0, width: 300, height: 80),
1234
+ styleMask: [.titled, .nonactivatingPanel, .fullSizeContentView],
1235
+ backing: .buffered,
1236
+ defer: false
1237
+ )
1238
+
1239
+ toastWindow.isFloatingPanel = true
1240
+ toastWindow.level = .floating
1241
+ toastWindow.titleVisibility = .hidden
1242
+ toastWindow.titlebarAppearsTransparent = true
1243
+ toastWindow.isMovableByWindowBackground = true
1244
+ toastWindow.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.95)
1245
+
1246
+ // Create content view
1247
+ let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 80))
1248
+
1249
+ // Icon
1250
+ let iconView = NSImageView(frame: NSRect(x: 15, y: 22, width: 36, height: 36))
1251
+ if let iconImage = NSImage(contentsOfFile: Config.iconPath) {
1252
+ iconImage.size = NSSize(width: 36, height: 36)
1253
+ iconView.image = iconImage
1254
+ } else if let symbolImage = NSImage(systemSymbolName: "brain.head.profile", accessibilityDescription: "MageAgent") {
1255
+ iconView.image = symbolImage
1256
+ }
1257
+ contentView.addSubview(iconView)
1258
+
1259
+ // Title
1260
+ let titleLabel = NSTextField(labelWithString: title)
1261
+ titleLabel.frame = NSRect(x: 60, y: 48, width: 225, height: 20)
1262
+ titleLabel.font = NSFont.boldSystemFont(ofSize: 13)
1263
+ titleLabel.textColor = NSColor.labelColor
1264
+ contentView.addSubview(titleLabel)
1265
+
1266
+ // Body
1267
+ let bodyLabel = NSTextField(labelWithString: body)
1268
+ bodyLabel.frame = NSRect(x: 60, y: 12, width: 225, height: 32)
1269
+ bodyLabel.font = NSFont.systemFont(ofSize: 11)
1270
+ bodyLabel.textColor = NSColor.secondaryLabelColor
1271
+ bodyLabel.lineBreakMode = .byTruncatingTail
1272
+ bodyLabel.maximumNumberOfLines = 2
1273
+ contentView.addSubview(bodyLabel)
1274
+
1275
+ toastWindow.contentView = contentView
1276
+
1277
+ // Position in top-right corner
1278
+ if let screen = NSScreen.main {
1279
+ let screenFrame = screen.visibleFrame
1280
+ let windowFrame = toastWindow.frame
1281
+ let x = screenFrame.maxX - windowFrame.width - 20
1282
+ let y = screenFrame.maxY - windowFrame.height - 10
1283
+ toastWindow.setFrameOrigin(NSPoint(x: x, y: y))
1284
+ }
1285
+
1286
+ // Show with animation
1287
+ toastWindow.alphaValue = 0
1288
+ toastWindow.makeKeyAndOrderFront(nil)
1289
+ toastWindow.orderFrontRegardless()
1290
+
1291
+ NSAnimationContext.runAnimationGroup { context in
1292
+ context.duration = 0.3
1293
+ toastWindow.animator().alphaValue = 1.0
1294
+ }
1295
+
1296
+ // Auto-dismiss after 3 seconds
1297
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
1298
+ NSAnimationContext.runAnimationGroup({ context in
1299
+ context.duration = 0.3
1300
+ toastWindow.animator().alphaValue = 0
1301
+ }) {
1302
+ toastWindow.close()
1303
+ }
1304
+ }
1305
+ }
1306
+
1307
+ // MARK: - Debug Logging
1308
+
1309
+ private func debugLog(_ message: String) {
1310
+ let timestamp = ISO8601DateFormatter().string(from: Date())
1311
+ let logMessage = "[\(timestamp)] \(message)"
1312
+
1313
+ // Print to console
1314
+ print(logMessage)
1315
+
1316
+ // Write to debug log file
1317
+ let logEntry = logMessage + "\n"
1318
+ guard let data = logEntry.data(using: .utf8) else { return }
1319
+
1320
+ let logDir = (Config.debugLogFile as NSString).deletingLastPathComponent
1321
+
1322
+ // Ensure log directory exists
1323
+ if !FileManager.default.fileExists(atPath: logDir) {
1324
+ try? FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true)
1325
+ }
1326
+
1327
+ if FileManager.default.fileExists(atPath: Config.debugLogFile) {
1328
+ if let fileHandle = FileHandle(forWritingAtPath: Config.debugLogFile) {
1329
+ defer { fileHandle.closeFile() }
1330
+ fileHandle.seekToEndOfFile()
1331
+ fileHandle.write(data)
1332
+ }
1333
+ } else {
1334
+ try? data.write(to: URL(fileURLWithPath: Config.debugLogFile))
1335
+ }
1336
+ }
1337
+ }