@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.
- package/README.md +85 -9
- package/app/Package.swift +8 -1
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +44 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +164 -5
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +733 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +45 -9
- package/app/Sources/IntentEngine.swift +925 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1235 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +1 -1
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +731 -0
- package/bin/{lattices-app.js → lattices-app.ts} +67 -32
- package/bin/lattices-dev +160 -0
- package/bin/{lattices.js → lattices.ts} +600 -137
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +21 -10
- 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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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:
|
|
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:
|
|
342
|
-
var launchQueue: [(session: String, position:
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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?.
|
|
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.
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
}
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
}
|