@lattices/cli 0.4.14 → 0.5.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 (180) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +2 -2
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +60 -1
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/reference/dewey.config.ts +2 -2
  19. package/docs/release.md +171 -0
  20. package/docs/repo-structure.md +4 -5
  21. package/docs/voice.md +11 -27
  22. package/package.json +9 -10
  23. package/apps/mac/Package.swift +0 -27
  24. package/apps/mac/Sources/AppShell/App.swift +0 -26
  25. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  26. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  27. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  28. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  29. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  30. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  31. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  32. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  33. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  34. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  35. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  36. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  37. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  38. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  39. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  41. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  42. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  43. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  44. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  45. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  46. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  47. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  48. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  49. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  50. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  51. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  52. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  53. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  54. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  55. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  56. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  57. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  58. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  59. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  60. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  61. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  62. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  63. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  64. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  65. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  66. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  70. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  71. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  72. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  73. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  74. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  75. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  76. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  77. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  78. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  79. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  80. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  81. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  82. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  83. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  84. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  85. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  86. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  87. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  88. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  90. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  91. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  92. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  93. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  94. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  95. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  98. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  99. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  100. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  101. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  102. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  103. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  104. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  105. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  106. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  107. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  110. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  112. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  113. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  120. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  121. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  122. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  125. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  126. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  129. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  130. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  131. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  132. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  133. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  134. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  135. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  136. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  137. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  138. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  139. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  140. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  141. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  142. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  143. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  144. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  145. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  146. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  147. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  148. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  149. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  150. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  151. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  152. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  153. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  154. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  155. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  156. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  158. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  160. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  161. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  162. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  163. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  164. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  165. package/apps/mac/Sources/UI/Theme.swift +0 -164
  166. package/apps/mac/Tests/StageDragTests.swift +0 -333
  167. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  168. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  169. package/apps/mac/Tests/StageTileTests.swift +0 -353
  170. package/swift/Package.swift +0 -20
  171. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  172. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  173. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  174. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  175. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  176. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  177. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  178. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  179. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  180. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,839 +0,0 @@
