@lattices/cli 0.4.0 → 0.4.2

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.
@@ -97,6 +97,15 @@ final class HandsOffSession: ObservableObject {
97
97
  private var workerBuffer = ""
98
98
  private let workerQueue = DispatchQueue(label: "com.lattices.handsoff-worker", qos: .userInitiated)
99
99
  private var lastCueAt: Date = .distantPast
100
+ private var workerRoot: String? {
101
+ if let idx = CommandLine.arguments.firstIndex(of: "--lattices-cli-root"),
102
+ CommandLine.arguments.indices.contains(idx + 1) {
103
+ return CommandLine.arguments[idx + 1]
104
+ }
105
+
106
+ let devRoot = NSHomeDirectory() + "/dev/lattices"
107
+ return FileManager.default.fileExists(atPath: devRoot) ? devRoot : nil
108
+ }
100
109
 
101
110
  /// JSONL log for full turn data — ~/.lattices/handsoff.jsonl
102
111
  private let turnLogPath = NSHomeDirectory() + "/.lattices/handsoff.jsonl"
@@ -122,7 +131,7 @@ final class HandsOffSession: ObservableObject {
122
131
  // MARK: - Lifecycle
123
132
 
124
133
  func start() {
125
- startWorker()
134
+ // Worker startup is lazy — only start it when a voice turn or cached cue needs it.
126
135
  }
127
136
 
128
137
  func setAudibleFeedbackEnabled(_ enabled: Bool) {
@@ -170,9 +179,10 @@ final class HandsOffSession: ObservableObject {
170
179
  }
171
180
  }
172
181
 
173
- private func startWorker() {
182
+ @discardableResult
183
+ private func startWorker() -> Bool {
174
184
  if workerProcess?.isRunning == true, workerStdin != nil {
175
- return
185
+ return true
176
186
  }
177
187
 
178
188
  let bunPaths = [
@@ -182,19 +192,24 @@ final class HandsOffSession: ObservableObject {
182
192
  ]
183
193
  guard let bunPath = bunPaths.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else {
184
194
  DiagnosticLog.shared.warn("HandsOff: bun not found, worker disabled")
185
- return
195
+ return false
196
+ }
197
+
198
+ guard let workerRoot else {
199
+ DiagnosticLog.shared.warn("HandsOff: worker root not found, worker disabled")
200
+ return false
186
201
  }
187
202
 
188
- let scriptPath = NSHomeDirectory() + "/dev/lattices/bin/handsoff-worker.ts"
203
+ let scriptPath = workerRoot + "/bin/handsoff-worker.ts"
189
204
  guard FileManager.default.fileExists(atPath: scriptPath) else {
190
205
  DiagnosticLog.shared.warn("HandsOff: worker script not found at \(scriptPath)")
191
- return
206
+ return false
192
207
  }
193
208
 
194
209
  let proc = Process()
195
210
  proc.executableURL = URL(fileURLWithPath: bunPath)
196
211
  proc.arguments = ["run", scriptPath]
197
- proc.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory() + "/dev/lattices")
212
+ proc.currentDirectoryURL = URL(fileURLWithPath: workerRoot)
198
213
 
199
214
  var env = ProcessInfo.processInfo.environment
200
215
  env.removeValue(forKey: "CLAUDECODE")
@@ -211,7 +226,7 @@ final class HandsOffSession: ObservableObject {
211
226
  try proc.run()
212
227
  } catch {
213
228
  DiagnosticLog.shared.warn("HandsOff: failed to start worker — \(error)")
214
- return
229
+ return false
215
230
  }
216
231
 
217
232
  workerProcess = proc
@@ -235,22 +250,29 @@ final class HandsOffSession: ObservableObject {
235
250
 
236
251
  // Handle worker crash → restart
237
252
  proc.terminationHandler = { [weak self] proc in
238
- DiagnosticLog.shared.warn("HandsOff: worker exited (code \(proc.terminationStatus)), restarting in 2s")
239
- self?.workerProcess = nil
240
- self?.workerStdin = nil
253
+ guard let self else { return }
254
+ let keepWarm = self.audibleFeedbackEnabled || self.state != .idle
255
+ let suffix = keepWarm ? ", restarting in 2s" : ", staying idle"
256
+ DiagnosticLog.shared.warn("HandsOff: worker exited (code \(proc.terminationStatus))\(suffix)")
257
+ self.workerProcess = nil
258
+ self.workerStdin = nil
259
+ guard keepWarm else { return }
241
260
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
242
- self?.startWorker()
261
+ self.startWorker()
243
262
  }
244
263
  }
245
264
 
246
265
  // Ping to verify
247
266
  sendToWorker(["cmd": "ping"])
248
267
  DiagnosticLog.shared.info("HandsOff: worker started (pid \(proc.processIdentifier))")
268
+ return true
249
269
  }
250
270
 
251
271
  // MARK: - Worker communication
252
272
 
253
273
  private var pendingCallback: (([String: Any]) -> Void)?
274
+ private var turnTimeoutWork: DispatchWorkItem?
275
+ private static let turnTimeoutSeconds: TimeInterval = 30
254
276
 
255
277
  private func sendToWorker(_ dict: [String: Any]) {
256
278
  guard let data = try? JSONSerialization.data(withJSONObject: dict),
@@ -335,17 +357,34 @@ final class HandsOffSession: ObservableObject {
335
357
  case .listening:
336
358
  finishListening()
337
359
  case .thinking:
338
- DiagnosticLog.shared.info("HandsOff: busy, ignoring toggle")
360
+ cancelTurn()
339
361
  case .connecting:
340
362
  cancel()
341
363
  }
342
364
  }
343
365
 
344
366
  func cancel() {
367
+ cancelVoxSession()
345
368
  state = .idle
346
369
  DiagnosticLog.shared.info("HandsOff: cancelled")
347
370
  }
348
371
 
372
+ private func cancelTurn() {
373
+ turnTimeoutWork?.cancel()
374
+ turnTimeoutWork = nil
375
+ pendingCallback = nil
376
+ state = .idle
377
+ DiagnosticLog.shared.warn("HandsOff: turn cancelled by user")
378
+ playSound("Funk")
379
+ }
380
+
381
+ /// Cancel any active Vox recording session without transcribing.
382
+ private func cancelVoxSession() {
383
+ guard VoxClient.shared.activeSessionId != nil else { return }
384
+ DiagnosticLog.shared.info("HandsOff: cancelling Vox session")
385
+ VoxClient.shared.cancelSession()
386
+ }
387
+
349
388
  // MARK: - Voice capture
350
389
 
351
390
  private func beginListening() {
@@ -377,24 +416,46 @@ final class HandsOffSession: ObservableObject {
377
416
  }
378
417
  }
379
418
 
419
+ /// Guard against double-processing the transcript (session.final + completion can both deliver it).
420
+ private var turnProcessed = false
421
+
380
422
  private func startDictation() {
381
423
  state = .listening
382
424
  lastTranscript = nil
425
+ turnProcessed = false
383
426
  playSound("Tink")
384
427
 
385
428
  DiagnosticLog.shared.info("HandsOff: listening...")
386
429
 
387
430
  // Vox live session: startSession opens the mic, events flow on the start call ID.
388
- // No partial transcripts Vox transcribes after recording stops.
431
+ // session.final arrives via onProgress, then the same data arrives via completion.
432
+ // We process the transcript from whichever arrives first to be resilient against
433
+ // connection drops between the two.
389
434
  VoxClient.shared.startSession(
390
435
  onProgress: { [weak self] event, data in
391
436
  DispatchQueue.main.async {
392
- if event == "session.state" {
437
+ guard let self else { return }
438
+ switch event {
439
+ case "session.state":
393
440
  let sessionState = data["state"] as? String ?? ""
394
441
  DiagnosticLog.shared.info("HandsOff: session → \(sessionState)")
395
- }
396
- if event == "session.final", let text = data["text"] as? String {
397
- self?.lastTranscript = text
442
+ // Vox cancelled the session (e.g. recording timeout)
443
+ if sessionState == "cancelled" {
444
+ let reason = data["reason"] as? String ?? "unknown"
445
+ DiagnosticLog.shared.warn("HandsOff: Vox cancelled session — \(reason)")
446
+ if self.state == .listening {
447
+ self.state = .idle
448
+ self.playSound("Basso")
449
+ }
450
+ }
451
+ case "session.final":
452
+ // Primary transcript delivery — process immediately
453
+ if let text = data["text"] as? String, !text.isEmpty {
454
+ self.lastTranscript = text
455
+ self.deliverTranscript(text)
456
+ }
457
+ default:
458
+ break
398
459
  }
399
460
  }
400
461
  },
@@ -405,24 +466,36 @@ final class HandsOffSession: ObservableObject {
405
466
  case .success(let data):
406
467
  let text = data["text"] as? String ?? ""
407
468
  if text.isEmpty {
408
- self.state = .idle
409
- DiagnosticLog.shared.info("HandsOff: no speech detected")
469
+ if !self.turnProcessed {
470
+ self.state = .idle
471
+ DiagnosticLog.shared.info("HandsOff: no speech detected")
472
+ }
410
473
  } else {
474
+ // Fallback — deliver if session.final didn't already
411
475
  self.lastTranscript = text
412
- DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
413
- self.appendChat(.user, text: text)
414
- self.processTurn(text)
476
+ self.deliverTranscript(text)
415
477
  }
416
478
  case .failure(let error):
417
- self.state = .idle
418
- DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
419
- self.playSound("Basso")
479
+ if !self.turnProcessed {
480
+ self.state = .idle
481
+ DiagnosticLog.shared.warn("HandsOff: session error — \(error.localizedDescription)")
482
+ self.playSound("Basso")
483
+ }
420
484
  }
421
485
  }
422
486
  }
423
487
  )
424
488
  }
425
489
 
490
+ /// Deliver transcript exactly once — called from both session.final and completion.
491
+ private func deliverTranscript(_ text: String) {
492
+ guard !turnProcessed else { return }
493
+ turnProcessed = true
494
+ DiagnosticLog.shared.info("HandsOff: heard → '\(text)'")
495
+ appendChat(.user, text: text)
496
+ processTurn(text)
497
+ }
498
+
426
499
  func finishListening() {
427
500
  guard state == .listening else { return }
428
501
  playSound("Tink")
@@ -433,6 +506,12 @@ final class HandsOffSession: ObservableObject {
433
506
 
434
507
  private func processTurn(_ transcript: String) {
435
508
  state = .thinking
509
+ guard startWorker() else {
510
+ state = .idle
511
+ DiagnosticLog.shared.warn("HandsOff: worker unavailable")
512
+ playSound("Basso")
513
+ return
514
+ }
436
515
  turnCount += 1
437
516
 
438
517
  let turnStart = Date()
@@ -449,9 +528,25 @@ final class HandsOffSession: ObservableObject {
449
528
  "history": conversationHistory,
450
529
  ]
451
530
 
531
+ // Start turn timeout — forcibly reset if worker never responds
532
+ turnTimeoutWork?.cancel()
533
+ let timeout = DispatchWorkItem { [weak self] in
534
+ guard let self, self.state == .thinking else { return }
535
+ DiagnosticLog.shared.warn("HandsOff: ⏱ turn \(self.turnCount) timed out after \(Int(Self.turnTimeoutSeconds))s")
536
+ self.pendingCallback = nil
537
+ self.state = .idle
538
+ self.playSound("Basso")
539
+ }
540
+ turnTimeoutWork = timeout
541
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.turnTimeoutSeconds, execute: timeout)
542
+
452
543
  sendToWorkerWithCallback(turnCmd) { [weak self] response in
453
544
  guard let self else { return }
454
545
 
546
+ // Cancel the timeout — we got a response
547
+ self.turnTimeoutWork?.cancel()
548
+ self.turnTimeoutWork = nil
549
+
455
550
  let turnMs = Int(Date().timeIntervalSince(turnStart) * 1000)
456
551
  DiagnosticLog.shared.info("HandsOff: ⏱ turn \(self.turnCount) complete — \(turnMs)ms")
457
552
 
@@ -24,6 +24,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
24
24
  case handsOff
25
25
  case unifiedWindow
26
26
  case hud
27
+ case mouseFinder
27
28
  // Layers
28
29
  case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
29
30
  case layerNext, layerPrev, layerTag
@@ -45,6 +46,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
45
46
  case .handsOff: return "Hands-Off Mode"
46
47
  case .unifiedWindow: return "Unified Window"
47
48
  case .hud: return "HUD"
49
+ case .mouseFinder: return "Find Mouse"
48
50
  case .layer1: return "Layer 1"
49
51
  case .layer2: return "Layer 2"
50
52
  case .layer3: return "Layer 3"
@@ -76,7 +78,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
76
78
 
77
79
  var group: HotkeyGroup {
78
80
  switch self {
79
- case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch, .voiceCommand, .handsOff, .unifiedWindow, .hud: return .app
81
+ case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch, .voiceCommand, .handsOff, .unifiedWindow, .hud, .mouseFinder: return .app
80
82
  case .layer1, .layer2, .layer3, .layer4, .layer5,
81
83
  .layer6, .layer7, .layer8, .layer9,
82
84
  .layerNext, .layerPrev, .layerTag: return .layers
@@ -96,6 +98,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
96
98
  case .handsOff: return 206
97
99
  case .unifiedWindow: return 207
98
100
  case .hud: return 208
101
+ case .mouseFinder: return 209
99
102
  case .layer1: return 101
100
103
  case .layer2: return 102
101
104
  case .layer3: return 103
@@ -231,6 +234,7 @@ class HotkeyStore: ObservableObject {
231
234
  bind(.handsOff, 46, cmdCtrl) // Ctrl+Cmd+M
232
235
  bind(.omniSearch, 23, hyper) // Hyper+5
233
236
  bind(.cheatSheet, 22, hyper) // Hyper+6
237
+ bind(.mouseFinder, 26, hyper) // Hyper+7
234
238
 
235
239
  // Layers: Cmd+Option+1-9
236
240
  let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
@@ -723,6 +723,43 @@ final class IntentEngine {
723
723
  }
724
724
  ))
725
725
 
726
+ // ── Find / Summon Mouse ────────────────────────────────
727
+
728
+ register(IntentDef(
729
+ name: "find_mouse",
730
+ description: "Show a sonar pulse at the current mouse cursor position",
731
+ examples: [
732
+ "where's my mouse",
733
+ "find the cursor",
734
+ "I lost my mouse",
735
+ "find mouse",
736
+ "show cursor"
737
+ ],
738
+ slots: [],
739
+ handler: { _ in
740
+ DispatchQueue.main.async { MouseFinder.shared.find() }
741
+ let pos = NSEvent.mouseLocation
742
+ return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
743
+ }
744
+ ))
745
+
746
+ register(IntentDef(
747
+ name: "summon_mouse",
748
+ description: "Warp the mouse cursor to the center of the screen",
749
+ examples: [
750
+ "summon mouse",
751
+ "bring the cursor here",
752
+ "center the mouse",
753
+ "mouse come here",
754
+ "bring mouse back"
755
+ ],
756
+ slots: [],
757
+ handler: { _ in
758
+ DispatchQueue.main.async { MouseFinder.shared.summon() }
759
+ return .object(["ok": .bool(true)])
760
+ }
761
+ ))
762
+
726
763
  // ── Undo / Restore ─────────────────────────────────────
727
764
 
728
765
  register(IntentDef(
@@ -1818,6 +1818,46 @@ final class LatticesApi {
1818
1818
 
1819
1819
  // ── Meta endpoint ───────────────────────────────────────
1820
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
+
1821
1861
  api.register(Endpoint(
1822
1862
  method: "api.schema",
1823
1863
  description: "Get the full API schema including all methods and models",
@@ -283,27 +283,50 @@ struct MainView: View {
283
283
 
284
284
  private var actionsSection: some View {
285
285
  VStack(spacing: 0) {
286
- ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
287
- CommandPaletteWindow.shared.toggle()
288
- }
289
- ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
290
- ScreenMapWindowController.shared.toggle()
291
- }
292
- ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
293
- CommandModeWindow.shared.toggle()
294
- }
295
- ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
296
- WindowBezel.showBezelForFrontmostWindow()
286
+ HStack {
287
+ Text("Quick Actions")
288
+ .font(Typo.monoBold(10))
289
+ .foregroundColor(Palette.textMuted)
290
+
291
+ Spacer()
292
+
293
+ Button("Help & shortcuts") {
294
+ ScreenMapWindowController.shared.showPage(.docs)
295
+ }
296
+ .buttonStyle(.plain)
297
+ .font(Typo.mono(9))
298
+ .foregroundColor(Palette.textMuted)
297
299
  }
298
- ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
299
- CheatSheetHUD.shared.toggle()
300
+ .padding(.horizontal, 14)
301
+ .padding(.top, 10)
302
+ .padding(.bottom, 6)
303
+
304
+ ActionRow(
305
+ label: "Command Palette",
306
+ detail: "Launch, attach, and control projects",
307
+ hotkeyTokens: hotkeyTokens(.palette),
308
+ icon: "command",
309
+ accentColor: Palette.running
310
+ ) {
311
+ CommandPaletteWindow.shared.toggle()
300
312
  }
301
- ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
302
- OmniSearchWindow.shared.toggle()
313
+ ActionRow(
314
+ label: "Workspace",
315
+ detail: "Screen map, inventory, and window context",
316
+ hotkeyTokens: hotkeyTokens(.unifiedWindow),
317
+ icon: "square.grid.2x2",
318
+ accentColor: Palette.text
319
+ ) {
320
+ ScreenMapWindowController.shared.showPage(.home)
303
321
  }
304
- ActionRow(shortcut: "7", label: "Voice Command", hotkey: hotkeyLabel(.voiceCommand), icon: "mic", accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim) {
305
- let audio = AudioLayer.shared
306
- if audio.isListening { audio.stopVoiceCommand() } else { audio.startVoiceCommand() }
322
+ ActionRow(
323
+ label: "Assistant",
324
+ detail: "Search now, or use voice when you need it",
325
+ hotkeyTokens: hotkeyTokens(.omniSearch),
326
+ icon: "magnifyingglass",
327
+ accentColor: AudioLayer.shared.isListening ? Palette.running : Palette.textDim
328
+ ) {
329
+ showAssistant()
307
330
  }
308
331
  }
309
332
  .padding(.vertical, 4)
@@ -351,9 +374,38 @@ struct MainView: View {
351
374
  .buttonStyle(.plain)
352
375
  }
353
376
 
354
- private func hotkeyLabel(_ action: HotkeyAction) -> String? {
355
- guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
356
- return binding.displayParts.joined(separator: "")
377
+ private func hotkeyTokens(_ action: HotkeyAction) -> [String] {
378
+ guard let binding = HotkeyStore.shared.bindings[action],
379
+ let key = binding.displayParts.last else { return [] }
380
+
381
+ let modifiers = Set(binding.displayParts.dropLast())
382
+ if modifiers == Set(["Ctrl", "Option", "Shift", "Cmd"]) {
383
+ return ["Hyper", shortenHotkeyToken(key)]
384
+ }
385
+
386
+ return binding.displayParts.map(shortenHotkeyToken)
387
+ }
388
+
389
+ private func shortenHotkeyToken(_ token: String) -> String {
390
+ switch token {
391
+ case "Cmd": return "⌘"
392
+ case "Shift": return "⇧"
393
+ case "Option": return "⌥"
394
+ case "Ctrl": return "⌃"
395
+ case "Return": return "↩"
396
+ case "Escape": return "Esc"
397
+ case "Space": return "Space"
398
+ default: return token
399
+ }
400
+ }
401
+
402
+ private func showAssistant() {
403
+ if AudioLayer.shared.isListening || VoiceCommandWindow.shared.isVisible {
404
+ VoiceCommandWindow.shared.toggle()
405
+ return
406
+ }
407
+
408
+ OmniSearchWindow.shared.show()
357
409
  }
358
410
 
359
411
  // MARK: - Empty state