@lattices/cli 0.3.0 → 0.4.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.
Files changed (111) hide show
  1. package/README.md +85 -9
  2. package/app/Info.plist +30 -0
  3. package/app/Lattices.app/Contents/Info.plist +8 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  6. package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
  7. package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
  8. package/app/Lattices.entitlements +15 -0
  9. package/app/Package.swift +8 -1
  10. package/app/Resources/tap.wav +0 -0
  11. package/app/Sources/AdvisorLearningStore.swift +90 -0
  12. package/app/Sources/AgentSession.swift +377 -0
  13. package/app/Sources/AppDelegate.swift +45 -12
  14. package/app/Sources/AppShellView.swift +81 -8
  15. package/app/Sources/AudioProvider.swift +386 -0
  16. package/app/Sources/CheatSheetHUD.swift +261 -19
  17. package/app/Sources/DaemonProtocol.swift +13 -0
  18. package/app/Sources/DaemonServer.swift +8 -0
  19. package/app/Sources/DesktopModel.swift +189 -6
  20. package/app/Sources/DesktopModelTypes.swift +2 -0
  21. package/app/Sources/DiagnosticLog.swift +104 -2
  22. package/app/Sources/EventBus.swift +1 -0
  23. package/app/Sources/HUDBottomBar.swift +279 -0
  24. package/app/Sources/HUDController.swift +1158 -0
  25. package/app/Sources/HUDLeftBar.swift +849 -0
  26. package/app/Sources/HUDMinimap.swift +179 -0
  27. package/app/Sources/HUDRightBar.swift +774 -0
  28. package/app/Sources/HUDState.swift +367 -0
  29. package/app/Sources/HUDTopBar.swift +243 -0
  30. package/app/Sources/HandsOffSession.swift +802 -0
  31. package/app/Sources/HomeDashboardView.swift +125 -0
  32. package/app/Sources/HotkeyManager.swift +2 -0
  33. package/app/Sources/HotkeyStore.swift +49 -9
  34. package/app/Sources/IntentEngine.swift +962 -0
  35. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  36. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  37. package/app/Sources/Intents/FocusIntent.swift +69 -0
  38. package/app/Sources/Intents/HelpIntent.swift +41 -0
  39. package/app/Sources/Intents/KillIntent.swift +47 -0
  40. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  41. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  42. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  43. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  44. package/app/Sources/Intents/ScanIntent.swift +52 -0
  45. package/app/Sources/Intents/SearchIntent.swift +190 -0
  46. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  47. package/app/Sources/Intents/TileIntent.swift +61 -0
  48. package/app/Sources/LatticesApi.swift +1275 -30
  49. package/app/Sources/LauncherHUD.swift +348 -0
  50. package/app/Sources/MainView.swift +147 -44
  51. package/app/Sources/MouseFinder.swift +222 -0
  52. package/app/Sources/OcrModel.swift +34 -1
  53. package/app/Sources/OmniSearchState.swift +99 -102
  54. package/app/Sources/OnboardingView.swift +457 -0
  55. package/app/Sources/PermissionChecker.swift +2 -12
  56. package/app/Sources/PiChatDock.swift +454 -0
  57. package/app/Sources/PiChatSession.swift +815 -0
  58. package/app/Sources/PiWorkspaceView.swift +364 -0
  59. package/app/Sources/PlacementSpec.swift +195 -0
  60. package/app/Sources/Preferences.swift +59 -0
  61. package/app/Sources/ProjectScanner.swift +58 -45
  62. package/app/Sources/ScreenMapState.swift +701 -55
  63. package/app/Sources/ScreenMapView.swift +843 -103
  64. package/app/Sources/ScreenMapWindowController.swift +22 -0
  65. package/app/Sources/SessionLayerStore.swift +285 -0
  66. package/app/Sources/SessionManager.swift +4 -1
  67. package/app/Sources/SettingsView.swift +186 -3
  68. package/app/Sources/Theme.swift +9 -8
  69. package/app/Sources/TmuxModel.swift +7 -0
  70. package/app/Sources/TmuxQuery.swift +27 -3
  71. package/app/Sources/VoiceChatView.swift +192 -0
  72. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  73. package/app/Sources/VoiceIntentResolver.swift +671 -0
  74. package/app/Sources/VoxClient.swift +454 -0
  75. package/app/Sources/WindowTiler.swift +348 -87
  76. package/app/Sources/WorkspaceManager.swift +127 -18
  77. package/app/Tests/StageDragTests.swift +333 -0
  78. package/app/Tests/StageJoinTests.swift +313 -0
  79. package/app/Tests/StageManagerTests.swift +280 -0
  80. package/app/Tests/StageTileTests.swift +353 -0
  81. package/assets/AppIcon.icns +0 -0
  82. package/bin/client.ts +16 -0
  83. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  84. package/bin/handsoff-infer.ts +280 -0
  85. package/bin/handsoff-worker.ts +740 -0
  86. package/bin/lattices-app.ts +338 -0
  87. package/bin/lattices-dev +208 -0
  88. package/bin/{lattices.js → lattices.ts} +777 -140
  89. package/bin/project-twin.ts +645 -0
  90. package/docs/agent-execution-plan.md +562 -0
  91. package/docs/agent-layer-guide.md +207 -0
  92. package/docs/agents.md +142 -0
  93. package/docs/api.md +153 -34
  94. package/docs/app.md +29 -1
  95. package/docs/config.md +5 -1
  96. package/docs/handsoff-test-scenarios.md +84 -0
  97. package/docs/layers.md +20 -20
  98. package/docs/ocr.md +14 -5
  99. package/docs/overview.md +5 -1
  100. package/docs/presentation-execution-review.md +491 -0
  101. package/docs/prompts/hands-off-system.md +374 -0
  102. package/docs/prompts/hands-off-turn.md +30 -0
  103. package/docs/prompts/voice-advisor.md +31 -0
  104. package/docs/prompts/voice-fallback.md +23 -0
  105. package/docs/tiling-reference.md +167 -0
  106. package/docs/twins.md +138 -0
  107. package/docs/voice-command-protocol.md +278 -0
  108. package/docs/voice.md +219 -0
  109. package/package.json +29 -11
  110. package/bin/client.js +0 -4
  111. package/bin/lattices-app.js +0 -221