1
- import AppKit
2
-
3
- /// Hands-off voice mode: hotkey → listen → worker handles everything.
4
- ///
5
- /// Architecture:
6
- /// - Swift owns: hotkey, Vox dictation, action execution
7
- /// - Worker owns: inference (Groq), TTS (streaming OpenAI), fast path matching, audio caching
8
- /// - Worker is a long-running bun process, started once, communicates via JSON lines over stdio
9
- ///
10
- /// The worker handles the full turn orchestration in parallel:
11
- /// - Fast path: local match → cached ack + execute + cached confirm (~300ms)
12
- /// - Slow path: cached ack ∥ Groq inference → streaming TTS ∥ execute (~2s)
13
-
14
- // MARK: - Chat Log Entry
15
-
16
- struct VoiceChatEntry: Identifiable, Equatable {
17
- let id = UUID()
18
- let timestamp: Date
19
- let role: Role
20
- let text: String
21
- /// Optional structured data — actions taken, search results, etc.
22
- /// Displayable in the chat log but not spoken.
23
- let detail: String?
24
-
25
- enum Role: String, Equatable {
26
- case user // what the user said
27
- case assistant // spoken response
28
- case system // silent info (actions executed, search results, etc.)
29
- }
30
-
31
- static func == (lhs: VoiceChatEntry, rhs: VoiceChatEntry) -> Bool {
32
- lhs.id == rhs.id
33
- }
34
- }
35
-
36
- final class HandsOffSession: ObservableObject {
37
- static let shared = HandsOffSession()
38
-
39
- enum State: Equatable {
40
- case idle
41
- case connecting
42
- case listening
43
- case thinking
44
- }
45
-
46
- @Published var state: State = .idle {
47
- didSet {
48
- if state != oldValue {
49
- stateChangedAt = Date()
50
- }
51
- }
52
- }
53
- @Published private(set) var stateChangedAt: Date = Date()
54
- @Published var lastTranscript: String?
55
- @Published var lastResponse: String?
56
- @Published var audibleFeedbackEnabled: Bool = false
57
-
58
- /// Recently executed actions — shown as playback in the HUD bottom bar
59
- @Published var recentActions: [[String: Any]] = []
60
-
61
- /// Frame history for undo — stores pre-move frames of windows touched by the last turn
62
- struct FrameSnapshot {
63
- let wid: UInt32
64
- let pid: Int32
65
- let frame: WindowFrame
66
- }
67
- private(set) var frameHistory: [FrameSnapshot] = []
68
- private(set) var frameHistoryUpdatedAt: Date?
69
-
70
- /// Snapshot current frames for all windows that are about to be moved.
71
- /// Stores frames in CG/AX coordinates (top-left origin) for direct use with batchRestoreWindows.
72
- func snapshotFrames(wids: [UInt32]) {
73
- frameHistory.removeAll()
74
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return }
75
- for wid in wids {
76
- guard let entry = DesktopModel.shared.windows[wid] else { continue }
77
- for info in windowList {
78
- guard let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
79
- let dict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
80
- var rect = CGRect.zero
81
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
82
- let frame = WindowFrame(x: rect.origin.x, y: rect.origin.y, w: rect.width, h: rect.height)
83
- frameHistory.append(FrameSnapshot(wid: wid, pid: entry.pid, frame: frame))
84
- }
85
- break
86
- }
87
- }
88
- frameHistoryUpdatedAt = frameHistory.isEmpty ? nil : Date()
89
- }
90
-
91
- func clearFrameHistory() {
92
- frameHistory.removeAll()
93
- frameHistoryUpdatedAt = nil
94
- }
95
-
96
- /// Running chat log — visible in the voice chat panel. Persists across turns.
97
- @Published private(set) var chatLog: [VoiceChatEntry] = []
98
- private let maxChatEntries = 50
99
-
100
- private var turnCount = 0
101
- @Published private(set) var conversationHistory: [[String: String]] = []
102
- private let maxHistoryTurns = 10
103
-
104
- // Long-running worker process
105
- private var workerProcess: Process?
106
- private var workerStdin: FileHandle?
107
- private var workerBuffer = ""
108
- private let workerQueue = DispatchQueue(label: "com.lattices.handsoff-worker", qos: .userInitiated)
109
- private var lastCueAt: Date = .distantPast
110
- private var workerRoot: String? {
111
- if let idx = CommandLine.arguments.firstIndex(of: "--lattices-cli-root"),
112
- CommandLine.arguments.indices.contains(idx + 1) {
113
- return CommandLine.arguments[idx + 1]
114
- }
115
-
116
- let devRoot = NSHomeDirectory() + "/dev/lattices"
117
- return FileManager.default.fileExists(atPath: devRoot) ? devRoot : nil
118
- }
119
-
120
- /// JSONL log for full turn data — ~/.lattices/handsoff.jsonl
121
- private let turnLogPath = NSHomeDirectory() + "/.lattices/handsoff.jsonl"
122
-
123
- private init() {}
124
-
125
- // MARK: - Chat Log
126
-
127
- func appendChat(_ role: VoiceChatEntry.Role, text: String, detail: String? = nil) {
128
- let entry = VoiceChatEntry(timestamp: Date(), role: role, text: text, detail: detail)
129
- DispatchQueue.main.async {
130
- self.chatLog.append(entry)
131
- if self.chatLog.count > self.maxChatEntries {
132
- self.chatLog.removeFirst(self.chatLog.count - self.maxChatEntries)
133
- }
134
- }
135
- }
136
-
137
- func clearChatLog() {
138
- DispatchQueue.main.async { self.chatLog.removeAll() }
139
- }
140
-
141
- // MARK: - Lifecycle
142
-
143
- func start() {
144
- // Worker startup is lazy — only start it when a voice turn or cached cue needs it.
145
- }
146
-
147
- func setAudibleFeedbackEnabled(_ enabled: Bool) {
148
- audibleFeedbackEnabled = enabled
149
- if enabled {
150
- startWorker()
151
- }
152
- }
153
-
154
- func playCachedCue(_ phrase: String) {
155
- guard audibleFeedbackEnabled else { return }
156
- let now = Date()
157
- guard now.timeIntervalSince(lastCueAt) >= 0.2 else { return }
158
- lastCueAt = now
159
- startWorker()
160
- sendToWorker(["cmd": "play_cached", "text": phrase])
161
- }
162
-
163
- /// Append a full turn record to the JSONL log
164
- private func logTurn(transcript: String, response: [String: Any], turnMs: Int) {
165
- let snapshot = buildSnapshot()
166
- var record: [String: Any] = [
167
- "ts": ISO8601DateFormatter().string(from: Date()),
168
- "turn": turnCount,
169
- "transcript": transcript,
170
- "turnMs": turnMs,
171
- "snapshot": snapshot,
172
- ]
173
- if let data = response["data"] as? [String: Any] {
174
- record["actions"] = data["actions"]
175
- record["spoken"] = data["spoken"]
176
- record["meta"] = data["_meta"]
177
- }
178
-
179
- guard let jsonData = try? JSONSerialization.data(withJSONObject: record),
180
- var line = String(data: jsonData, encoding: .utf8) else { return }
181
- line += "\n"
182
-
183
- if let handle = FileHandle(forWritingAtPath: turnLogPath) {
184
- handle.seekToEndOfFile()
185
- handle.write(line.data(using: .utf8)!)
186
- handle.closeFile()
187
- } else {
188
- FileManager.default.createFile(atPath: turnLogPath, contents: line.data(using: .utf8))
189
- }
190
- }
191
-
192
- @discardableResult
193
- private func startWorker() -> Bool {
194
- if workerProcess?.isRunning == true, workerStdin != nil {
195
- return true
196
- }
197
-
198
- let bunPaths = [
199
- NSHomeDirectory() + "/.bun/bin/bun",
200
- "/usr/local/bin/bun",
201
- "/opt/homebrew/bin/bun",
202
- ]
203
- guard let bunPath = bunPaths.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else {
204
- DiagnosticLog.shared.warn("HandsOff: bun not found, worker disabled")
205
- return false
206
- }
207
-
208
- guard let workerRoot else {
209
- DiagnosticLog.shared.warn("HandsOff: worker root not found, worker disabled")
210
- return false
211
- }
212
-
213
- let scriptPath = workerRoot + "/bin/handsoff-worker.ts"
214
- guard FileManager.default.fileExists(atPath: scriptPath) else {
215
- DiagnosticLog.shared.warn("HandsOff: worker script not found at \(scriptPath)")
216
- return false
217
- }
218
-
219
- let proc = Process()
220
- proc.executableURL = URL(fileURLWithPath: bunPath)
221
- proc.arguments = ["run", scriptPath]
222
- proc.currentDirectoryURL = URL(fileURLWithPath: workerRoot)
223
-
224
- var env = ProcessInfo.processInfo.environment
225
- env.removeValue(forKey: "CLAUDECODE")
226
- proc.environment = env
227
-
228
- let inPipe = Pipe()
229
- let outPipe = Pipe()
230
- let errPipe = Pipe()
231
- proc.standardInput = inPipe
232
- proc.standardOutput = outPipe
233
- proc.standardError = errPipe
234
-
235
- do {
236
- try proc.run()
237
- } catch {
238
- DiagnosticLog.shared.warn("HandsOff: failed to start worker — \(error)")
239
- return false
240
- }
241
-
242
- workerProcess = proc
243
- workerStdin = inPipe.fileHandleForWriting
244
-
245
- // Read stdout for responses
246
- outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
247
- let data = handle.availableData
248
- guard !data.isEmpty, let str = String(data: data, encoding: .utf8) else { return }
249
- self?.handleWorkerOutput(str)
250
- }
251
-
252
- // Log stderr
253
- errPipe.fileHandleForReading.readabilityHandler = { handle in
254
- let data = handle.availableData
255
- guard !data.isEmpty, let str = String(data: data, encoding: .utf8) else { return }
256
- for line in str.components(separatedBy: "\n") where !line.isEmpty {
257
- DiagnosticLog.shared.info("HandsOff worker: \(line)")
258
- }
259
- }
260
-
261
- // Handle worker crash → restart
262
- proc.terminationHandler = { [weak self] proc in
263
- guard let self else { return }
264
- let keepWarm = self.audibleFeedbackEnabled || self.state != .idle
265
- let suffix = keepWarm ? ", restarting in 2s" : ", staying idle"
266
- DiagnosticLog.shared.warn("HandsOff: worker exited (code \(proc.terminationStatus))\(suffix)")
267
- self.workerProcess = nil
268
- self.workerStdin = nil
269
- guard keepWarm else { return }
270
- DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
271
- self.startWorker()
272
- }
273
- }
274
-
275
- // Ping to verify
276
- sendToWorker(["cmd": "ping"])
277
- DiagnosticLog.shared.info("HandsOff: worker started (pid \(proc.processIdentifier))")
278
- return true
279
- }
280
-
281
- // MARK: - Worker communication
282
-
283
- private var pendingCallback: (([String: Any]) -> Void)?
284
- private var turnTimeoutWork: DispatchWorkItem?
285
- private static let turnTimeoutSeconds: TimeInterval = 30
286
-
287
- private func sendToWorker(_ dict: [String: Any]) {
288
- guard let data = try? JSONSerialization.data(withJSONObject: dict),
289
- var str = String(data: data, encoding: .utf8) else { return }
290
- str += "\n"
291
- workerQueue.async { [weak self] in
292
- self?.workerStdin?.write(str.data(using: .utf8)!)
293
- }
294
- }
295
-
296
- private func sendToWorkerWithCallback(_ dict: [String: Any], callback: @escaping ([String: Any]) -> Void) {
297
- pendingCallback = callback
298
- sendToWorker(dict)
299
- }
300
-
301
- private func handleWorkerOutput(_ str: String) {
302
- workerBuffer += str
303
- let lines = workerBuffer.components(separatedBy: "\n")
304
- workerBuffer = lines.last ?? ""
305
-
306
- for line in lines.dropLast() {
307
- let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
308
- guard !trimmed.isEmpty,
309
- let data = trimmed.data(using: .utf8),
310
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
311
- else { continue }
312
-
313
- DiagnosticLog.shared.info("HandsOff: worker response → \(trimmed)")
314
-
315
- // Parse everything on the background thread, then do ONE main-queue dispatch
316
- // to update all @Published properties atomically. Scattered dispatches cause
317
- // Combine deadlocks (os_unfair_lock contention with SwiftUI rendering).
318
- let dataObj = json["data"] as? [String: Any]
319
- let spoken = dataObj?["spoken"] as? String
320
- let actions = dataObj?["actions"] as? [[String: Any]]
321
- let cb = pendingCallback
322
- pendingCallback = nil
323
-
324
- // Build chat entries off-main
325
- var chatEntries: [(VoiceChatEntry.Role, String)] = []
326
- if let spoken { chatEntries.append((.assistant, spoken)) }
327
- if let actions, !actions.isEmpty {
328
- let summaries = actions.compactMap { action -> String? in
329
- guard let intent = action["intent"] as? String else { return nil }
330
- let slots = action["slots"] as? [String: Any] ?? [:]
331
- let target = slots["app"] as? String ?? slots["query"] as? String ?? ""
332
- let pos = slots["position"] as? String ?? ""
333
- return [intent, target, pos].filter { !$0.isEmpty }.joined(separator: " ")
334
- }
335
- if !summaries.isEmpty {
336
- chatEntries.append((.system, summaries.joined(separator: ", ")))
337
- }
338
- }
339
-
340
- // Single dispatch — all @Published mutations in one block.
341
- // The pending callback also mutates @Published state, so it must
342
- // run on main with the rest of the turn completion.
343
- DispatchQueue.main.async { [weak self] in
344
- guard let self else { return }
345
- if let spoken { self.lastResponse = spoken }
346
- for (role, text) in chatEntries {
347
- self.chatLog.append(VoiceChatEntry(timestamp: Date(), role: role, text: text, detail: nil))
348
- }
349
- if self.chatLog.count > self.maxChatEntries {
350
- self.chatLog.removeFirst(self.chatLog.count - self.maxChatEntries)
351
- }
352
- if let actions, !actions.isEmpty {
353
- self.recentActions = actions
354
- self.executeActions(actions)
355
- }
356
- self.state = .idle
357
- cb?(json)
358
- }
359
- }
360
- }
361
-
362
- // MARK: - Toggle
363
-
364
- func toggle() {
365
- switch state {
366
- case .idle:
367
- beginListening()
368
- case .listening:
369
- finishListening()
370
- case .thinking:
371
- cancelTurn()
372
- case .connecting:
373
- cancel()
374
- }
375
- }
376
-
377
- func cancel() {
378
- cancelVoxSession()
379
- state = .idle
380
- DiagnosticLog.shared.info("HandsOff: cancelled")
381
- }
382
-
383
- private func cancelTurn() {
384
- turnTimeoutWork?.cancel()
385
- turnTimeoutWork = nil
386
- pendingCallback = nil
387
- state = .idle
388
- DiagnosticLog.shared.warn("HandsOff: turn cancelled by user")
389
- playSound("Funk")
390
- }
391
-
392
- /// Cancel any active Vox recording session without transcribing.
393
- private func cancelVoxSession() {
394
- guard VoxClient.shared.activeSessionId != nil else { return }
395
- DiagnosticLog.shared.info("HandsOff: cancelling Vox session")
396
- VoxClient.shared.cancelSession()
397
- }
398
-
399
- // MARK: - Voice capture
400
-
401
- private func beginListening() {
402
- let client = VoxClient.shared
403
-
404
- if client.connectionState != .connected {
405
- state = .connecting
406
- client.connect()
407
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
408
- self?.retryListenIfConnected(attempts: 5)
409
- }
410
- return
411
- }
412
-
413
- startDictation()
414
- }
415
-
416
- private func retryListenIfConnected(attempts: Int) {
417
- if VoxClient.shared.connectionState == .connected {
418
- startDictation()
419
- } else if attempts > 0 {
420
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
421
- self?.retryListenIfConnected(attempts: attempts - 1)
422
- }
423
- } else {
424
- state = .idle
425
- DiagnosticLog.shared.warn("HandsOff: Vox not available")
426
- playSound("Basso")
427
- }
428
- }
429
-
430
- /// Guard against double-processing the transcript (session.final + completion can both deliver it).
431
- private var turnProcessed = false
432
-
433
- private func startDictation() {
434
- state = .listening
435
- lastTranscript = nil
436
- turnProcessed = false
437
- playSound("Tink")
438
-
439
- DiagnosticLog.shared.info("HandsOff: listening...")
440
-
441
- // Vox live session: startSession opens the mic, events flow on the start call ID.
442
- // session.final arrives via onProgress, then the same data arrives via completion.
443
- // We process the transcript from whichever arrives first to be resilient against
444
- // connection drops between the two.
445
- VoxClient.shared.startSession(
446
- onProgress: { [weak self] event, data in
447
- DispatchQueue.main.async {
448
- guard let self else { return }
449
- switch event {
450
- case "session.state":
451
- let sessionState = data["state"] as? String ?? ""
452
- DiagnosticLog.shared.info("HandsOff: session → \(sessionState)")
453
- // Vox cancelled the session (e.g. recording timeout)
454
- if sessionState == "cancelled" {
455
- let reason = data["reason"] as? String ?? "unknown"
456
- DiagnosticLog.shared.warn("HandsOff: Vox cancelled session — \(reason)")
457
- if self.state == .listening {
458
- self.state = .idle
459
- self.playSound("Basso")
460
- }
461
- }
462
- case "session.final":
463
- // Primary transcript delivery — process immediately
464
- if let text = data["text"] as? String, !text.isEmpty {
465
- self.lastTranscript = text
466
- self.deliverTranscript(text)
467
- }
468
- default:
469
- break
470
- }
471
- }
472
- },
473
- completion: { [weak self] result in
474
- DispatchQueue.main.async {
475
- guard let self else { return }
476
- switch result {
477
- case .success(let data):
478
- let text = data["text"] as? String ?? ""
479
- if text.isEmpty {
480
- if !self.turnProcessed {
481
- self.state = .idle
482
- DiagnosticLog.shared.info("HandsOff: no speech detected")
483
- }
484
- } else {
485
- // Fallback — deliver if session.final didn't already
486
- self.lastTranscript = text
487
- self.deliverTranscript(text)
488
- }
489
- case .failure(let error):
490
- if !self.turnProcessed {
491
- self.state = .idle
492
- DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
493
- self.playSound("Basso")
494
- }
495
- }
496
- }
497
- }
498
- )
499
- }
500
-
501
- /// Deliver transcript exactly once — called from both session.final and completion.
502
- private func deliverTranscript(_ text: String) {
503
- guard !turnProcessed else { return }
504
- turnProcessed = true
505
- DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
506
- appendChat(.user, text: text)
507
- processTurn(text)
508
- }
509
-
510
- func finishListening() {
511
- guard state == .listening else { return }
512
- playSound("Tink")
513
- VoxClient.shared.stopSession()
514
- }
515
-
516
- // MARK: - Turn processing (delegates to worker)
517
-
518
- private func processTurn(_ transcript: String) {
519
- state = .thinking
520
- guard startWorker() else {
521
- state = .idle
522
- DiagnosticLog.shared.warn("HandsOff: worker unavailable")
523
- playSound("Basso")
524
- return
525
- }
526
- turnCount += 1
527
-
528
- let turnStart = Date()
529
- DiagnosticLog.shared.info("HandsOff: ⏱ turn \(turnCount) — '\(transcript)'")
530
-
531
- // Build snapshot
532
- let snapshot = buildSnapshot()
533
-
534
- // Send turn to worker — it handles ack, inference, TTS, everything in parallel
535
- let turnCmd: [String: Any] = [
536
- "cmd": "turn",
537
- "transcript": transcript,
538
- "snapshot": snapshot,
539
- "history": conversationHistory,
540
- ]
541
-
542
- // Start turn timeout — forcibly reset if worker never responds
543
- turnTimeoutWork?.cancel()
544
- let timeout = DispatchWorkItem { [weak self] in
545
- guard let self, self.state == .thinking else { return }
546
- DiagnosticLog.shared.warn("HandsOff: ⏱ turn \(self.turnCount) timed out after \(Int(Self.turnTimeoutSeconds))s")
547
- self.pendingCallback = nil
548
- self.state = .idle
549
- self.playSound("Basso")
550
- }
551
- turnTimeoutWork = timeout
552
- DispatchQueue.main.asyncAfter(deadline: .now() + Self.turnTimeoutSeconds, execute: timeout)
553
-
554
- sendToWorkerWithCallback(turnCmd) { [weak self] response in
555
- guard let self else { return }
556
-
557
- // Cancel the timeout — we got a response
558
- self.turnTimeoutWork?.cancel()
559
- self.turnTimeoutWork = nil
560
-
561
- let turnMs = Int(Date().timeIntervalSince(turnStart) * 1000)
562
- DiagnosticLog.shared.info("HandsOff: ⏱ turn \(self.turnCount) complete — \(turnMs)ms")
563
-
564
- // Log full turn to JSONL
565
- self.logTurn(transcript: transcript, response: response, turnMs: turnMs)
566
-
567
- // Record history
568
- if let data = response["data"] as? [String: Any] {
569
- let responseStr = (try? String(data: JSONSerialization.data(withJSONObject: data), encoding: .utf8)) ?? ""
570
- self.conversationHistory.append(["role": "user", "content": transcript])
571
- self.conversationHistory.append(["role": "assistant", "content": responseStr])
572
- if self.conversationHistory.count > self.maxHistoryTurns * 2 {
573
- self.conversationHistory = Array(self.conversationHistory.suffix(self.maxHistoryTurns * 2))
574
- }
575
- }
576
- }
577
- }
578
-
579
- // MARK: - Desktop snapshot (full context — all windows, all screens)
580
-
581
- private func buildSnapshot() -> [String: Any] {
582
- let allWindows = DesktopModel.shared.allWindows()
583
- let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
584
- let grouping = UserDefaults(suiteName: "com.apple.WindowManager")?.integer(forKey: "AppWindowGroupingBehavior") ?? 0
585
-
586
- // All windows — no filtering. Order is front-to-back (Z-order).
587
- let windowList: [[String: Any]] = allWindows.enumerated().map { (zIndex, w) in
588
- var entry: [String: Any] = [
589
- "wid": w.wid,
590
- "app": w.app,
591
- "title": w.title,
592
- "frame": "\(Int(w.frame.x)),\(Int(w.frame.y)) \(Int(w.frame.w))x\(Int(w.frame.h))",
593
- "onScreen": w.isOnScreen,
594
- "zIndex": zIndex, // 0 = frontmost
595
- ]
596
- if let session = w.latticesSession {
597
- entry["session"] = session
598
- }
599
- if !w.spaceIds.isEmpty {
600
- entry["spaces"] = w.spaceIds
601
- }
602
- return entry
603
- }
604
-
605
- // All screens
606
- let screens: [[String: Any]] = NSScreen.screens.enumerated().map { (i, s) in
607
- [
608
- "index": i + 1,
609
- "width": Int(s.frame.width),
610
- "height": Int(s.frame.height),
611
- "isMain": s == NSScreen.main,
612
- "visibleWidth": Int(s.visibleFrame.width),
613
- "visibleHeight": Int(s.visibleFrame.height),
614
- ]
615
- }
616
-
617
- // Layers
618
- var layerInfo: [String: Any]?
619
- let layerStore = SessionLayerStore.shared
620
- if layerStore.activeIndex >= 0 && layerStore.activeIndex < layerStore.layers.count {
621
- let current = layerStore.layers[layerStore.activeIndex]
622
- layerInfo = ["name": current.name, "index": layerStore.activeIndex]
623
- }
624
-
625
- // Terminal enrichment — cwd, running commands, claude, tmux sessions
626
- let terminals = ProcessModel.shared.synthesizeTerminals()
627
- let terminalList: [[String: Any]] = terminals.compactMap { inst in
628
- var entry: [String: Any] = [
629
- "tty": inst.tty,
630
- "hasClaude": inst.hasClaude,
631
- "displayName": inst.displayName,
632
- "isActiveTab": inst.isActiveTab,
633
- ]
634
- if let cwd = inst.cwd { entry["cwd"] = cwd }
635
- if let app = inst.app { entry["app"] = app.rawValue }
636
- if let session = inst.tmuxSession { entry["tmuxSession"] = session }
637
- if let wid = inst.windowId { entry["windowId"] = Int(wid) }
638
- if let title = inst.tabTitle { entry["tabTitle"] = title }
639
- // Top running command (most useful for context)
640
- let userProcesses = inst.processes.filter {
641
- !["zsh", "bash", "fish", "login", "-zsh", "-bash"].contains($0.comm)
642
- }
643
- if !userProcesses.isEmpty {
644
- entry["runningCommands"] = userProcesses.map { proc in
645
- var cmd: [String: Any] = ["command": proc.comm]
646
- if let cwd = proc.cwd { cmd["cwd"] = cwd }
647
- return cmd
648
- }
649
- }
650
- return entry
651
- }
652
-
653
- // Tmux sessions
654
- let tmuxSessions = TmuxModel.shared.sessions
655
- let tmuxList: [[String: Any]] = tmuxSessions.map { s in
656
- [
657
- "name": s.name,
658
- "windowCount": s.windowCount,
659
- "attached": s.attached,
660
- ]
661
- }
662
-
663
- var snapshot: [String: Any] = [
664
- "stageManager": smEnabled,
665
- "smGrouping": grouping == 0 ? "all-at-once" : "one-at-a-time",
666
- "windows": windowList,
667
- "terminals": terminalList,
668
- "screens": screens,
669
- "windowCount": allWindows.count,
670
- "onScreenCount": allWindows.filter(\.isOnScreen).count,
671
- ]
672
- if !tmuxList.isEmpty { snapshot["tmuxSessions"] = tmuxList }
673
- if let layerInfo { snapshot["currentLayer"] = layerInfo }
674
-
675
- return snapshot
676
- }
677
-
678
- // MARK: - Action execution
679
-
680
- /// Hard cap on simultaneous actions. Rearranging 20+ windows is never right.
681
- /// distribute is exempt because it's a single intent that handles all windows safely.
682
- private static let maxActions = 6
683
-
684
- private func executeActions(_ actions: [[String: Any]]) {
685
- // Snapshot frames of all windows about to be moved (for undo)
686
- let movingWids: [UInt32] = actions.compactMap { action in
687
- let intent = action["intent"] as? String ?? ""
688
- guard ["tile_window", "swap", "distribute", "move_to_display"].contains(intent) else { return nil }
689
- let slots = action["slots"] as? [String: Any] ?? [:]
690
- return (slots["wid"] as? NSNumber)?.uint32Value
691
- ?? (slots["wid_a"] as? NSNumber)?.uint32Value
692
- }
693
- // Also grab wid_b from swap actions
694
- let swapBWids: [UInt32] = actions.compactMap { action in
695
- let slots = action["slots"] as? [String: Any] ?? [:]
696
- return (slots["wid_b"] as? NSNumber)?.uint32Value
697
- }
698
- snapshotFrames(wids: movingWids + swapBWids)
699
-
700
- // Guard: refuse to execute bulk operations that would be disorienting
701
- let nonDistributeActions = actions.filter { ($0["intent"] as? String) != "distribute" }
702
- if nonDistributeActions.count > Self.maxActions {
703
- DiagnosticLog.shared.warn(
704
- "HandsOff: BLOCKED — \(nonDistributeActions.count) actions exceeds limit of \(Self.maxActions). " +
705
- "Skipping execution to avoid disorienting window rearrangement."
706
- )
707
- return
708
- }
709
-
710
- // Smart distribution: when multiple tile_window actions target the same
711
- // position, subdivide that region instead of stacking windows on top of each other.
712
- let distributed = distributeTileActions(actions)
713
-
714
- for action in distributed {
715
- guard let intent = action["intent"] as? String else { continue }
716
- let slots = action["slots"] as? [String: Any] ?? [:]
717
-
718
- let jsonSlots = slots.reduce(into: [String: JSON]()) { dict, pair in
719
- if let s = pair.value as? String {
720
- dict[pair.key] = .string(s)
721
- } else if let n = pair.value as? Int {
722
- dict[pair.key] = .int(n)
723
- } else if let b = pair.value as? Bool {
724
- dict[pair.key] = .bool(b)
725
- }
726
- }
727
-
728
- let match = IntentMatch(
729
- intentName: intent,
730
- slots: jsonSlots,
731
- confidence: 0.95,
732
- matchedPhrase: "hands-off"
733
- )
734
-
735
- do {
736
- _ = try PhraseMatcher.shared.execute(match)
737
- DiagnosticLog.shared.success("HandsOff: \(intent) executed")
738
- } catch {
739
- DiagnosticLog.shared.warn("HandsOff: \(intent) failed — \(error.localizedDescription)")
740
- }
741
- }
742
- }
743
-
744
- /// When multiple tile_window actions target the same position, distribute them
745
- /// within that region. E.g., 3 windows → "left" becomes top-left, left, bottom-left.
746
- private func distributeTileActions(_ actions: [[String: Any]]) -> [[String: Any]] {
747
- // Group tile_window actions by position
748
- var tileGroups: [String: [[String: Any]]] = [:]
749
- var otherActions: [[String: Any]] = []
750
-
751
- for action in actions {
752
- let intent = action["intent"] as? String ?? ""
753
- if intent == "tile_window",
754
- let slots = action["slots"] as? [String: Any],
755
- let position = slots["position"] as? String {
756
- tileGroups[position, default: []].append(action)
757
- } else {
758
- otherActions.append(action)
759
- }
760
- }
761
-
762
- var result = otherActions
763
-
764
- for (position, group) in tileGroups {
765
- if group.count == 1 {
766
- // Single window — keep as-is
767
- result.append(group[0])
768
- } else {
769
- // Multiple windows targeting the same position — subdivide
770
- let subPositions = subdividePosition(position, count: group.count)
771
- for (i, action) in group.enumerated() {
772
- var modified = action
773
- var slots = (action["slots"] as? [String: Any]) ?? [:]
774
- slots["position"] = subPositions[i]
775
- modified["slots"] = slots
776
- result.append(modified)
777
- DiagnosticLog.shared.info("HandsOff: distributed \(position) → \(subPositions[i]) for window \(slots["wid"] ?? "?")")
778
- }
779
- }
780
- }
781
-
782
- return result
783
- }
784
-
785
- /// Subdivide a tile position for N windows.
786
- private func subdividePosition(_ position: String, count: Int) -> [String] {
787
- // 2-3 windows in a half → vertical stack
788
- let verticalSubs: [String: [String]] = [
789
- "left": ["top-left", "bottom-left"],
790
- "right": ["top-right", "bottom-right"],
791
- ]
792
- // 4+ windows in a half → 2×2 grid using the eighths
793
- let gridSubs: [String: [String]] = [
794
- "left": ["top-first-fourth", "top-second-fourth", "bottom-first-fourth", "bottom-second-fourth"],
795
- "right": ["top-third-fourth", "top-last-fourth", "bottom-third-fourth", "bottom-last-fourth"],
796
- ]
797
- // Horizontal stacking within a half
798
- let horizontalSubs: [String: [String]] = [
799
- "top": ["top-left", "top-right"],
800
- "bottom": ["bottom-left", "bottom-right"],
801
- ]
802
- // 4+ windows horizontal → use fourths
803
- let horizontalGridSubs: [String: [String]] = [
804
- "top": ["top-first-fourth", "top-second-fourth", "top-third-fourth", "top-last-fourth"],
805
- "bottom": ["bottom-first-fourth", "bottom-second-fourth", "bottom-third-fourth", "bottom-last-fourth"],
806
- ]
807
- // Full screen → grid
808
- let fullSubs = ["top-left", "top-right", "bottom-left", "bottom-right", "left", "right"]
809
-
810
- let subs: [String]
811
- if count >= 4, let g = gridSubs[position] {
812
- subs = g
813
- } else if let v = verticalSubs[position] {
814
- subs = v
815
- } else if count >= 4, let hg = horizontalGridSubs[position] {
816
- subs = hg
817
- } else if let h = horizontalSubs[position] {
818
- subs = h
819
- } else if position == "maximize" || position == "center" {
820
- subs = fullSubs
821
- } else {
822
- // Can't subdivide further — just repeat the position
823
- return Array(repeating: position, count: count)
824
- }
825
-
826
- // Distribute windows across available sub-positions
827
- var result: [String] = []
828
- for i in 0..<count {
829
- result.append(subs[i % subs.count])
830
- }
831
- return result
832
- }
833
-
834
- // MARK: - Sound
835
-
836
- private func playSound(_ name: NSSound.Name) {
837
- NSSound(named: name)?.play()
838
- }
839
- }