@lattices/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -78,7 +78,7 @@ class WorkspaceManager: ObservableObject {
78
78
 
79
79
  private let configPath: String
80
80
  private let gridConfigPath: String
81
- private let tmuxPath = "/opt/homebrew/bin/tmux"
81
+ private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
82
82
  private let activeLayerKey = "lattices.activeLayerIndex"
83
83
 
84
84
  init() {
@@ -173,13 +173,15 @@ class WorkspaceManager: ObservableObject {
173
173
 
174
174
  /// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
175
175
  func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
176
- if let preset = gridPresets[tile] {
177
- return preset.fractions
178
- }
179
- if let position = TilePosition(rawValue: tile) {
180
- return position.rect
176
+ resolvePlacement(tile)?.fractions
177
+ }
178
+
179
+ func resolvePlacement(_ tile: String) -> PlacementSpec? {
180
+ if let preset = gridPresets[tile],
181
+ let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) {
182
+ return .fractions(placement)
181
183
  }
182
- return nil
184
+ return PlacementSpec(string: tile)
183
185
  }
184
186
 
185
187
  // MARK: - Tab Groups
@@ -275,7 +277,7 @@ class WorkspaceManager: ObservableObject {
275
277
 
276
278
  /// Resolve a session name to a tile target: (wid, pid, frame).
277
279
  /// Returns nil if the window isn't tracked or has no tile position.
278
- private func batchTarget(session: String, position: TilePosition, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
280
+ private func batchTarget(session: String, position: PlacementSpec, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
279
281
  guard let entry = windowForSession(session) else { return nil }
280
282
  let frame = WindowTiler.tileFrame(for: position, on: screen)
281
283
  return (entry.wid, entry.pid, frame)
@@ -309,6 +311,71 @@ class WorkspaceManager: ObservableObject {
309
311
  return (running, total)
310
312
  }
311
313
 
314
+ // MARK: - Layer Focus (raise only)
315
+
316
+ /// Switch to a layer by raising all its windows in place — no launching, no tiling, no moving.
317
+ /// This is the default hotkey action: just bring the layer's windows to the front.
318
+ func focusLayer(index: Int) {
319
+ guard let config, let layers = config.layers, index < layers.count else { return }
320
+ if index == activeLayerIndex { return }
321
+
322
+ let diag = DiagnosticLog.shared
323
+ let t = diag.startTimed("focusLayer \(activeLayerIndex)→\(index)")
324
+
325
+ DesktopModel.shared.poll()
326
+
327
+ let targetLayer = layers[index]
328
+ var windowsToRaise: [(wid: UInt32, pid: Int32)] = []
329
+
330
+ for lp in targetLayer.projects {
331
+ if let groupId = lp.group, let grp = group(byId: groupId) {
332
+ // Raise all tab windows in the group
333
+ for tab in grp.tabs {
334
+ let sessionName = Self.sessionName(for: tab.path)
335
+ if let entry = windowForSession(sessionName) {
336
+ windowsToRaise.append((entry.wid, entry.pid))
337
+ }
338
+ }
339
+ continue
340
+ }
341
+
342
+ if let appName = lp.app {
343
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
344
+ windowsToRaise.append((entry.wid, entry.pid))
345
+ }
346
+ continue
347
+ }
348
+
349
+ guard let path = lp.path else { continue }
350
+ let sessionName = Self.sessionName(for: path)
351
+ if let entry = windowForSession(sessionName) {
352
+ windowsToRaise.append((entry.wid, entry.pid))
353
+ }
354
+
355
+ // Also raise companion windows
356
+ let companions = projectWindows(at: path)
357
+ for cw in companions {
358
+ guard let appName = cw.app else { continue }
359
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
360
+ windowsToRaise.append((entry.wid, entry.pid))
361
+ }
362
+ }
363
+ }
364
+
365
+ if !windowsToRaise.isEmpty {
366
+ WindowTiler.raiseWindowsAndReactivate(windows: windowsToRaise)
367
+ }
368
+
369
+ activeLayerIndex = index
370
+ UserDefaults.standard.set(index, forKey: activeLayerKey)
371
+
372
+ let allLabels = layers.map(\.label)
373
+ LayerBezel.shared.show(label: targetLayer.label, index: index, total: layers.count, allLabels: allLabels)
374
+ HandsOffSession.shared.playCachedCue("Switched.")
375
+
376
+ diag.finish(t)
377
+ }
378
+
312
379
  // MARK: - Unified Layer Tiling
313
380
 
314
381
  /// Unified entry point for arranging a layer's windows.
@@ -332,14 +399,17 @@ class WorkspaceManager: ObservableObject {
332
399
  let scanner = ProjectScanner.shared
333
400
  let targetLayer = layers[index]
334
401
 
402
+ // Fresh poll so we see windows on all Spaces before matching
403
+ DesktopModel.shared.poll()
404
+
335
405
  // Tile debug log (written to ~/.lattices/tile-debug.log)
336
406
  let debugPath = (FileManager.default.homeDirectoryForCurrentUser.path as NSString).appendingPathComponent(".lattices/tile-debug.log")
337
407
  var debugLines: [String] = ["tileLayer index=\(index) launch=\(launch) force=\(force) layer=\(targetLayer.id)"]
338
408
 
339
409
  // Phase 1: classify each project
340
410
  var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
341
- var fallbacks: [(session: String, position: TilePosition, screen: NSScreen)] = []
342
- var launchQueue: [(session: String, position: TilePosition?, screen: NSScreen, launchAction: () -> Void)] = []
411
+ var fallbacks: [(session: String, position: PlacementSpec, screen: NSScreen)] = []
412
+ var launchQueue: [(session: String, position: PlacementSpec?, screen: NSScreen, launchAction: () -> Void)] = []
343
413
 
344
414
  // Log screen info
345
415
  for (i, s) in NSScreen.screens.enumerated() {
@@ -351,7 +421,7 @@ class WorkspaceManager: ObservableObject {
351
421
 
352
422
  if let groupId = lp.group, let grp = group(byId: groupId) {
353
423
  let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
354
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
424
+ let position = lp.tile.flatMap { resolvePlacement($0) }
355
425
  let groupRunning = isGroupRunning(grp)
356
426
 
357
427
  if groupRunning, let pos = position,
@@ -373,12 +443,19 @@ class WorkspaceManager: ObservableObject {
373
443
 
374
444
  // App-based window matching
375
445
  if let appName = lp.app {
376
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
446
+ let position = lp.tile.flatMap { resolvePlacement($0) }
377
447
  if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
378
448
  if let pos = position {
379
449
  let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
380
450
  batchMoves.append((entry.wid, entry.pid, frame))
381
451
  }
452
+ } else if let found = Self.findAppWindow(app: appName, title: lp.title) {
453
+ // Window exists but wasn't in DesktopModel (e.g. different Space) — tile it
454
+ diag.info(" found app via CGWindowList fallback: \(appName) wid=\(found.wid)")
455
+ if let pos = position {
456
+ let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
457
+ batchMoves.append((found.wid, found.pid, frame))
458
+ }
382
459
  } else if launch {
383
460
  diag.info(" launch app: \(appName)")
384
461
  let capturedLp = lp
@@ -407,13 +484,13 @@ class WorkspaceManager: ObservableObject {
407
484
  guard let path = lp.path else { continue }
408
485
  let sessionName = Self.sessionName(for: path)
409
486
  let project = scanner.projects.first(where: { $0.path == path })
410
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
487
+ let position = lp.tile.flatMap { resolvePlacement($0) }
411
488
  // Check scanner first, fall back to direct tmux check for projects without .lattices.json
412
489
  let isRunning = project?.isRunning == true || shell([tmuxPath, "has-session", "-t", sessionName]) == 0
413
490
 
414
491
  if isRunning {
415
492
  let foundWindow = windowForSession(sessionName)
416
- let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.rawValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
493
+ let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.wireValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
417
494
  diag.info(msg)
418
495
  debugLines.append(msg)
419
496
  if let pos = position,
@@ -422,7 +499,7 @@ class WorkspaceManager: ObservableObject {
422
499
  debugLines.append(" → batch move wid=\(target.wid) frame=\(target.frame)")
423
500
  } else if let pos = position {
424
501
  fallbacks.append((sessionName, pos, lpScreen))
425
- debugLines.append(" → fallback \(pos.rawValue)")
502
+ debugLines.append(" → fallback \(pos.wireValue)")
426
503
  }
427
504
  } else if launch {
428
505
  if let project {
@@ -443,7 +520,7 @@ class WorkspaceManager: ObservableObject {
443
520
  for cw in companions {
444
521
  guard let appName = cw.app else { continue }
445
522
  let cwScreen = screen(for: cw.display ?? lp.display) ?? lpScreen
446
- let cwPosition = cw.tile.flatMap { TilePosition(rawValue: $0) }
523
+ let cwPosition = cw.tile.flatMap { resolvePlacement($0) }
447
524
  if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
448
525
  if let pos = cwPosition {
449
526
  let frame = WindowTiler.tileFrame(for: pos, on: cwScreen)
@@ -486,7 +563,7 @@ class WorkspaceManager: ObservableObject {
486
563
  for (i, fb) in fallbacks.enumerated() {
487
564
  let delay = Double(i) * 0.15 + 0.1
488
565
  DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
489
- diag.info(" tile fallback: \(fb.session) → \(fb.position.rawValue)")
566
+ diag.info(" tile fallback: \(fb.session) → \(fb.position.wireValue)")
490
567
  WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
491
568
  WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
492
569
  }
@@ -498,7 +575,7 @@ class WorkspaceManager: ObservableObject {
498
575
  DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
499
576
  item.launchAction()
500
577
  if let pos = item.position {
501
- let t = diag.startTimed("tile launched: \(item.session) → \(pos.rawValue)")
578
+ let t = diag.startTimed("tile launched: \(item.session) → \(pos.wireValue)")
502
579
  WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
503
580
  diag.finish(t)
504
581
  }
@@ -558,6 +635,38 @@ class WorkspaceManager: ObservableObject {
558
635
  }
559
636
  }
560
637
 
638
+ // MARK: - App Window Fallback (CGWindowList .optionAll)
639
+
640
+ /// Find an app window across ALL Spaces via CGWindowList (bypasses DesktopModel cache)
641
+ static func findAppWindow(app: String, title: String?) -> (wid: UInt32, pid: Int32)? {
642
+ guard let list = CGWindowListCopyWindowInfo(
643
+ [.optionAll, .excludeDesktopElements],
644
+ kCGNullWindowID
645
+ ) as? [[String: Any]] else { return nil }
646
+
647
+ for info in list {
648
+ guard let ownerName = info[kCGWindowOwnerName as String] as? String,
649
+ ownerName.localizedCaseInsensitiveContains(app),
650
+ let wid = info[kCGWindowNumber as String] as? UInt32,
651
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
652
+ let layer = info[kCGWindowLayer as String] as? Int, layer == 0,
653
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
654
+ else { continue }
655
+
656
+ var rect = CGRect.zero
657
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
658
+ rect.width >= 50, rect.height >= 50 else { continue }
659
+
660
+ if let title {
661
+ let windowTitle = info[kCGWindowName as String] as? String ?? ""
662
+ guard windowTitle.localizedCaseInsensitiveContains(title) else { continue }
663
+ }
664
+
665
+ return (wid, pid)
666
+ }
667
+ return nil
668
+ }
669
+
561
670
  // MARK: - Session Name Helper
562
671
 
563
672
  /// Replicates Project.sessionName logic from a bare path
package/bin/client.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Public API — re-exports from daemon-client for a cleaner import path.
2
+ // Usage: import { daemonCall, isDaemonRunning } from '@lattices/cli'
3
+
4
+ export { daemonCall, isDaemonRunning } from "./daemon-client.ts";
5
+ export {
6
+ ProjectTwin,
7
+ createProjectTwin,
8
+ readOpenScoutRelayContext,
9
+ type OpenScoutRelayContext,
10
+ type ProjectTwinEvent,
11
+ type ProjectTwinInvokeRequest,
12
+ type ProjectTwinOptions,
13
+ type ProjectTwinResult,
14
+ type ProjectTwinState,
15
+ type ProjectTwinThinkingLevel,
16
+ } from "./project-twin.ts";
@@ -1,21 +1,26 @@
1
1
  // Lightweight WebSocket client for lattices daemon (ws://127.0.0.1:9399)
2
2
  // Uses Node `net` module with manual HTTP upgrade + minimal WS framing.
3
- // Zero npm dependencies. Requires Node >= 18.
3
+ // Zero npm dependencies.
4
4
 
5
- import { createConnection } from "node:net";
5
+ import { createConnection, type Socket } from "node:net";
6
6
  import { randomBytes } from "node:crypto";
7
7
 
8
8
  const DAEMON_HOST = "127.0.0.1";
9
9
  const DAEMON_PORT = 9399;
10
10
 
11
+ interface ParsedFrame {
12
+ payload: string;
13
+ rest: Buffer<ArrayBuffer>;
14
+ }
15
+
11
16
  /**
12
17
  * Send a JSON-RPC-style request to the daemon and return the response.
13
- * @param {string} method
14
- * @param {object} [params]
15
- * @param {number} [timeoutMs=3000]
16
- * @returns {Promise<object>} The result field from the response
17
18
  */
18
- export async function daemonCall(method, params, timeoutMs = 3000) {
19
+ export async function daemonCall(
20
+ method: string,
21
+ params?: Record<string, unknown> | null,
22
+ timeoutMs = 3000
23
+ ): Promise<unknown> {
19
24
  const id = randomBytes(4).toString("hex");
20
25
  const request = JSON.stringify({ id, method, params: params ?? null });
21
26
 
@@ -62,8 +67,8 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
62
67
  socket.write(upgrade);
63
68
  });
64
69
 
65
- socket.on("data", (chunk) => {
66
- buffer = Buffer.concat([buffer, chunk]);
70
+ socket.on("data", (chunk: Buffer) => {
71
+ buffer = Buffer.concat([buffer, chunk]) as Buffer<ArrayBuffer>;
67
72
 
68
73
  if (!upgraded) {
69
74
  const headerEnd = buffer.indexOf("\r\n\r\n");
@@ -82,23 +87,38 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
82
87
  sendFrame(socket, request);
83
88
  }
84
89
 
85
- // Try to parse a WebSocket frame from the buffer
86
- const result = parseFrame(buffer);
87
- if (result) {
90
+ // The daemon can push broadcast events before the RPC response.
91
+ // Keep consuming frames until we see our matching response id.
92
+ while (true) {
93
+ const result = parseFrame(buffer);
94
+ if (!result) break;
88
95
  buffer = result.rest;
89
- if (!settled) {
90
- settled = true;
91
- cleanup();
92
- try {
93
- const parsed = JSON.parse(result.payload);
96
+
97
+ try {
98
+ const parsed = JSON.parse(result.payload);
99
+ if (parsed.event) {
100
+ continue;
101
+ }
102
+ if (parsed.id !== id) {
103
+ continue;
104
+ }
105
+ if (!settled) {
106
+ settled = true;
107
+ cleanup();
94
108
  if (parsed.error) {
95
109
  reject(new Error(parsed.error));
96
110
  } else {
97
111
  resolve(parsed.result);
98
112
  }
99
- } catch (e) {
113
+ }
114
+ return;
115
+ } catch {
116
+ if (!settled) {
117
+ settled = true;
118
+ cleanup();
100
119
  reject(new Error("Invalid JSON response from daemon"));
101
120
  }
121
+ return;
102
122
  }
103
123
  }
104
124
  });
@@ -107,9 +127,8 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
107
127
 
108
128
  /**
109
129
  * Check if the daemon is reachable.
110
- * @returns {Promise<boolean>}
111
130
  */
112
- export async function isDaemonRunning() {
131
+ export async function isDaemonRunning(): Promise<boolean> {
113
132
  try {
114
133
  await daemonCall("daemon.status", null, 1000);
115
134
  return true;
@@ -120,12 +139,12 @@ export async function isDaemonRunning() {
120
139
 
121
140
  // MARK: - WebSocket framing helpers
122
141
 
123
- function sendFrame(socket, text) {
142
+ function sendFrame(socket: Socket, text: string): void {
124
143
  const payload = Buffer.from(text, "utf8");
125
144
  const mask = randomBytes(4);
126
145
  const len = payload.length;
127
146
 
128
- let header;
147
+ let header: Buffer;
129
148
  if (len < 126) {
130
149
  header = Buffer.alloc(2);
131
150
  header[0] = 0x81; // FIN + text opcode
@@ -145,17 +164,17 @@ function sendFrame(socket, text) {
145
164
  // Mask payload
146
165
  const masked = Buffer.alloc(payload.length);
147
166
  for (let i = 0; i < payload.length; i++) {
148
- masked[i] = payload[i] ^ mask[i % 4];
167
+ masked[i] = payload[i]! ^ mask[i % 4]!;
149
168
  }
150
169
 
151
170
  socket.write(Buffer.concat([header, mask, masked]));
152
171
  }
153
172
 
154
- function parseFrame(buf) {
173
+ function parseFrame(buf: Buffer): ParsedFrame | null {
155
174
  if (buf.length < 2) return null;
156
175
 
157
- const masked = (buf[1] & 0x80) !== 0;
158
- let payloadLen = buf[1] & 0x7f;
176
+ const isMasked = (buf[1]! & 0x80) !== 0;
177
+ let payloadLen = buf[1]! & 0x7f;
159
178
  let offset = 2;
160
179
 
161
180
  if (payloadLen === 126) {
@@ -168,20 +187,20 @@ function parseFrame(buf) {
168
187
  offset = 10;
169
188
  }
170
189
 
171
- if (masked) offset += 4;
190
+ if (isMasked) offset += 4;
172
191
  if (buf.length < offset + payloadLen) return null;
173
192
 
174
193
  let payload = buf.subarray(offset, offset + payloadLen);
175
- if (masked) {
194
+ if (isMasked) {
176
195
  const maskKey = buf.subarray(offset - 4, offset);
177
196
  payload = Buffer.alloc(payloadLen);
178
197
  for (let i = 0; i < payloadLen; i++) {
179
- payload[i] = buf[offset + i] ^ maskKey[i % 4];
198
+ payload[i] = buf[offset + i]! ^ maskKey[i % 4]!;
180
199
  }
181
200
  }
182
201
 
183
202
  return {
184
203
  payload: payload.toString("utf8"),
185
- rest: buf.subarray(offset + payloadLen),
204
+ rest: buf.subarray(offset + payloadLen) as Buffer<ArrayBuffer>,
186
205
  };
187
206
  }