@@ -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,20 +1339,525 @@ 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)",
1723
+ access: .mutate,
1724
+ params: [],
1725
+ returns: .ok,
1726
+ handler: { _ in
1727
+ guard AudioLayer.shared.provider != nil else {
1728
+ throw RouterError.custom("No audio provider available. Is Vox running?")
1729
+ }
1730
+ DispatchQueue.main.async {
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",
1004
1740
  access: .mutate,
1005
1741
  params: [],
1006
1742
  returns: .ok,
1007
1743
  handler: { _ in
1008
1744
  DispatchQueue.main.async {
1009
- WindowTiler.distributeVisible()
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
 
1821
+ // ── Mouse Finder ────────────────────────────────────────
1822
+
1823
+ api.register(Endpoint(
1824
+ method: "mouse.find",
1825
+ description: "Show a sonar pulse at the current mouse cursor position",
1826
+ access: .read,
1827
+ params: [],
1828
+ returns: .ok,
1829
+ handler: { _ in
1830
+ DispatchQueue.main.async { MouseFinder.shared.find() }
1831
+ let pos = NSEvent.mouseLocation
1832
+ return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
1833
+ }
1834
+ ))
1835
+
1836
+ api.register(Endpoint(
1837
+ method: "mouse.summon",
1838
+ description: "Warp the mouse cursor to screen center (or a given point) and show a sonar pulse",
1839
+ access: .mutate,
1840
+ params: [
1841
+ Param(name: "x", type: "int", required: false, description: "Target X coordinate (screen, bottom-left origin)"),
1842
+ Param(name: "y", type: "int", required: false, description: "Target Y coordinate (screen, bottom-left origin)"),
1843
+ ],
1844
+ returns: .ok,
1845
+ handler: { params in
1846
+ let target: CGPoint?
1847
+ if let x = params?["x"]?.intValue, let y = params?["y"]?.intValue {
1848
+ target = CGPoint(x: CGFloat(x), y: CGFloat(y))
1849
+ } else {
1850
+ target = nil
1851
+ }
1852
+ DispatchQueue.main.async { MouseFinder.shared.summon(to: target) }
1853
+ let pos = target ?? {
1854
+ let screen = NSScreen.main ?? NSScreen.screens[0]
1855
+ return CGPoint(x: screen.frame.midX, y: screen.frame.midY)
1856
+ }()
1857
+ return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
1858
+ }
1859
+ ))
1860
+
1017
1861
  api.register(Endpoint(
1018
1862
  method: "api.schema",
1019
1863
  description: "Get the full API schema including all methods and models",
@@ -1027,6 +1871,376 @@ final class LatticesApi {
1027
1871
  }
1028
1872
  }
1029
1873
 
1874
+ private extension LatticesApi {
1875
+ static func parsePlacement(from json: JSON?) -> PlacementSpec? {
1876
+ PlacementSpec(json: json)
1877
+ }
1878
+
1879
+ static func resolveTargetScreen(for entry: WindowEntry?, displayIndex: Int?) -> NSScreen {
1880
+ if let displayIndex, displayIndex >= 0, displayIndex < NSScreen.screens.count {
1881
+ return NSScreen.screens[displayIndex]
1882
+ }
1883
+ if let entry {
1884
+ return WindowTiler.screenForWindowFrame(entry.frame)
1885
+ }
1886
+ return NSScreen.main ?? NSScreen.screens[0]
1887
+ }
1888
+
1889
+ static func frontmostWindowTarget() -> (wid: UInt32, pid: Int32)? {
1890
+ guard let app = NSWorkspace.shared.frontmostApplication,
1891
+ app.bundleIdentifier != "com.arach.lattices" else {
1892
+ return nil
1893
+ }
1894
+
1895
+ let appRef = AXUIElementCreateApplication(app.processIdentifier)
1896
+ var focusedRef: CFTypeRef?
1897
+ guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
1898
+ let focusedWindow = focusedRef else {
1899
+ return nil
1900
+ }
1901
+
1902
+ var wid: CGWindowID = 0
1903
+ guard _AXUIElementGetWindow(focusedWindow as! AXUIElement, &wid) == .success else {
1904
+ return nil
1905
+ }
1906
+ return (UInt32(wid), app.processIdentifier)
1907
+ }
1908
+
1909
+ static func executeWindowPlacement(params: JSON?) throws -> JSON {
1910
+ guard let placement = parsePlacement(from: params?["placement"] ?? params?["position"]) else {
1911
+ throw RouterError.missingParam("placement")
1912
+ }
1913
+
1914
+ let displayIndex = params?["display"]?.intValue
1915
+ var trace: [JSON] = []
1916
+
1917
+ if let wid = params?["wid"]?.uint32Value {
1918
+ guard let entry = DesktopModel.shared.windows[wid] else {
1919
+ throw RouterError.notFound("window \(wid)")
1920
+ }
1921
+ let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
1922
+ trace.append(.string("resolved target by wid"))
1923
+ trace.append(.string("placement \(placement.wireValue)"))
1924
+ DispatchQueue.main.async {
1925
+ WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: placement, on: screen)
1926
+ }
1927
+ return .object([
1928
+ "ok": .bool(true),
1929
+ "target": .string("wid"),
1930
+ "wid": .int(Int(wid)),
1931
+ "app": .string(entry.app),
1932
+ "placement": placement.jsonValue,
1933
+ "trace": .array(trace),
1934
+ ])
1935
+ }
1936
+
1937
+ if let session = params?["session"]?.stringValue {
1938
+ let screen = resolveTargetScreen(
1939
+ for: DesktopModel.shared.windowForSession(session),
1940
+ displayIndex: displayIndex
1941
+ )
1942
+ trace.append(.string("resolved target by session"))
1943
+ trace.append(.string("placement \(placement.wireValue)"))
1944
+
1945
+ if let entry = DesktopModel.shared.windowForSession(session) {
1946
+ DispatchQueue.main.async {
1947
+ WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement, on: screen)
1948
+ }
1949
+ return .object([
1950
+ "ok": .bool(true),
1951
+ "target": .string("session"),
1952
+ "session": .string(session),
1953
+ "wid": .int(Int(entry.wid)),
1954
+ "placement": placement.jsonValue,
1955
+ "trace": .array(trace),
1956
+ ])
1957
+ }
1958
+
1959
+ let terminal = Preferences.shared.terminal
1960
+ trace.append(.string("session window not in DesktopModel; using terminal fallback"))
1961
+ DispatchQueue.main.async {
1962
+ WindowTiler.tile(session: session, terminal: terminal, to: placement, on: screen)
1963
+ }
1964
+ return .object([
1965
+ "ok": .bool(true),
1966
+ "target": .string("session"),
1967
+ "session": .string(session),
1968
+ "placement": placement.jsonValue,
1969
+ "trace": .array(trace),
1970
+ ])
1971
+ }
1972
+
1973
+ if let app = params?["app"]?.stringValue {
1974
+ let title = params?["title"]?.stringValue
1975
+ guard let entry = DesktopModel.shared.windowForApp(app: app, title: title) else {
1976
+ throw RouterError.notFound("window for app \(app)")
1977
+ }
1978
+ let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
1979
+ trace.append(.string("resolved target by app/title match"))
1980
+ trace.append(.string("placement \(placement.wireValue)"))
1981
+ DispatchQueue.main.async {
1982
+ WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement, on: screen)
1983
+ }
1984
+ return .object([
1985
+ "ok": .bool(true),
1986
+ "target": .string("app"),
1987
+ "app": .string(entry.app),
1988
+ "wid": .int(Int(entry.wid)),
1989
+ "placement": placement.jsonValue,
1990
+ "trace": .array(trace),
1991
+ ])
1992
+ }
1993
+
1994
+ if let target = frontmostWindowTarget() {
1995
+ let wid = target.wid
1996
+ let entry = DesktopModel.shared.windows[wid]
1997
+ let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
1998
+ trace.append(.string("resolved target by frontmost window"))
1999
+ trace.append(.string("placement \(placement.wireValue)"))
2000
+ DispatchQueue.main.async {
2001
+ WindowTiler.tileWindowById(wid: wid, pid: target.pid, to: placement, on: screen)
2002
+ }
2003
+
2004
+ var response: [String: JSON] = [
2005
+ "ok": .bool(true),
2006
+ "target": .string("frontmost"),
2007
+ "wid": .int(Int(wid)),
2008
+ "placement": placement.jsonValue,
2009
+ "trace": .array(trace),
2010
+ ]
2011
+ if let entry {
2012
+ response["app"] = .string(entry.app)
2013
+ }
2014
+ return .object(response)
2015
+ }
2016
+
2017
+ throw RouterError.custom("Could not resolve a window target for placement")
2018
+ }
2019
+
2020
+ static func executeLayerActivation(params: JSON?) throws -> JSON {
2021
+ let wm = WorkspaceManager.shared
2022
+ guard let layers = wm.config?.layers, !layers.isEmpty else {
2023
+ throw RouterError.notFound("workspace layers")
2024
+ }
2025
+
2026
+ let index: Int
2027
+ var trace: [JSON] = []
2028
+
2029
+ if let value = params?["index"]?.intValue {
2030
+ index = value
2031
+ trace.append(.string("resolved layer by index"))
2032
+ } else if let name = params?["name"]?.stringValue, let value = wm.layerIndex(named: name) {
2033
+ index = value
2034
+ trace.append(.string("resolved layer by name"))
2035
+ } else {
2036
+ throw RouterError.missingParam("index or name")
2037
+ }
2038
+
2039
+ guard index >= 0, index < layers.count else {
2040
+ throw RouterError.notFound("layer \(index)")
2041
+ }
2042
+
2043
+ let mode = try parseLayerActivationMode(params?["mode"]?.stringValue)
2044
+ let layer = layers[index]
2045
+ let previousIndex = wm.activeLayerIndex
2046
+ trace.append(.string("activation mode \(mode)"))
2047
+
2048
+ DispatchQueue.main.async {
2049
+ switch mode {
2050
+ case "focus":
2051
+ wm.focusLayer(index: index)
2052
+ case "retile":
2053
+ wm.tileLayer(index: index, launch: false, force: true)
2054
+ default:
2055
+ wm.tileLayer(index: index, launch: true, force: true)
2056
+ }
2057
+
2058
+ if previousIndex != index || mode != "focus" {
2059
+ EventBus.shared.post(.layerSwitched(index: index))
2060
+ }
2061
+ }
2062
+
2063
+ return .object([
2064
+ "ok": .bool(true),
2065
+ "index": .int(index),
2066
+ "id": .string(layer.id),
2067
+ "label": .string(layer.label),
2068
+ "mode": .string(mode),
2069
+ "trace": .array(trace),
2070
+ ])
2071
+ }
2072
+
2073
+ static func executeSpaceOptimization(params: JSON?) throws -> JSON {
2074
+ let scope = try parseOptimizationScope(from: params)
2075
+ let strategy = try parseOptimizationStrategy(params?["strategy"]?.stringValue)
2076
+ var trace: [JSON] = [.string("resolved scope \(scope)"), .string("resolved strategy \(strategy)")]
2077
+ let windows = resolveOptimizationTargets(scope: scope, params: params, trace: &trace)
2078
+
2079
+ // Resolve optional region constraint (e.g. "right" → right half of screen)
2080
+ var region: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil
2081
+ if let regionStr = params?["region"]?.stringValue {
2082
+ if let spec = PlacementSpec(string: regionStr) {
2083
+ region = spec.fractions
2084
+ trace.append(.string("region \(regionStr) → fractions \(spec.fractions)"))
2085
+ } else {
2086
+ trace.append(.string("unknown region \(regionStr), using full screen"))
2087
+ }
2088
+ }
2089
+
2090
+ if strategy == "mosaic" {
2091
+ trace.append(.string("strategy mosaic currently uses the smart-grid distributor"))
2092
+ }
2093
+
2094
+ guard !windows.isEmpty else {
2095
+ trace.append(.string("no eligible windows resolved"))
2096
+ return .object([
2097
+ "ok": .bool(true),
2098
+ "scope": .string(scope),
2099
+ "strategy": .string(strategy),
2100
+ "windowCount": .int(0),
2101
+ "wids": .array([]),
2102
+ "trace": .array(trace),
2103
+ ])
2104
+ }
2105
+
2106
+ let targets = windows.map { (wid: $0.wid, pid: $0.pid) }
2107
+ DispatchQueue.main.async {
2108
+ WindowTiler.batchRaiseAndDistribute(windows: targets, region: region)
2109
+ }
2110
+
2111
+ return .object([
2112
+ "ok": .bool(true),
2113
+ "scope": .string(scope),
2114
+ "strategy": .string(strategy),
2115
+ "windowCount": .int(windows.count),
2116
+ "wids": .array(windows.map { .int(Int($0.wid)) }),
2117
+ "trace": .array(trace),
2118
+ ])
2119
+ }
2120
+
2121
+ static func parseLayerActivationMode(_ raw: String?) throws -> String {
2122
+ let mode = normalizeToken(raw ?? "launch")
2123
+ switch mode {
2124
+ case "launch", "focus", "retile":
2125
+ return mode
2126
+ default:
2127
+ throw RouterError.custom("Unsupported layer activation mode: \(raw ?? mode)")
2128
+ }
2129
+ }
2130
+
2131
+ static func parseOptimizationScope(from params: JSON?) throws -> String {
2132
+ if params?["windowIds"] != nil {
2133
+ return "selection"
2134
+ }
2135
+ if params?["app"] != nil {
2136
+ return "app"
2137
+ }
2138
+
2139
+ let scope = normalizeToken(params?["scope"]?.stringValue ?? "visible")
2140
+ switch scope {
2141
+ case "visible", "selection", "app", "active-app", "frontmost-app", "current-app":
2142
+ return scope
2143
+ default:
2144
+ throw RouterError.custom("Unsupported optimization scope: \(params?["scope"]?.stringValue ?? scope)")
2145
+ }
2146
+ }
2147
+
2148
+ static func parseOptimizationStrategy(_ raw: String?) throws -> String {
2149
+ let strategy = normalizeToken(raw ?? "balanced")
2150
+ switch strategy {
2151
+ case "balanced", "mosaic":
2152
+ return strategy
2153
+ default:
2154
+ throw RouterError.custom("Unsupported optimization strategy: \(raw ?? strategy)")
2155
+ }
2156
+ }
2157
+
2158
+ static func resolveOptimizationTargets(scope: String, params: JSON?, trace: inout [JSON]) -> [WindowEntry] {
2159
+ let visible = distributableWindows()
2160
+ let titleFilter = params?["title"]?.stringValue
2161
+
2162
+ switch scope {
2163
+ case "selection":
2164
+ let ids = selectedWindowIds(from: params?["windowIds"])
2165
+ trace.append(.string("selection size \(ids.count)"))
2166
+ return dedupeWindows(visible.filter { ids.contains($0.wid) })
2167
+
2168
+ case "app":
2169
+ guard let app = params?["app"]?.stringValue else {
2170
+ trace.append(.string("missing app for app scope"))
2171
+ return []
2172
+ }
2173
+ trace.append(.string("filtered by app \(app)"))
2174
+ if let titleFilter {
2175
+ trace.append(.string("title contains \(titleFilter)"))
2176
+ }
2177
+ return dedupeWindows(visible.filter {
2178
+ $0.app.localizedCaseInsensitiveCompare(app) == .orderedSame &&
2179
+ (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2180
+ })
2181
+
2182
+ case "active-app", "frontmost-app", "current-app":
2183
+ let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
2184
+ guard let activeApp else {
2185
+ trace.append(.string("no active app available"))
2186
+ return []
2187
+ }
2188
+ trace.append(.string("resolved active app \(activeApp)"))
2189
+ if let titleFilter {
2190
+ trace.append(.string("title contains \(titleFilter)"))
2191
+ }
2192
+ return dedupeWindows(visible.filter {
2193
+ $0.app.localizedCaseInsensitiveCompare(activeApp) == .orderedSame &&
2194
+ (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2195
+ })
2196
+
2197
+ default:
2198
+ trace.append(.string("using visible window scope"))
2199
+ return dedupeWindows(visible)
2200
+ }
2201
+ }
2202
+
2203
+ static func selectedWindowIds(from json: JSON?) -> [UInt32] {
2204
+ guard case .array(let values) = json else { return [] }
2205
+ return values.compactMap(\.uint32Value)
2206
+ }
2207
+
2208
+ static func distributableWindows() -> [WindowEntry] {
2209
+ DesktopModel.shared.allWindows().filter { entry in
2210
+ entry.isOnScreen &&
2211
+ entry.app != "Lattices" &&
2212
+ entry.frame.w > 50 &&
2213
+ entry.frame.h > 50
2214
+ }
2215
+ }
2216
+
2217
+ static func dedupeWindows(_ windows: [WindowEntry]) -> [WindowEntry] {
2218
+ var seen: Set<UInt32> = []
2219
+ var result: [WindowEntry] = []
2220
+ for window in windows where !seen.contains(window.wid) {
2221
+ seen.insert(window.wid)
2222
+ result.append(window)
2223
+ }
2224
+ return result
2225
+ }
2226
+
2227
+ static func frontmostOptimizableApp() -> String? {
2228
+ if let app = NSWorkspace.shared.frontmostApplication?.localizedName,
2229
+ !app.localizedCaseInsensitiveContains("lattices") {
2230
+ return app
2231
+ }
2232
+ return distributableWindows().first?.app
2233
+ }
2234
+
2235
+ static func normalizeToken(_ raw: String) -> String {
2236
+ raw
2237
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2238
+ .lowercased()
2239
+ .replacingOccurrences(of: "_", with: "-")
2240
+ .replacingOccurrences(of: " ", with: "-")
2241
+ }
2242
+ }
2243
+
1030
2244
  // MARK: - Encoders
1031
2245
 
1032
2246
  enum Encoders {
@@ -1043,7 +2257,8 @@ enum Encoders {
1043
2257
  "h": .double(w.frame.h)
1044
2258
  ]),
1045
2259
  "spaceIds": .array(w.spaceIds.map { .int($0) }),
1046
- "isOnScreen": .bool(w.isOnScreen)
2260
+ "isOnScreen": .bool(w.isOnScreen),
2261
+ "axVerified": .bool(w.axVerified)
1047
2262
  ]
1048
2263
  if let session = w.latticesSession {
1049
2264
  obj["latticesSession"] = .string(session)
@@ -1215,6 +2430,34 @@ enum Encoders {
1215
2430
  if let pm = p.packageManager { obj["packageManager"] = .string(pm) }
1216
2431
  return .object(obj)
1217
2432
  }
2433
+
2434
+ static func windowRef(_ ref: WindowRef) -> JSON {
2435
+ var obj: [String: JSON] = [
2436
+ "id": .string(ref.id),
2437
+ "app": .string(ref.app),
2438
+ ]
2439
+ if let hint = ref.contentHint { obj["contentHint"] = .string(hint) }
2440
+ if let tile = ref.tile { obj["tile"] = .string(tile) }
2441
+ if let display = ref.display { obj["display"] = .int(display) }
2442
+ if let wid = ref.wid { obj["wid"] = .int(Int(wid)) }
2443
+ if let pid = ref.pid { obj["pid"] = .int(Int(pid)) }
2444
+ if let title = ref.title { obj["title"] = .string(title) }
2445
+ if let frame = ref.frame {
2446
+ obj["frame"] = .object([
2447
+ "x": .double(frame.x), "y": .double(frame.y),
2448
+ "w": .double(frame.w), "h": .double(frame.h)
2449
+ ])
2450
+ }
2451
+ return .object(obj)
2452
+ }
2453
+
2454
+ static func sessionLayer(_ layer: SessionLayer) -> JSON {
2455
+ .object([
2456
+ "id": .string(layer.id),
2457
+ "name": .string(layer.name),
2458
+ "windows": .array(layer.windows.map { windowRef($0) })
2459
+ ])
2460
+ }
1218
2461
  }
1219
2462
 
1220
2463
  // MARK: - Errors
@@ -1223,12 +2466,14 @@ enum RouterError: LocalizedError {
1223
2466
  case unknownMethod(String)
1224
2467
  case missingParam(String)
1225
2468
  case notFound(String)
2469
+ case custom(String)
1226
2470
 
1227
2471
  var errorDescription: String? {
1228
2472
  switch self {
1229
2473
  case .unknownMethod(let m): return "Unknown method: \(m)"
1230
2474
  case .missingParam(let p): return "Missing parameter: \(p)"
1231
2475
  case .notFound(let what): return "Not found: \(what)"
2476
+ case .custom(let msg): return msg
1232
2477
  }
1233
2478
  }
1234
2479
  }