@lattices/cli 0.3.0 → 0.4.0

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.
Files changed (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -1,3 +1,5 @@
1
+ import AppKit
2
+ import ApplicationServices
1
3
  import Foundation
2
4
 
3
5
  // MARK: - Registry Types
@@ -371,6 +373,257 @@ final class LatticesApi {
371
373
  }
372
374
  ))
373
375
 
376
+ // MARK: - Unified Search
377
+
378
+ api.register(Endpoint(
379
+ method: "lattices.search",
380
+ description: "Unified search across windows, terminals, and OCR. Single entry point for all search surfaces.",
381
+ access: .read,
382
+ params: [
383
+ Param(name: "query", type: "string", required: true, description: "Search text"),
384
+ Param(name: "sources", type: "array<string>", required: false, description: "Data sources to include: titles, apps, sessions, cwd, tabs, tmux, ocr, processes. Omit for smart default (everything except ocr). Use ['all'] for everything."),
385
+ Param(name: "after", type: "string", required: false, description: "ISO8601 timestamp — only windows interacted with after this time"),
386
+ Param(name: "before", type: "string", required: false, description: "ISO8601 timestamp — only windows interacted with before this time"),
387
+ Param(name: "recency", type: "bool", required: false, description: "Boost score for recently-focused windows (default true)"),
388
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 20)"),
389
+ // Legacy compat
390
+ Param(name: "mode", type: "string", required: false, description: "Legacy: 'quick', 'complete', 'terminal'. Mapped to sources internally."),
391
+ ],
392
+ returns: .array(model: "SearchResult"),
393
+ handler: { params in
394
+ guard let query = params?["query"]?.stringValue?.lowercased(), !query.isEmpty else {
395
+ throw RouterError.missingParam("query")
396
+ }
397
+ let limit = params?["limit"]?.intValue ?? 20
398
+ let useRecency = params?["recency"]?.boolValue ?? true
399
+
400
+ // ── Resolve sources ──
401
+
402
+ // All available source names
403
+ let allSources: Set<String> = ["titles", "apps", "sessions", "cwd", "tabs", "tmux", "ocr", "processes"]
404
+ // Smart default: everything except OCR (fast)
405
+ let defaultSources: Set<String> = ["titles", "apps", "sessions", "cwd", "tabs", "tmux"]
406
+
407
+ var sources: Set<String>
408
+ if let arr = params?["sources"]?.arrayValue {
409
+ let names = arr.compactMap(\.stringValue)
410
+ if names.contains("all") {
411
+ sources = allSources
412
+ } else if names.contains("terminals") {
413
+ // Shorthand expansion
414
+ sources = Set(names).subtracting(["terminals"]).union(["cwd", "tabs", "tmux", "processes"])
415
+ } else {
416
+ sources = Set(names).intersection(allSources)
417
+ if sources.isEmpty { sources = defaultSources }
418
+ }
419
+ } else if let mode = params?["mode"]?.stringValue {
420
+ // Legacy mode param → sources mapping
421
+ switch mode {
422
+ case "quick": sources = ["titles", "apps", "sessions"]
423
+ case "terminal": sources = ["cwd", "tabs", "tmux", "processes"]
424
+ default: sources = allSources // "complete"
425
+ }
426
+ } else {
427
+ sources = defaultSources
428
+ }
429
+
430
+ let includeWindowIndex = !sources.isDisjoint(with: ["titles", "apps", "sessions"])
431
+ let includeOcr = sources.contains("ocr")
432
+ let includeTerminals = !sources.isDisjoint(with: ["cwd", "tabs", "tmux", "processes"])
433
+
434
+ // ── Resolve time filters ──
435
+
436
+ let isoFormatter = ISO8601DateFormatter()
437
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
438
+ let isoFormatterNoFrac = ISO8601DateFormatter()
439
+
440
+ func parseDate(_ str: String?) -> Date? {
441
+ guard let str else { return nil }
442
+ return isoFormatter.date(from: str) ?? isoFormatterNoFrac.date(from: str)
443
+ }
444
+
445
+ let afterDate = parseDate(params?["after"]?.stringValue)
446
+ let beforeDate = parseDate(params?["before"]?.stringValue)
447
+
448
+ // Default time window: 2 days (only applies when no explicit time filters given)
449
+ let defaultCutoff = (afterDate == nil && beforeDate == nil)
450
+ ? Date().addingTimeInterval(-2 * 24 * 3600)
451
+ : nil
452
+
453
+ let now = Date()
454
+ let desktop = DesktopModel.shared
455
+
456
+ /// Check if a window passes the time filter.
457
+ /// Windows with no interaction date are included if they're currently on screen (live windows).
458
+ func passesTimeFilter(wid: UInt32, entry: WindowEntry) -> Bool {
459
+ if let interacted = desktop.lastInteractionDate(for: wid) {
460
+ if let after = afterDate, interacted < after { return false }
461
+ if let before = beforeDate, interacted > before { return false }
462
+ if let cutoff = defaultCutoff, interacted < cutoff { return false }
463
+ return true
464
+ }
465
+ // No interaction date — include if currently visible (it's a live window we just haven't tracked yet)
466
+ return entry.isOnScreen
467
+ }
468
+
469
+ /// Recency boost: windows focused recently score higher.
470
+ /// Frontmost (zIndex 0) gets +4, last 5 min +3, last hour +2, last day +1.
471
+ func recencyBoost(wid: UInt32, zIndex: Int) -> Int {
472
+ guard useRecency else { return 0 }
473
+ if zIndex == 0 { return 4 }
474
+ guard let last = desktop.lastInteractionDate(for: wid) else { return 0 }
475
+ let ago = now.timeIntervalSince(last)
476
+ if ago < 300 { return 3 } // 5 min
477
+ if ago < 3600 { return 2 } // 1 hour
478
+ if ago < 86400 { return 1 } // 1 day
479
+ return 0
480
+ }
481
+
482
+ // ── Accumulator ──
483
+
484
+ struct Accum {
485
+ let entry: WindowEntry
486
+ var score: Int
487
+ var sources: [String]
488
+ var ocrSnippet: String?
489
+ var tabs: [JSON]
490
+ var lastInteraction: Date?
491
+ }
492
+ var byWid: [UInt32: Accum] = [:]
493
+
494
+ // ── Tier 1: Window index (title, app, session) ──
495
+
496
+ if includeWindowIndex {
497
+ let ocrResults = includeOcr ? OcrModel.shared.results : [:]
498
+ let checkTitles = sources.contains("titles")
499
+ let checkApps = sources.contains("apps")
500
+ let checkSessions = sources.contains("sessions")
501
+
502
+ for entry in desktop.allWindows() {
503
+ guard passesTimeFilter(wid: entry.wid, entry: entry) else { continue }
504
+
505
+ var score = 0
506
+ var matchSources: [String] = []
507
+ var ocrSnippet: String? = nil
508
+
509
+ if checkTitles && entry.title.lowercased().contains(query) { score += 3; matchSources.append("title") }
510
+ if checkApps && entry.app.lowercased().contains(query) { score += 2; matchSources.append("app") }
511
+ if checkSessions && entry.latticesSession?.lowercased().contains(query) == true { score += 3; matchSources.append("session") }
512
+
513
+ if includeOcr, let ocrResult = ocrResults[entry.wid] {
514
+ let text = ocrResult.fullText
515
+ if text.lowercased().contains(query) {
516
+ score += 1; matchSources.append("ocr")
517
+ if let range = text.lowercased().range(of: query) {
518
+ let half = max(0, (80 - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
519
+ let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
520
+ let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
521
+ var snippet = String(text[start..<end])
522
+ .replacingOccurrences(of: "\n", with: " ")
523
+ .trimmingCharacters(in: .whitespaces)
524
+ if start > text.startIndex { snippet = "…" + snippet }
525
+ if end < text.endIndex { snippet += "…" }
526
+ ocrSnippet = snippet
527
+ }
528
+ }
529
+ }
530
+
531
+ if score > 0 {
532
+ score += recencyBoost(wid: entry.wid, zIndex: entry.zIndex)
533
+ byWid[entry.wid] = Accum(
534
+ entry: entry, score: score, sources: matchSources,
535
+ ocrSnippet: ocrSnippet, tabs: [],
536
+ lastInteraction: desktop.lastInteractionDate(for: entry.wid)
537
+ )
538
+ }
539
+ }
540
+ }
541
+
542
+ // ── Tier 2: Terminal inspection (cwd, tab titles, tmux sessions, processes) ──
543
+
544
+ if includeTerminals {
545
+ let checkCwd = sources.contains("cwd")
546
+ let checkTabs = sources.contains("tabs")
547
+ let checkTmux = sources.contains("tmux")
548
+ let checkProcesses = sources.contains("processes")
549
+
550
+ let instances = ProcessModel.shared.synthesizeTerminals()
551
+ for inst in instances {
552
+ let cwdMatch = checkCwd && (inst.cwd?.lowercased().contains(query) ?? false)
553
+ let tabMatch = checkTabs && (inst.tabTitle?.lowercased().contains(query) ?? false)
554
+ let tmuxMatch = checkTmux && (inst.tmuxSession?.lowercased().contains(query) ?? false)
555
+ let processMatch = checkProcesses && inst.processes.contains {
556
+ $0.comm.lowercased().contains(query) || $0.args.lowercased().contains(query)
557
+ }
558
+ guard cwdMatch || tabMatch || tmuxMatch || processMatch else { continue }
559
+
560
+ var tab: [String: JSON] = [:]
561
+ if let idx = inst.tabIndex { tab["tabIndex"] = .int(idx) }
562
+ if let cwd = inst.cwd { tab["cwd"] = .string(cwd) }
563
+ if let title = inst.tabTitle { tab["tabTitle"] = .string(title) }
564
+ tab["hasClaude"] = .bool(inst.hasClaude)
565
+ if let session = inst.tmuxSession { tab["tmuxSession"] = .string(session) }
566
+ if processMatch {
567
+ let matched = inst.processes.filter {
568
+ $0.comm.lowercased().contains(query) || $0.args.lowercased().contains(query)
569
+ }
570
+ tab["matchedProcesses"] = .array(matched.map { .string($0.comm) })
571
+ }
572
+
573
+ let tabJson = JSON.object(tab)
574
+ var tabScore = 0
575
+ if cwdMatch { tabScore += 3 }
576
+ if tabMatch { tabScore += 2 }
577
+ if tmuxMatch { tabScore += 3 }
578
+ if processMatch { tabScore += 2 }
579
+
580
+ if let wid = inst.windowId {
581
+ if var acc = byWid[wid] {
582
+ acc.score += tabScore
583
+ if cwdMatch && !acc.sources.contains("cwd") { acc.sources.append("cwd") }
584
+ if tabMatch && !acc.sources.contains("tab") { acc.sources.append("tab") }
585
+ if tmuxMatch && !acc.sources.contains("tmux") { acc.sources.append("tmux") }
586
+ if processMatch && !acc.sources.contains("process") { acc.sources.append("process") }
587
+ acc.tabs.append(tabJson)
588
+ byWid[wid] = acc
589
+ } else if let entry = desktop.windows[wid] {
590
+ guard passesTimeFilter(wid: wid, entry: entry) else { continue }
591
+ var matchSources: [String] = []
592
+ if cwdMatch { matchSources.append("cwd") }
593
+ if tabMatch { matchSources.append("tab") }
594
+ if tmuxMatch { matchSources.append("tmux") }
595
+ if processMatch { matchSources.append("process") }
596
+ let score = tabScore + recencyBoost(wid: wid, zIndex: entry.zIndex)
597
+ byWid[wid] = Accum(
598
+ entry: entry, score: score, sources: matchSources,
599
+ ocrSnippet: nil, tabs: [tabJson],
600
+ lastInteraction: desktop.lastInteractionDate(for: wid)
601
+ )
602
+ }
603
+ }
604
+ }
605
+ }
606
+
607
+ // ── Build results ──
608
+
609
+ let sorted = byWid.values.sorted { $0.score > $1.score }
610
+ return .array(Array(sorted.prefix(limit)).map { acc in
611
+ var obj = Encoders.window(acc.entry)
612
+ if case .object(var dict) = obj {
613
+ dict["score"] = .int(acc.score)
614
+ dict["matchSources"] = .array(acc.sources.map { .string($0) })
615
+ if let snippet = acc.ocrSnippet { dict["ocrSnippet"] = .string(snippet) }
616
+ if !acc.tabs.isEmpty { dict["terminalTabs"] = .array(acc.tabs) }
617
+ if let last = acc.lastInteraction {
618
+ dict["lastInteraction"] = .string(ISO8601DateFormatter().string(from: last))
619
+ }
620
+ obj = .object(dict)
621
+ }
622
+ return obj
623
+ })
624
+ }
625
+ ))
626
+
374
627
  // MARK: - Window Layer Tags
375
628
 
376
629
  api.register(Endpoint(
@@ -379,7 +632,7 @@ final class LatticesApi {
379
632
  access: .mutate,
380
633
  params: [
381
634
  Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
382
- Param(name: "layer", type: "string", required: true, description: "Layer id (e.g. 'lattices', 'talkie')")
635
+ Param(name: "layer", type: "string", required: true, description: "Layer id (e.g. 'lattices', 'vox')")
383
636
  ],
384
637
  returns: .ok,
385
638
  handler: { params in
@@ -756,22 +1009,18 @@ final class LatticesApi {
756
1009
  params: [
757
1010
  Param(name: "session", type: "string", required: true, description: "Tmux session name"),
758
1011
  Param(name: "position", type: "string", required: true,
759
- description: "Tile position (\(TilePosition.allCases.map(\.rawValue).joined(separator: ", ")))"),
1012
+ description: "Placement shorthand or grid syntax"),
760
1013
  ],
761
1014
  returns: .ok,
762
1015
  handler: { params in
763
- guard let session = params?["session"]?.stringValue else {
1016
+ guard case .object(var dict) = params else {
764
1017
  throw RouterError.missingParam("session")
765
1018
  }
766
- guard let posStr = params?["position"]?.stringValue,
767
- let position = TilePosition(rawValue: posStr) else {
768
- throw RouterError.missingParam("position (valid: \(TilePosition.allCases.map(\.rawValue).joined(separator: ", ")))")
769
- }
770
- let terminal = Preferences.shared.terminal
771
- DispatchQueue.main.async {
772
- WindowTiler.tile(session: session, terminal: terminal, to: position)
1019
+ guard dict["session"]?.stringValue != nil else {
1020
+ throw RouterError.missingParam("session")
773
1021
  }
774
- return .object(["ok": .bool(true)])
1022
+ dict["placement"] = dict["placement"] ?? dict["position"]
1023
+ return try Self.executeWindowPlacement(params: .object(dict))
775
1024
  }
776
1025
  ))
777
1026
 
@@ -789,10 +1038,16 @@ final class LatticesApi {
789
1038
  guard let entry = DesktopModel.shared.windows[wid] else {
790
1039
  throw RouterError.notFound("window \(wid)")
791
1040
  }
792
- DispatchQueue.main.async {
793
- WindowTiler.focusWindow(wid: wid, pid: entry.pid)
1041
+ var raised = false
1042
+ if Thread.isMainThread {
1043
+ raised = WindowTiler.focusWindow(wid: wid, pid: entry.pid)
1044
+ } else {
1045
+ DispatchQueue.main.sync {
1046
+ raised = WindowTiler.focusWindow(wid: wid, pid: entry.pid)
1047
+ }
794
1048
  }
795
- return .object(["ok": .bool(true), "wid": .int(Int(wid)), "app": .string(entry.app)])
1049
+ return .object(["ok": .bool(raised), "wid": .int(Int(wid)), "app": .string(entry.app),
1050
+ "raised": .bool(raised)])
796
1051
  }
797
1052
  guard let session = params?["session"]?.stringValue else {
798
1053
  throw RouterError.missingParam("session or wid")
@@ -805,6 +1060,83 @@ final class LatticesApi {
805
1060
  }
806
1061
  ))
807
1062
 
1063
+ api.register(Endpoint(
1064
+ method: "window.place",
1065
+ description: "Place a window or session using a typed placement spec",
1066
+ access: .mutate,
1067
+ params: [
1068
+ Param(name: "wid", type: "uint32", required: false, description: "Window ID"),
1069
+ Param(name: "session", type: "string", required: false, description: "Tmux session name"),
1070
+ Param(name: "app", type: "string", required: false, description: "Application name"),
1071
+ Param(name: "title", type: "string", required: false, description: "Optional title substring for app matching"),
1072
+ Param(name: "display", type: "int", required: false, description: "Target display index"),
1073
+ Param(name: "placement", type: "string|object", required: true, description: "Placement shorthand or typed placement object"),
1074
+ ],
1075
+ returns: .custom("Execution receipt with target resolution, placement, and trace"),
1076
+ handler: { params in
1077
+ try Self.executeWindowPlacement(params: params)
1078
+ }
1079
+ ))
1080
+
1081
+ // ── Present Window ────────────────────────────────────────────
1082
+ api.register(Endpoint(
1083
+ method: "window.present",
1084
+ description: "Present a window: move to current space, bring to front, optionally position it",
1085
+ access: .mutate,
1086
+ params: [
1087
+ Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
1088
+ Param(name: "x", type: "double", required: false, description: "Target x position"),
1089
+ Param(name: "y", type: "double", required: false, description: "Target y position"),
1090
+ Param(name: "w", type: "double", required: false, description: "Target width"),
1091
+ Param(name: "h", type: "double", required: false, description: "Target height"),
1092
+ Param(name: "position", type: "string", required: false,
1093
+ description: "Tile position (e.g. center, left, right, bottom-right)"),
1094
+ ],
1095
+ returns: .ok,
1096
+ handler: { params in
1097
+ guard let wid = params?["wid"]?.uint32Value else {
1098
+ throw RouterError.missingParam("wid")
1099
+ }
1100
+ guard let entry = DesktopModel.shared.windows[wid] else {
1101
+ throw RouterError.notFound("window \(wid)")
1102
+ }
1103
+
1104
+ // Resolve position to fractional rect
1105
+ var fractions: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil
1106
+ if let placement = Self.parsePlacement(from: params?["placement"] ?? params?["position"]) {
1107
+ fractions = placement.fractions
1108
+ }
1109
+
1110
+ var frame: CGRect? = nil
1111
+ if let fracs = fractions {
1112
+ let screen = Self.resolveTargetScreen(for: entry, displayIndex: params?["display"]?.intValue)
1113
+ // Compute pixel frame (needs main thread for NSScreen)
1114
+ if Thread.isMainThread {
1115
+ frame = WindowTiler.tileFrame(fractions: fracs, on: screen)
1116
+ } else {
1117
+ DispatchQueue.main.sync {
1118
+ frame = WindowTiler.tileFrame(fractions: fracs, on: screen)
1119
+ }
1120
+ }
1121
+ } else if let x = params?["x"]?.intValue,
1122
+ let y = params?["y"]?.intValue,
1123
+ let w = params?["w"]?.intValue,
1124
+ let h = params?["h"]?.intValue {
1125
+ frame = CGRect(x: x, y: y, width: w, height: h)
1126
+ }
1127
+
1128
+ var presented = false
1129
+ if Thread.isMainThread {
1130
+ presented = WindowTiler.present(wid: wid, pid: entry.pid, frame: frame)
1131
+ } else {
1132
+ DispatchQueue.main.sync {
1133
+ presented = WindowTiler.present(wid: wid, pid: entry.pid, frame: frame)
1134
+ }
1135
+ }
1136
+ return .object(["ok": .bool(presented), "wid": .int(Int(wid)), "app": .string(entry.app)])
1137
+ }
1138
+ ))
1139
+
808
1140
  api.register(Endpoint(
809
1141
  method: "window.move",
810
1142
  description: "Move a session's window to a different space",
@@ -929,20 +1261,27 @@ final class LatticesApi {
929
1261
  ],
930
1262
  returns: .ok,
931
1263
  handler: { params in
932
- let wm = WorkspaceManager.shared
933
- let index: Int
934
- if let i = params?["index"]?.intValue {
935
- index = i
936
- } else if let n = params?["name"]?.stringValue, let i = wm.layerIndex(named: n) {
937
- index = i
938
- } else {
939
- throw RouterError.missingParam("index or name")
1264
+ var dict: [String: JSON] = [:]
1265
+ if case .object(let obj) = params {
1266
+ dict = obj
940
1267
  }
941
- DispatchQueue.main.async {
942
- wm.tileLayer(index: index, launch: true, force: true)
943
- EventBus.shared.post(.layerSwitched(index: index))
944
- }
945
- return .object(["ok": .bool(true)])
1268
+ dict["mode"] = dict["mode"] ?? .string("launch")
1269
+ return try Self.executeLayerActivation(params: .object(dict))
1270
+ }
1271
+ ))
1272
+
1273
+ api.register(Endpoint(
1274
+ method: "layer.activate",
1275
+ description: "Activate a workspace layer using an explicit activation mode",
1276
+ access: .mutate,
1277
+ params: [
1278
+ Param(name: "index", type: "int", required: false, description: "Layer index"),
1279
+ Param(name: "name", type: "string", required: false, description: "Layer id or label (case-insensitive)"),
1280
+ Param(name: "mode", type: "string", required: false, description: "Activation mode: launch, focus, or retile"),
1281
+ ],
1282
+ returns: .custom("Execution receipt with resolved layer, activation mode, and trace"),
1283
+ handler: { params in
1284
+ try Self.executeLayerActivation(params: params)
946
1285
  }
947
1286
  ))
948
1287
 
@@ -1000,18 +1339,483 @@ final class LatticesApi {
1000
1339
 
1001
1340
  api.register(Endpoint(
1002
1341
  method: "layout.distribute",
1003
- description: "Distribute visible windows evenly across the screen",
1342
+ description: "Distribute windows evenly in a grid, optionally filtered by app and constrained to a screen region",
1343
+ access: .mutate,
1344
+ params: [
1345
+ Param(name: "app", type: "string", required: false, description: "Filter to windows of this app (e.g. 'iTerm2')"),
1346
+ Param(name: "region", type: "string", required: false, description: "Constrain grid to a screen region (e.g. 'right', 'left', 'top-right'). Uses tile position names."),
1347
+ ],
1348
+ returns: .ok,
1349
+ handler: { params in
1350
+ var dict: [String: JSON] = [:]
1351
+ if case .object(let obj) = params {
1352
+ dict = obj
1353
+ }
1354
+ // If app is provided, switch to app scope
1355
+ if dict["app"] != nil && dict["scope"] == nil {
1356
+ dict["scope"] = .string("app")
1357
+ } else {
1358
+ dict["scope"] = dict["scope"] ?? .string("visible")
1359
+ }
1360
+ dict["strategy"] = dict["strategy"] ?? .string("balanced")
1361
+ return try Self.executeSpaceOptimization(params: .object(dict))
1362
+ }
1363
+ ))
1364
+
1365
+ api.register(Endpoint(
1366
+ method: "space.optimize",
1367
+ description: "Optimize a set of windows using an explicit scope and strategy",
1368
+ access: .mutate,
1369
+ params: [
1370
+ Param(name: "scope", type: "string", required: false, description: "Optimization scope: visible, active-app, app, or selection"),
1371
+ Param(name: "strategy", type: "string", required: false, description: "Optimization strategy: balanced or mosaic"),
1372
+ Param(name: "app", type: "string", required: false, description: "App name for app-scoped optimization"),
1373
+ Param(name: "title", type: "string", required: false, description: "Optional title substring for app-scoped optimization"),
1374
+ Param(name: "windowIds", type: "[uint32]", required: false, description: "Explicit window selection for selection scope"),
1375
+ ],
1376
+ returns: .custom("Execution receipt with scope, strategy, resolved windows, and trace"),
1377
+ handler: { params in
1378
+ try Self.executeSpaceOptimization(params: params)
1379
+ }
1380
+ ))
1381
+
1382
+ // ── Session Layers ────────────────────────────────────────
1383
+
1384
+ api.model(ApiModel(name: "WindowRef", fields: [
1385
+ Field(name: "id", type: "string", required: true, description: "Stable UUID for this ref"),
1386
+ Field(name: "app", type: "string", required: true, description: "Application name"),
1387
+ Field(name: "contentHint", type: "string", required: false, description: "Title substring hint for matching"),
1388
+ Field(name: "tile", type: "string", required: false, description: "Intended tile position"),
1389
+ Field(name: "display", type: "int", required: false, description: "Intended display index"),
1390
+ Field(name: "wid", type: "int", required: false, description: "Resolved CGWindowID"),
1391
+ Field(name: "pid", type: "int", required: false, description: "Resolved process ID"),
1392
+ Field(name: "title", type: "string", required: false, description: "Resolved window title"),
1393
+ Field(name: "frame", type: "Frame", required: false, description: "Resolved window frame"),
1394
+ ]))
1395
+
1396
+ api.model(ApiModel(name: "SessionLayer", fields: [
1397
+ Field(name: "id", type: "string", required: true, description: "Layer UUID"),
1398
+ Field(name: "name", type: "string", required: true, description: "Layer display name"),
1399
+ Field(name: "windows", type: "[WindowRef]", required: true, description: "Window references in this layer"),
1400
+ ]))
1401
+
1402
+ api.register(Endpoint(
1403
+ method: "session.layers.create",
1404
+ description: "Create a named session layer with optional window references",
1405
+ access: .mutate,
1406
+ params: [
1407
+ Param(name: "name", type: "string", required: true, description: "Layer name"),
1408
+ Param(name: "windowIds", type: "[uint32]", required: false, description: "Window IDs to include"),
1409
+ Param(name: "windows", type: "[object]", required: false, description: "Window refs as {app, contentHint}"),
1410
+ ],
1411
+ returns: .object(model: "SessionLayer"),
1412
+ handler: { params in
1413
+ guard let name = params?["name"]?.stringValue, !name.isEmpty else {
1414
+ throw RouterError.missingParam("name")
1415
+ }
1416
+ var refs: [WindowRef] = []
1417
+
1418
+ // Build refs from windowIds
1419
+ if case .array(let ids) = params?["windowIds"] {
1420
+ for idJson in ids {
1421
+ if let wid = idJson.uint32Value, let entry = DesktopModel.shared.windows[wid] {
1422
+ refs.append(WindowRef(
1423
+ app: entry.app, contentHint: entry.title,
1424
+ wid: entry.wid, pid: entry.pid, title: entry.title, frame: entry.frame
1425
+ ))
1426
+ }
1427
+ }
1428
+ }
1429
+
1430
+ // Build refs from windows array
1431
+ if case .array(let winSpecs) = params?["windows"] {
1432
+ for spec in winSpecs {
1433
+ guard let app = spec["app"]?.stringValue else { continue }
1434
+ let hint = spec["contentHint"]?.stringValue
1435
+ var ref = WindowRef(app: app, contentHint: hint)
1436
+ // Try to resolve immediately
1437
+ if let entry = DesktopModel.shared.windowForApp(app: app, title: hint) {
1438
+ ref.wid = entry.wid
1439
+ ref.pid = entry.pid
1440
+ ref.title = entry.title
1441
+ ref.frame = entry.frame
1442
+ }
1443
+ refs.append(ref)
1444
+ }
1445
+ }
1446
+
1447
+ let layer = SessionLayerStore.shared.create(name: name, windows: refs)
1448
+ // Update layer tags
1449
+ for ref in refs {
1450
+ if let wid = ref.wid {
1451
+ DesktopModel.shared.assignLayer(wid: wid, layerId: name)
1452
+ }
1453
+ }
1454
+ return Encoders.sessionLayer(layer)
1455
+ }
1456
+ ))
1457
+
1458
+ api.register(Endpoint(
1459
+ method: "session.layers.delete",
1460
+ description: "Delete a session layer by id or name",
1461
+ access: .mutate,
1462
+ params: [
1463
+ Param(name: "id", type: "string", required: false, description: "Layer UUID"),
1464
+ Param(name: "name", type: "string", required: false, description: "Layer name"),
1465
+ ],
1466
+ returns: .ok,
1467
+ handler: { params in
1468
+ let store = SessionLayerStore.shared
1469
+ if let id = params?["id"]?.stringValue {
1470
+ store.delete(id: id)
1471
+ } else if let name = params?["name"]?.stringValue, let layer = store.layerByName(name) {
1472
+ store.delete(id: layer.id)
1473
+ } else {
1474
+ throw RouterError.missingParam("id or name")
1475
+ }
1476
+ return .object(["ok": .bool(true)])
1477
+ }
1478
+ ))
1479
+
1480
+ api.register(Endpoint(
1481
+ method: "session.layers.list",
1482
+ description: "List all session layers with resolved window info",
1483
+ access: .read,
1484
+ params: [],
1485
+ returns: .custom("Object with 'layers' array and 'activeIndex'"),
1486
+ handler: { _ in
1487
+ let store = SessionLayerStore.shared
1488
+ return .object([
1489
+ "layers": .array(store.layers.map { Encoders.sessionLayer($0) }),
1490
+ "activeIndex": .int(store.activeIndex)
1491
+ ])
1492
+ }
1493
+ ))
1494
+
1495
+ api.register(Endpoint(
1496
+ method: "session.layers.assign",
1497
+ description: "Add window ref(s) to a session layer",
1498
+ access: .mutate,
1499
+ params: [
1500
+ Param(name: "layerId", type: "string", required: false, description: "Layer UUID"),
1501
+ Param(name: "layerName", type: "string", required: false, description: "Layer name"),
1502
+ Param(name: "wid", type: "uint32", required: false, description: "Single window ID to add"),
1503
+ Param(name: "windowIds", type: "[uint32]", required: false, description: "Multiple window IDs to add"),
1504
+ Param(name: "window", type: "object", required: false, description: "Window ref as {app, contentHint}"),
1505
+ ],
1506
+ returns: .ok,
1507
+ handler: { params in
1508
+ let store = SessionLayerStore.shared
1509
+ let layerId: String
1510
+ if let id = params?["layerId"]?.stringValue {
1511
+ layerId = id
1512
+ } else if let name = params?["layerName"]?.stringValue, let layer = store.layerByName(name) {
1513
+ layerId = layer.id
1514
+ } else {
1515
+ throw RouterError.missingParam("layerId or layerName")
1516
+ }
1517
+
1518
+ if let wid = params?["wid"]?.uint32Value {
1519
+ store.assignByWid(wid, toLayerId: layerId)
1520
+ }
1521
+ if case .array(let ids) = params?["windowIds"] {
1522
+ for idJson in ids {
1523
+ if let wid = idJson.uint32Value {
1524
+ store.assignByWid(wid, toLayerId: layerId)
1525
+ }
1526
+ }
1527
+ }
1528
+ if let spec = params?["window"] {
1529
+ if let app = spec["app"]?.stringValue {
1530
+ let hint = spec["contentHint"]?.stringValue
1531
+ var ref = WindowRef(app: app, contentHint: hint)
1532
+ if let entry = DesktopModel.shared.windowForApp(app: app, title: hint) {
1533
+ ref.wid = entry.wid
1534
+ ref.pid = entry.pid
1535
+ ref.title = entry.title
1536
+ ref.frame = entry.frame
1537
+ }
1538
+ store.assign(ref: ref, toLayerId: layerId)
1539
+ }
1540
+ }
1541
+ return .object(["ok": .bool(true)])
1542
+ }
1543
+ ))
1544
+
1545
+ api.register(Endpoint(
1546
+ method: "session.layers.remove",
1547
+ description: "Remove window ref(s) from a session layer",
1548
+ access: .mutate,
1549
+ params: [
1550
+ Param(name: "layerId", type: "string", required: false, description: "Layer UUID"),
1551
+ Param(name: "layerName", type: "string", required: false, description: "Layer name"),
1552
+ Param(name: "refId", type: "string", required: true, description: "WindowRef ID to remove"),
1553
+ ],
1554
+ returns: .ok,
1555
+ handler: { params in
1556
+ let store = SessionLayerStore.shared
1557
+ let layerId: String
1558
+ if let id = params?["layerId"]?.stringValue {
1559
+ layerId = id
1560
+ } else if let name = params?["layerName"]?.stringValue, let layer = store.layerByName(name) {
1561
+ layerId = layer.id
1562
+ } else {
1563
+ throw RouterError.missingParam("layerId or layerName")
1564
+ }
1565
+ guard let refId = params?["refId"]?.stringValue else {
1566
+ throw RouterError.missingParam("refId")
1567
+ }
1568
+ store.remove(refId: refId, fromLayerId: layerId)
1569
+ return .object(["ok": .bool(true)])
1570
+ }
1571
+ ))
1572
+
1573
+ api.register(Endpoint(
1574
+ method: "session.layers.switch",
1575
+ description: "Switch to a session layer by index or name",
1576
+ access: .mutate,
1577
+ params: [
1578
+ Param(name: "index", type: "int", required: false, description: "Layer index"),
1579
+ Param(name: "name", type: "string", required: false, description: "Layer name"),
1580
+ ],
1581
+ returns: .ok,
1582
+ handler: { params in
1583
+ let store = SessionLayerStore.shared
1584
+ let index: Int
1585
+ if let i = params?["index"]?.intValue {
1586
+ index = i
1587
+ } else if let name = params?["name"]?.stringValue,
1588
+ let i = store.layers.firstIndex(where: { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }) {
1589
+ index = i
1590
+ } else {
1591
+ throw RouterError.missingParam("index or name")
1592
+ }
1593
+ DispatchQueue.main.async {
1594
+ store.switchTo(index: index)
1595
+ }
1596
+ return .object(["ok": .bool(true)])
1597
+ }
1598
+ ))
1599
+
1600
+ api.register(Endpoint(
1601
+ method: "session.layers.rename",
1602
+ description: "Rename a session layer",
1603
+ access: .mutate,
1604
+ params: [
1605
+ Param(name: "id", type: "string", required: false, description: "Layer UUID"),
1606
+ Param(name: "oldName", type: "string", required: false, description: "Current layer name"),
1607
+ Param(name: "name", type: "string", required: true, description: "New layer name"),
1608
+ ],
1609
+ returns: .ok,
1610
+ handler: { params in
1611
+ let store = SessionLayerStore.shared
1612
+ guard let newName = params?["name"]?.stringValue, !newName.isEmpty else {
1613
+ throw RouterError.missingParam("name")
1614
+ }
1615
+ if let id = params?["id"]?.stringValue {
1616
+ store.rename(id: id, name: newName)
1617
+ } else if let oldName = params?["oldName"]?.stringValue, let layer = store.layerByName(oldName) {
1618
+ store.rename(id: layer.id, name: newName)
1619
+ } else {
1620
+ throw RouterError.missingParam("id or oldName")
1621
+ }
1622
+ return .object(["ok": .bool(true)])
1623
+ }
1624
+ ))
1625
+
1626
+ api.register(Endpoint(
1627
+ method: "session.layers.clear",
1628
+ description: "Clear all session layers",
1629
+ access: .mutate,
1630
+ params: [],
1631
+ returns: .ok,
1632
+ handler: { _ in
1633
+ SessionLayerStore.shared.clear()
1634
+ return .object(["ok": .bool(true)])
1635
+ }
1636
+ ))
1637
+
1638
+ // ── Intents ───────────────────────────────────────────────
1639
+
1640
+ api.model(ApiModel(name: "IntentSlot", fields: [
1641
+ Field(name: "name", type: "string", required: true, description: "Slot name"),
1642
+ Field(name: "type", type: "string", required: true, description: "Slot type (string, int, position, query, bool)"),
1643
+ Field(name: "required", type: "bool", required: true, description: "Whether the slot is required"),
1644
+ Field(name: "description", type: "string", required: true, description: "Slot description"),
1645
+ Field(name: "values", type: "[string]", required: false, description: "Allowed values for enum slots"),
1646
+ ]))
1647
+
1648
+ api.model(ApiModel(name: "IntentDef", fields: [
1649
+ Field(name: "intent", type: "string", required: true, description: "Intent identifier"),
1650
+ Field(name: "description", type: "string", required: true, description: "What the intent does"),
1651
+ Field(name: "examples", type: "[string]", required: true, description: "Example phrases"),
1652
+ Field(name: "slots", type: "[IntentSlot]", required: true, description: "Named parameters"),
1653
+ ]))
1654
+
1655
+ api.register(Endpoint(
1656
+ method: "intents.list",
1657
+ description: "List all available intents with their slots and example phrases",
1658
+ access: .read,
1659
+ params: [],
1660
+ returns: .array(model: "IntentDef"),
1661
+ handler: { _ in
1662
+ IntentEngine.shared.catalog()
1663
+ }
1664
+ ))
1665
+
1666
+ api.register(Endpoint(
1667
+ method: "intents.execute",
1668
+ description: "Execute a structured intent (from voice, agent, or script)",
1669
+ access: .mutate,
1670
+ params: [
1671
+ Param(name: "intent", type: "string", required: true, description: "Intent name (e.g. 'tile_window', 'focus', 'launch')"),
1672
+ Param(name: "slots", type: "object", required: false, description: "Named parameters for the intent"),
1673
+ Param(name: "rawText", type: "string", required: false, description: "Original transcription text"),
1674
+ Param(name: "confidence", type: "double", required: false, description: "Transcription confidence (0-1)"),
1675
+ Param(name: "source", type: "string", required: false, description: "Source of the intent (e.g. 'vox', 'siri', 'cli')"),
1676
+ ],
1677
+ returns: .custom("Intent-specific result"),
1678
+ handler: { params in
1679
+ guard let intentName = params?["intent"]?.stringValue else {
1680
+ throw RouterError.missingParam("intent")
1681
+ }
1682
+
1683
+ // Extract slots
1684
+ var slots: [String: JSON] = [:]
1685
+ if case .object(let obj) = params?["slots"] {
1686
+ slots = obj
1687
+ }
1688
+
1689
+ let request = IntentRequest(
1690
+ intent: intentName,
1691
+ slots: slots,
1692
+ rawText: params?["rawText"]?.stringValue,
1693
+ confidence: params?["confidence"]?.numericDouble,
1694
+ source: params?["source"]?.stringValue
1695
+ )
1696
+
1697
+ return try IntentEngine.shared.execute(request)
1698
+ }
1699
+ ))
1700
+
1701
+ // ── Voice / Audio ─────────────────────────────────────────
1702
+
1703
+ api.register(Endpoint(
1704
+ method: "voice.status",
1705
+ description: "Check audio provider status (e.g. Vox availability)",
1706
+ access: .read,
1707
+ params: [],
1708
+ returns: .custom("Provider status with name and listening state"),
1709
+ handler: { _ in
1710
+ let audio = AudioLayer.shared
1711
+ return .object([
1712
+ "provider": .string(audio.providerName),
1713
+ "available": .bool(audio.provider?.isAvailable ?? false),
1714
+ "listening": .bool(audio.isListening),
1715
+ "lastTranscript": audio.lastTranscript.map { .string($0) } ?? .null
1716
+ ])
1717
+ }
1718
+ ))
1719
+
1720
+ api.register(Endpoint(
1721
+ method: "voice.listen",
1722
+ description: "Start voice capture via the audio provider (e.g. Vox)",
1004
1723
  access: .mutate,
1005
1724
  params: [],
1006
1725
  returns: .ok,
1007
1726
  handler: { _ in
1727
+ guard AudioLayer.shared.provider != nil else {
1728
+ throw RouterError.custom("No audio provider available. Is Vox running?")
1729
+ }
1008
1730
  DispatchQueue.main.async {
1009
- WindowTiler.distributeVisible()
1731
+ AudioLayer.shared.startVoiceCommand()
1732
+ }
1733
+ return .object(["ok": .bool(true), "provider": .string(AudioLayer.shared.providerName)])
1734
+ }
1735
+ ))
1736
+
1737
+ api.register(Endpoint(
1738
+ method: "voice.stop",
1739
+ description: "Stop voice capture and process the transcription",
1740
+ access: .mutate,
1741
+ params: [],
1742
+ returns: .ok,
1743
+ handler: { _ in
1744
+ DispatchQueue.main.async {
1745
+ AudioLayer.shared.stopVoiceCommand()
1010
1746
  }
1011
1747
  return .object(["ok": .bool(true)])
1012
1748
  }
1013
1749
  ))
1014
1750
 
1751
+ api.register(Endpoint(
1752
+ method: "voice.simulate",
1753
+ description: "Simulate a voice command: parse text into an intent and execute it",
1754
+ access: .mutate,
1755
+ params: [
1756
+ Param(name: "text", type: "string", required: true, description: "Voice command text (as if transcribed)"),
1757
+ Param(name: "execute", type: "bool", required: false, description: "Actually execute the intent (default true)"),
1758
+ ],
1759
+ returns: .custom("Parsed intent with execution result"),
1760
+ handler: { params in
1761
+ guard let text = params?["text"]?.stringValue, !text.isEmpty else {
1762
+ throw RouterError.missingParam("text")
1763
+ }
1764
+ let shouldExecute = params?["execute"]?.boolValue ?? true
1765
+
1766
+ let matcher = PhraseMatcher.shared
1767
+ guard let matched = matcher.match(text: text) else {
1768
+ return .object([
1769
+ "parsed": .bool(false),
1770
+ "text": .string(text),
1771
+ "intent": .null,
1772
+ "message": .string("No intent matched")
1773
+ ])
1774
+ }
1775
+
1776
+ var response: [String: JSON] = [
1777
+ "parsed": .bool(true),
1778
+ "text": .string(text),
1779
+ "intent": .string(matched.intentName),
1780
+ "slots": .object(matched.slots),
1781
+ "confidence": .double(matched.confidence),
1782
+ ]
1783
+
1784
+ if shouldExecute {
1785
+ do {
1786
+ let result = try matcher.execute(matched)
1787
+ response["executed"] = .bool(true)
1788
+ response["result"] = result
1789
+ } catch {
1790
+ response["executed"] = .bool(false)
1791
+ response["error"] = .string(error.localizedDescription)
1792
+ }
1793
+ }
1794
+
1795
+ return .object(response)
1796
+ }
1797
+ ))
1798
+
1799
+ api.register(Endpoint(
1800
+ method: "voice.reconnect",
1801
+ description: "Force disconnect and reconnect the Vox WebSocket connection",
1802
+ access: .mutate,
1803
+ params: [],
1804
+ returns: .custom("Reconnection initiated with previous and new connection state"),
1805
+ handler: { _ in
1806
+ let client = VoxClient.shared
1807
+ let previousState = "\(client.connectionState)"
1808
+ DispatchQueue.main.async {
1809
+ client.reconnect()
1810
+ }
1811
+ return .object([
1812
+ "ok": .bool(true),
1813
+ "previousState": .string(previousState),
1814
+ "action": .string("reconnecting"),
1815
+ ])
1816
+ }
1817
+ ))
1818
+
1015
1819
  // ── Meta endpoint ───────────────────────────────────────
1016
1820
 
1017
1821
  api.register(Endpoint(
@@ -1027,6 +1831,376 @@ final class LatticesApi {
1027
1831
  }
1028
1832
  }
1029
1833
 
1834
+ private extension LatticesApi {
1835
+ static func parsePlacement(from json: JSON?) -> PlacementSpec? {
1836
+ PlacementSpec(json: json)
1837
+ }
1838
+
1839
+ static func resolveTargetScreen(for entry: WindowEntry?, displayIndex: Int?) -> NSScreen {
1840
+ if let displayIndex, displayIndex >= 0, displayIndex < NSScreen.screens.count {
1841
+ return NSScreen.screens[displayIndex]
1842
+ }
1843
+ if let entry {
1844
+ return WindowTiler.screenForWindowFrame(entry.frame)
1845
+ }
1846
+ return NSScreen.main ?? NSScreen.screens[0]
1847
+ }
1848
+
1849
+ static func frontmostWindowTarget() -> (wid: UInt32, pid: Int32)? {
1850
+ guard let app = NSWorkspace.shared.frontmostApplication,
1851
+ app.bundleIdentifier != "com.arach.lattices" else {
1852
+ return nil
1853
+ }
1854
+
1855
+ let appRef = AXUIElementCreateApplication(app.processIdentifier)
1856
+ var focusedRef: CFTypeRef?
1857
+ guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
1858
+ let focusedWindow = focusedRef else {
1859
+ return nil
1860
+ }
1861
+
1862
+ var wid: CGWindowID = 0
1863
+ guard _AXUIElementGetWindow(focusedWindow as! AXUIElement, &wid) == .success else {
1864
+ return nil
1865
+ }
1866
+ return (UInt32(wid), app.processIdentifier)
1867
+ }
1868
+
1869
+ static func executeWindowPlacement(params: JSON?) throws -> JSON {
1870
+ guard let placement = parsePlacement(from: params?["placement"] ?? params?["position"]) else {
1871
+ throw RouterError.missingParam("placement")
1872
+ }
1873
+
1874
+ let displayIndex = params?["display"]?.intValue
1875
+ var trace: [JSON] = []
1876
+
1877
+ if let wid = params?["wid"]?.uint32Value {
1878
+ guard let entry = DesktopModel.shared.windows[wid] else {
1879
+ throw RouterError.notFound("window \(wid)")
1880
+ }
1881
+ let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
1882
+ trace.append(.string("resolved target by wid"))
1883
+ trace.append(.string("placement \(placement.wireValue)"))
1884
+ DispatchQueue.main.async {
1885
+ WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: placement, on: screen)
1886
+ }
1887
+ return .object([
1888
+ "ok": .bool(true),
1889
+ "target": .string("wid"),
1890
+ "wid": .int(Int(wid)),
1891
+ "app": .string(entry.app),
1892
+ "placement": placement.jsonValue,
1893
+ "trace": .array(trace),
1894
+ ])
1895
+ }
1896
+
1897
+ if let session = params?["session"]?.stringValue {
1898
+ let screen = resolveTargetScreen(
1899
+ for: DesktopModel.shared.windowForSession(session),
1900
+ displayIndex: displayIndex
1901
+ )
1902
+ trace.append(.string("resolved target by session"))
1903
+ trace.append(.string("placement \(placement.wireValue)"))
1904
+
1905
+ if let entry = DesktopModel.shared.windowForSession(session) {
1906
+ DispatchQueue.main.async {
1907
+ WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement, on: screen)
1908
+ }
1909
+ return .object([
1910
+ "ok": .bool(true),
1911
+ "target": .string("session"),
1912
+ "session": .string(session),
1913
+ "wid": .int(Int(entry.wid)),
1914
+ "placement": placement.jsonValue,
1915
+ "trace": .array(trace),
1916
+ ])
1917
+ }
1918
+
1919
+ let terminal = Preferences.shared.terminal
1920
+ trace.append(.string("session window not in DesktopModel; using terminal fallback"))
1921
+ DispatchQueue.main.async {
1922
+ WindowTiler.tile(session: session, terminal: terminal, to: placement, on: screen)
1923
+ }
1924
+ return .object([
1925
+ "ok": .bool(true),
1926
+ "target": .string("session"),
1927
+ "session": .string(session),
1928
+ "placement": placement.jsonValue,
1929
+ "trace": .array(trace),
1930
+ ])
1931
+ }
1932
+
1933
+ if let app = params?["app"]?.stringValue {
1934
+ let title = params?["title"]?.stringValue
1935
+ guard let entry = DesktopModel.shared.windowForApp(app: app, title: title) else {
1936
+ throw RouterError.notFound("window for app \(app)")
1937
+ }
1938
+ let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
1939
+ trace.append(.string("resolved target by app/title match"))
1940
+ trace.append(.string("placement \(placement.wireValue)"))
1941
+ DispatchQueue.main.async {
1942
+ WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement, on: screen)
1943
+ }
1944
+ return .object([
1945
+ "ok": .bool(true),
1946
+ "target": .string("app"),
1947
+ "app": .string(entry.app),
1948
+ "wid": .int(Int(entry.wid)),
1949
+ "placement": placement.jsonValue,
1950
+ "trace": .array(trace),
1951
+ ])
1952
+ }
1953
+
1954
+ if let target = frontmostWindowTarget() {
1955
+ let wid = target.wid
1956
+ let entry = DesktopModel.shared.windows[wid]
1957
+ let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
1958
+ trace.append(.string("resolved target by frontmost window"))
1959
+ trace.append(.string("placement \(placement.wireValue)"))
1960
+ DispatchQueue.main.async {
1961
+ WindowTiler.tileWindowById(wid: wid, pid: target.pid, to: placement, on: screen)
1962
+ }
1963
+
1964
+ var response: [String: JSON] = [
1965
+ "ok": .bool(true),
1966
+ "target": .string("frontmost"),
1967
+ "wid": .int(Int(wid)),
1968
+ "placement": placement.jsonValue,
1969
+ "trace": .array(trace),
1970
+ ]
1971
+ if let entry {
1972
+ response["app"] = .string(entry.app)
1973
+ }
1974
+ return .object(response)
1975
+ }
1976
+
1977
+ throw RouterError.custom("Could not resolve a window target for placement")
1978
+ }
1979
+
1980
+ static func executeLayerActivation(params: JSON?) throws -> JSON {
1981
+ let wm = WorkspaceManager.shared
1982
+ guard let layers = wm.config?.layers, !layers.isEmpty else {
1983
+ throw RouterError.notFound("workspace layers")
1984
+ }
1985
+
1986
+ let index: Int
1987
+ var trace: [JSON] = []
1988
+
1989
+ if let value = params?["index"]?.intValue {
1990
+ index = value
1991
+ trace.append(.string("resolved layer by index"))
1992
+ } else if let name = params?["name"]?.stringValue, let value = wm.layerIndex(named: name) {
1993
+ index = value
1994
+ trace.append(.string("resolved layer by name"))
1995
+ } else {
1996
+ throw RouterError.missingParam("index or name")
1997
+ }
1998
+
1999
+ guard index >= 0, index < layers.count else {
2000
+ throw RouterError.notFound("layer \(index)")
2001
+ }
2002
+
2003
+ let mode = try parseLayerActivationMode(params?["mode"]?.stringValue)
2004
+ let layer = layers[index]
2005
+ let previousIndex = wm.activeLayerIndex
2006
+ trace.append(.string("activation mode \(mode)"))
2007
+
2008
+ DispatchQueue.main.async {
2009
+ switch mode {
2010
+ case "focus":
2011
+ wm.focusLayer(index: index)
2012
+ case "retile":
2013
+ wm.tileLayer(index: index, launch: false, force: true)
2014
+ default:
2015
+ wm.tileLayer(index: index, launch: true, force: true)
2016
+ }
2017
+
2018
+ if previousIndex != index || mode != "focus" {
2019
+ EventBus.shared.post(.layerSwitched(index: index))
2020
+ }
2021
+ }
2022
+
2023
+ return .object([
2024
+ "ok": .bool(true),
2025
+ "index": .int(index),
2026
+ "id": .string(layer.id),
2027
+ "label": .string(layer.label),
2028
+ "mode": .string(mode),
2029
+ "trace": .array(trace),
2030
+ ])
2031
+ }
2032
+
2033
+ static func executeSpaceOptimization(params: JSON?) throws -> JSON {
2034
+ let scope = try parseOptimizationScope(from: params)
2035
+ let strategy = try parseOptimizationStrategy(params?["strategy"]?.stringValue)
2036
+ var trace: [JSON] = [.string("resolved scope \(scope)"), .string("resolved strategy \(strategy)")]
2037
+ let windows = resolveOptimizationTargets(scope: scope, params: params, trace: &trace)
2038
+
2039
+ // Resolve optional region constraint (e.g. "right" → right half of screen)
2040
+ var region: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil
2041
+ if let regionStr = params?["region"]?.stringValue {
2042
+ if let spec = PlacementSpec(string: regionStr) {
2043
+ region = spec.fractions
2044
+ trace.append(.string("region \(regionStr) → fractions \(spec.fractions)"))
2045
+ } else {
2046
+ trace.append(.string("unknown region \(regionStr), using full screen"))
2047
+ }
2048
+ }
2049
+
2050
+ if strategy == "mosaic" {
2051
+ trace.append(.string("strategy mosaic currently uses the smart-grid distributor"))
2052
+ }
2053
+
2054
+ guard !windows.isEmpty else {
2055
+ trace.append(.string("no eligible windows resolved"))
2056
+ return .object([
2057
+ "ok": .bool(true),
2058
+ "scope": .string(scope),
2059
+ "strategy": .string(strategy),
2060
+ "windowCount": .int(0),
2061
+ "wids": .array([]),
2062
+ "trace": .array(trace),
2063
+ ])
2064
+ }
2065
+
2066
+ let targets = windows.map { (wid: $0.wid, pid: $0.pid) }
2067
+ DispatchQueue.main.async {
2068
+ WindowTiler.batchRaiseAndDistribute(windows: targets, region: region)
2069
+ }
2070
+
2071
+ return .object([
2072
+ "ok": .bool(true),
2073
+ "scope": .string(scope),
2074
+ "strategy": .string(strategy),
2075
+ "windowCount": .int(windows.count),
2076
+ "wids": .array(windows.map { .int(Int($0.wid)) }),
2077
+ "trace": .array(trace),
2078
+ ])
2079
+ }
2080
+
2081
+ static func parseLayerActivationMode(_ raw: String?) throws -> String {
2082
+ let mode = normalizeToken(raw ?? "launch")
2083
+ switch mode {
2084
+ case "launch", "focus", "retile":
2085
+ return mode
2086
+ default:
2087
+ throw RouterError.custom("Unsupported layer activation mode: \(raw ?? mode)")
2088
+ }
2089
+ }
2090
+
2091
+ static func parseOptimizationScope(from params: JSON?) throws -> String {
2092
+ if params?["windowIds"] != nil {
2093
+ return "selection"
2094
+ }
2095
+ if params?["app"] != nil {
2096
+ return "app"
2097
+ }
2098
+
2099
+ let scope = normalizeToken(params?["scope"]?.stringValue ?? "visible")
2100
+ switch scope {
2101
+ case "visible", "selection", "app", "active-app", "frontmost-app", "current-app":
2102
+ return scope
2103
+ default:
2104
+ throw RouterError.custom("Unsupported optimization scope: \(params?["scope"]?.stringValue ?? scope)")
2105
+ }
2106
+ }
2107
+
2108
+ static func parseOptimizationStrategy(_ raw: String?) throws -> String {
2109
+ let strategy = normalizeToken(raw ?? "balanced")
2110
+ switch strategy {
2111
+ case "balanced", "mosaic":
2112
+ return strategy
2113
+ default:
2114
+ throw RouterError.custom("Unsupported optimization strategy: \(raw ?? strategy)")
2115
+ }
2116
+ }
2117
+
2118
+ static func resolveOptimizationTargets(scope: String, params: JSON?, trace: inout [JSON]) -> [WindowEntry] {
2119
+ let visible = distributableWindows()
2120
+ let titleFilter = params?["title"]?.stringValue
2121
+
2122
+ switch scope {
2123
+ case "selection":
2124
+ let ids = selectedWindowIds(from: params?["windowIds"])
2125
+ trace.append(.string("selection size \(ids.count)"))
2126
+ return dedupeWindows(visible.filter { ids.contains($0.wid) })
2127
+
2128
+ case "app":
2129
+ guard let app = params?["app"]?.stringValue else {
2130
+ trace.append(.string("missing app for app scope"))
2131
+ return []
2132
+ }
2133
+ trace.append(.string("filtered by app \(app)"))
2134
+ if let titleFilter {
2135
+ trace.append(.string("title contains \(titleFilter)"))
2136
+ }
2137
+ return dedupeWindows(visible.filter {
2138
+ $0.app.localizedCaseInsensitiveCompare(app) == .orderedSame &&
2139
+ (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2140
+ })
2141
+
2142
+ case "active-app", "frontmost-app", "current-app":
2143
+ let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
2144
+ guard let activeApp else {
2145
+ trace.append(.string("no active app available"))
2146
+ return []
2147
+ }
2148
+ trace.append(.string("resolved active app \(activeApp)"))
2149
+ if let titleFilter {
2150
+ trace.append(.string("title contains \(titleFilter)"))
2151
+ }
2152
+ return dedupeWindows(visible.filter {
2153
+ $0.app.localizedCaseInsensitiveCompare(activeApp) == .orderedSame &&
2154
+ (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2155
+ })
2156
+
2157
+ default:
2158
+ trace.append(.string("using visible window scope"))
2159
+ return dedupeWindows(visible)
2160
+ }
2161
+ }
2162
+
2163
+ static func selectedWindowIds(from json: JSON?) -> [UInt32] {
2164
+ guard case .array(let values) = json else { return [] }
2165
+ return values.compactMap(\.uint32Value)
2166
+ }
2167
+
2168
+ static func distributableWindows() -> [WindowEntry] {
2169
+ DesktopModel.shared.allWindows().filter { entry in
2170
+ entry.isOnScreen &&
2171
+ entry.app != "Lattices" &&
2172
+ entry.frame.w > 50 &&
2173
+ entry.frame.h > 50
2174
+ }
2175
+ }
2176
+
2177
+ static func dedupeWindows(_ windows: [WindowEntry]) -> [WindowEntry] {
2178
+ var seen: Set<UInt32> = []
2179
+ var result: [WindowEntry] = []
2180
+ for window in windows where !seen.contains(window.wid) {
2181
+ seen.insert(window.wid)
2182
+ result.append(window)
2183
+ }
2184
+ return result
2185
+ }
2186
+
2187
+ static func frontmostOptimizableApp() -> String? {
2188
+ if let app = NSWorkspace.shared.frontmostApplication?.localizedName,
2189
+ !app.localizedCaseInsensitiveContains("lattices") {
2190
+ return app
2191
+ }
2192
+ return distributableWindows().first?.app
2193
+ }
2194
+
2195
+ static func normalizeToken(_ raw: String) -> String {
2196
+ raw
2197
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2198
+ .lowercased()
2199
+ .replacingOccurrences(of: "_", with: "-")
2200
+ .replacingOccurrences(of: " ", with: "-")
2201
+ }
2202
+ }
2203
+
1030
2204
  // MARK: - Encoders
1031
2205
 
1032
2206
  enum Encoders {
@@ -1043,7 +2217,8 @@ enum Encoders {
1043
2217
  "h": .double(w.frame.h)
1044
2218
  ]),
1045
2219
  "spaceIds": .array(w.spaceIds.map { .int($0) }),
1046
- "isOnScreen": .bool(w.isOnScreen)
2220
+ "isOnScreen": .bool(w.isOnScreen),
2221
+ "axVerified": .bool(w.axVerified)
1047
2222
  ]
1048
2223
  if let session = w.latticesSession {
1049
2224
  obj["latticesSession"] = .string(session)
@@ -1215,6 +2390,34 @@ enum Encoders {
1215
2390
  if let pm = p.packageManager { obj["packageManager"] = .string(pm) }
1216
2391
  return .object(obj)
1217
2392
  }
2393
+
2394
+ static func windowRef(_ ref: WindowRef) -> JSON {
2395
+ var obj: [String: JSON] = [
2396
+ "id": .string(ref.id),
2397
+ "app": .string(ref.app),
2398
+ ]
2399
+ if let hint = ref.contentHint { obj["contentHint"] = .string(hint) }
2400
+ if let tile = ref.tile { obj["tile"] = .string(tile) }
2401
+ if let display = ref.display { obj["display"] = .int(display) }
2402
+ if let wid = ref.wid { obj["wid"] = .int(Int(wid)) }
2403
+ if let pid = ref.pid { obj["pid"] = .int(Int(pid)) }
2404
+ if let title = ref.title { obj["title"] = .string(title) }
2405
+ if let frame = ref.frame {
2406
+ obj["frame"] = .object([
2407
+ "x": .double(frame.x), "y": .double(frame.y),
2408
+ "w": .double(frame.w), "h": .double(frame.h)
2409
+ ])
2410
+ }
2411
+ return .object(obj)
2412
+ }
2413
+
2414
+ static func sessionLayer(_ layer: SessionLayer) -> JSON {
2415
+ .object([
2416
+ "id": .string(layer.id),
2417
+ "name": .string(layer.name),
2418
+ "windows": .array(layer.windows.map { windowRef($0) })
2419
+ ])
2420
+ }
1218
2421
  }
1219
2422
 
1220
2423
  // MARK: - Errors
@@ -1223,12 +2426,14 @@ enum RouterError: LocalizedError {
1223
2426
  case unknownMethod(String)
1224
2427
  case missingParam(String)
1225
2428
  case notFound(String)
2429
+ case custom(String)
1226
2430
 
1227
2431
  var errorDescription: String? {
1228
2432
  switch self {
1229
2433
  case .unknownMethod(let m): return "Unknown method: \(m)"
1230
2434
  case .missingParam(let p): return "Missing parameter: \(p)"
1231
2435
  case .notFound(let what): return "Not found: \(what)"
2436
+ case .custom(let msg): return msg
1232
2437
  }
1233
2438
  }
1234
2439
  }