@arach/lattices 0.2.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -86
  3. package/apps/mac/Info.plist +43 -0
  4. package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
  5. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  6. package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  7. package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
  8. package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
  9. package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
  10. package/apps/mac/Lattices.entitlements +21 -0
  11. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  12. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  13. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  14. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  15. package/apps/mac/Resources/tap.wav +0 -0
  16. package/assets/AppIcon.icns +0 -0
  17. package/bin/assistant-intelligence.ts +912 -0
  18. package/bin/cli/capture.ts +252 -0
  19. package/bin/cli/daemon.ts +22 -0
  20. package/bin/cli/helpers.ts +105 -0
  21. package/bin/cli/layer.ts +178 -0
  22. package/bin/cli/runs.ts +43 -0
  23. package/bin/cli/search.ts +141 -0
  24. package/bin/cli/session.ts +32 -0
  25. package/bin/client.ts +17 -0
  26. package/bin/cua.ts +26 -0
  27. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  28. package/bin/handsoff-infer.ts +96 -0
  29. package/bin/handsoff-worker.ts +531 -0
  30. package/bin/infer.ts +424 -0
  31. package/bin/keychain.ts +75 -0
  32. package/bin/lattices-app.ts +655 -0
  33. package/bin/lattices-build +125 -0
  34. package/bin/lattices-build-env.ts +77 -0
  35. package/bin/lattices-dev +362 -0
  36. package/bin/lattices.ts +3260 -0
  37. package/bin/project-twin.ts +645 -0
  38. package/docs/agent-execution-plan.md +562 -0
  39. package/docs/agent-layer-guide.md +207 -0
  40. package/docs/agents.md +233 -0
  41. package/docs/ai-chat-ux-review.md +416 -0
  42. package/docs/api.md +1041 -47
  43. package/docs/app.md +96 -13
  44. package/docs/assistant-knowledge.md +130 -0
  45. package/docs/companion-deck.md +209 -0
  46. package/docs/component-extraction-roadmap.md +392 -0
  47. package/docs/concepts.md +13 -12
  48. package/docs/config.md +83 -10
  49. package/docs/gesture-customization-proposal.md +520 -0
  50. package/docs/handsoff-test-scenarios.md +84 -0
  51. package/docs/hyperspace-grid-snappiness.md +210 -0
  52. package/docs/layers.md +176 -28
  53. package/docs/mouse-gestures.md +244 -0
  54. package/docs/ocr.md +21 -9
  55. package/docs/overview.md +42 -23
  56. package/docs/presentation-execution-review.md +491 -0
  57. package/docs/prompts/hands-off-system.md +382 -0
  58. package/docs/prompts/hands-off-turn.md +30 -0
  59. package/docs/prompts/voice-advisor.md +31 -0
  60. package/docs/prompts/voice-fallback.md +23 -0
  61. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  62. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  63. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  64. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  65. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  66. package/docs/proposals/LAT-006-followup-gaps.md +103 -0
  67. package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
  68. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  69. package/docs/quickstart.md +8 -12
  70. package/docs/reference/dewey.config.ts +74 -0
  71. package/docs/reference/install-agent.md +79 -0
  72. package/docs/release.md +172 -0
  73. package/docs/repo-structure.md +100 -0
  74. package/docs/terminal-kit.md +87 -0
  75. package/docs/tiling-reference.md +224 -0
  76. package/docs/twins.md +138 -0
  77. package/docs/voice-command-protocol.md +278 -0
  78. package/docs/voice-error-model.md +73 -0
  79. package/docs/voice.md +221 -0
  80. package/package.json +69 -16
  81. package/packages/npm/sdk/cua.d.mts +1 -0
  82. package/packages/npm/sdk/cua.d.ts +188 -0
  83. package/packages/npm/sdk/cua.mjs +376 -0
  84. package/app/Lattices.app/Contents/Info.plist +0 -24
  85. package/app/Package.swift +0 -13
  86. package/app/Sources/ActionRow.swift +0 -61
  87. package/app/Sources/App.swift +0 -10
  88. package/app/Sources/AppDelegate.swift +0 -234
  89. package/app/Sources/AppShellView.swift +0 -62
  90. package/app/Sources/AppTypeClassifier.swift +0 -70
  91. package/app/Sources/AppWindowShell.swift +0 -63
  92. package/app/Sources/CheatSheetHUD.swift +0 -332
  93. package/app/Sources/CommandModeState.swift +0 -1362
  94. package/app/Sources/CommandModeView.swift +0 -1405
  95. package/app/Sources/CommandModeWindow.swift +0 -192
  96. package/app/Sources/CommandPaletteView.swift +0 -307
  97. package/app/Sources/CommandPaletteWindow.swift +0 -134
  98. package/app/Sources/DaemonProtocol.swift +0 -101
  99. package/app/Sources/DaemonServer.swift +0 -414
  100. package/app/Sources/DesktopModel.swift +0 -121
  101. package/app/Sources/DesktopModelTypes.swift +0 -71
  102. package/app/Sources/DiagnosticLog.swift +0 -271
  103. package/app/Sources/EventBus.swift +0 -30
  104. package/app/Sources/HotkeyManager.swift +0 -250
  105. package/app/Sources/HotkeyStore.swift +0 -338
  106. package/app/Sources/InventoryManager.swift +0 -35
  107. package/app/Sources/InventoryPath.swift +0 -43
  108. package/app/Sources/KeyRecorderView.swift +0 -210
  109. package/app/Sources/LatticesApi.swift +0 -1125
  110. package/app/Sources/MainView.swift +0 -467
  111. package/app/Sources/MainWindow.swift +0 -83
  112. package/app/Sources/OcrModel.swift +0 -309
  113. package/app/Sources/OcrStore.swift +0 -295
  114. package/app/Sources/OmniSearchState.swift +0 -283
  115. package/app/Sources/OmniSearchView.swift +0 -288
  116. package/app/Sources/OmniSearchWindow.swift +0 -105
  117. package/app/Sources/OrphanRow.swift +0 -129
  118. package/app/Sources/PaletteCommand.swift +0 -419
  119. package/app/Sources/PermissionChecker.swift +0 -125
  120. package/app/Sources/Preferences.swift +0 -92
  121. package/app/Sources/ProcessModel.swift +0 -199
  122. package/app/Sources/ProcessQuery.swift +0 -151
  123. package/app/Sources/Project.swift +0 -28
  124. package/app/Sources/ProjectRow.swift +0 -368
  125. package/app/Sources/ProjectScanner.swift +0 -121
  126. package/app/Sources/ScreenMapState.swift +0 -2387
  127. package/app/Sources/ScreenMapView.swift +0 -2820
  128. package/app/Sources/ScreenMapWindowController.swift +0 -89
  129. package/app/Sources/SessionManager.swift +0 -72
  130. package/app/Sources/SettingsView.swift +0 -1053
  131. package/app/Sources/SettingsWindow.swift +0 -20
  132. package/app/Sources/TabGroupRow.swift +0 -178
  133. package/app/Sources/Terminal.swift +0 -259
  134. package/app/Sources/TerminalQuery.swift +0 -156
  135. package/app/Sources/TerminalSynthesizer.swift +0 -200
  136. package/app/Sources/Theme.swift +0 -163
  137. package/app/Sources/TilePickerView.swift +0 -209
  138. package/app/Sources/TmuxModel.swift +0 -53
  139. package/app/Sources/TmuxQuery.swift +0 -81
  140. package/app/Sources/WindowTiler.swift +0 -1755
  141. package/app/Sources/WorkspaceManager.swift +0 -434
  142. package/bin/lattices-app.js +0 -221
  143. package/bin/lattices.js +0 -1418
@@ -1,92 +0,0 @@
1
- import Foundation
2
-
3
- enum InteractionMode: String {
4
- case learning = "learning"
5
- case auto = "auto"
6
- }
7
-
8
- class Preferences: ObservableObject {
9
- static let shared = Preferences()
10
-
11
- @Published var terminal: Terminal {
12
- didSet { UserDefaults.standard.set(terminal.rawValue, forKey: "terminal") }
13
- }
14
-
15
- @Published var scanRoot: String {
16
- didSet { UserDefaults.standard.set(scanRoot, forKey: "scanRoot") }
17
- }
18
-
19
- @Published var mode: InteractionMode {
20
- didSet { UserDefaults.standard.set(mode.rawValue, forKey: "mode") }
21
- }
22
-
23
- // MARK: - Search & OCR
24
-
25
- @Published var ocrEnabled: Bool {
26
- didSet { UserDefaults.standard.set(!ocrEnabled, forKey: "ocr.disabled") }
27
- }
28
-
29
- @Published var ocrQuickInterval: Double {
30
- didSet { UserDefaults.standard.set(ocrQuickInterval, forKey: "ocr.interval") }
31
- }
32
-
33
- @Published var ocrDeepInterval: Double {
34
- didSet { UserDefaults.standard.set(ocrDeepInterval, forKey: "ocr.deepInterval") }
35
- }
36
-
37
- @Published var ocrQuickLimit: Int {
38
- didSet { UserDefaults.standard.set(ocrQuickLimit, forKey: "ocr.quickLimit") }
39
- }
40
-
41
- @Published var ocrDeepLimit: Int {
42
- didSet { UserDefaults.standard.set(ocrDeepLimit, forKey: "ocr.deepLimit") }
43
- }
44
-
45
- @Published var ocrAccuracy: String {
46
- didSet { UserDefaults.standard.set(ocrAccuracy, forKey: "ocr.accuracy") }
47
- }
48
-
49
- init() {
50
- if let saved = UserDefaults.standard.string(forKey: "terminal"),
51
- let t = Terminal(rawValue: saved), t.isInstalled {
52
- self.terminal = t
53
- } else {
54
- self.terminal = Terminal.installed.first ?? .terminal
55
- }
56
-
57
- let savedRoot = UserDefaults.standard.string(forKey: "scanRoot") ?? ""
58
- if savedRoot.isEmpty {
59
- // Auto-detect a reasonable default
60
- let home = NSHomeDirectory()
61
- let candidates = ["\(home)/dev", "\(home)/Developer", "\(home)/projects", "\(home)/src"]
62
- self.scanRoot = candidates.first { FileManager.default.fileExists(atPath: $0) } ?? ""
63
- } else {
64
- self.scanRoot = savedRoot
65
- }
66
-
67
- if let saved = UserDefaults.standard.string(forKey: "mode"),
68
- let m = InteractionMode(rawValue: saved) {
69
- self.mode = m
70
- } else {
71
- self.mode = .learning
72
- }
73
-
74
- // Search & OCR
75
- self.ocrEnabled = !UserDefaults.standard.bool(forKey: "ocr.disabled")
76
-
77
- let savedInterval = UserDefaults.standard.double(forKey: "ocr.interval")
78
- self.ocrQuickInterval = savedInterval > 0 ? savedInterval : 60
79
-
80
- let savedDeep = UserDefaults.standard.double(forKey: "ocr.deepInterval")
81
- self.ocrDeepInterval = savedDeep > 0 ? savedDeep : 7200
82
-
83
- let savedQL = UserDefaults.standard.integer(forKey: "ocr.quickLimit")
84
- self.ocrQuickLimit = savedQL > 0 ? savedQL : 5
85
-
86
- let savedDL = UserDefaults.standard.integer(forKey: "ocr.deepLimit")
87
- self.ocrDeepLimit = savedDL > 0 ? savedDL : 15
88
-
89
- let savedAcc = UserDefaults.standard.string(forKey: "ocr.accuracy") ?? "accurate"
90
- self.ocrAccuracy = savedAcc
91
- }
92
- }
@@ -1,199 +0,0 @@
1
- import Foundation
2
-
3
- final class ProcessModel: ObservableObject {
4
- static let shared = ProcessModel()
5
-
6
- @Published private(set) var processTable: [Int: ProcessEntry] = [:]
7
- @Published private(set) var childrenMap: [Int: [Int]] = [:] // ppid → [child pids]
8
- @Published private(set) var interesting: [ProcessEntry] = []
9
-
10
- private var timer: DispatchSourceTimer?
11
- private var lastInterestingPids: Set<Int> = []
12
-
13
- // Terminal tab cache — refreshed lazily when terminals are queried
14
- private var cachedTerminalTabs: [TerminalTab] = []
15
- private var lastTabQueryTime: Date = .distantPast
16
- private static let tabCacheTTL: TimeInterval = 300.0 // 5 minutes
17
-
18
- /// Background queue for process polling — avoids blocking the main thread
19
- /// with posix_spawn calls (waitUntilExit deadlocks on macOS 26 main run loop).
20
- private let pollQueue = DispatchQueue(label: "lattices.process-poll", qos: .userInitiated)
21
-
22
- func start(interval: TimeInterval = 5.0) {
23
- guard timer == nil else { return }
24
- DiagnosticLog.shared.info("ProcessModel: starting (interval=\(interval)s)")
25
-
26
- let source = DispatchSource.makeTimerSource(queue: pollQueue)
27
- source.schedule(deadline: .now(), repeating: interval)
28
- source.setEventHandler { [weak self] in
29
- self?.poll()
30
- }
31
- source.resume()
32
- timer = source
33
- }
34
-
35
- func stop() {
36
- timer?.cancel()
37
- timer = nil
38
- }
39
-
40
- // MARK: - Query Methods
41
-
42
- /// All interesting developer processes with CWDs resolved.
43
- func interestingProcesses() -> [ProcessEntry] {
44
- interesting
45
- }
46
-
47
- /// BFS walk all descendants of a given PID.
48
- func descendants(of pid: Int) -> [ProcessEntry] {
49
- var result: [ProcessEntry] = []
50
- var queue = childrenMap[pid] ?? []
51
- var visited: Set<Int> = [pid]
52
-
53
- while !queue.isEmpty {
54
- let childPid = queue.removeFirst()
55
- guard !visited.contains(childPid) else { continue }
56
- visited.insert(childPid)
57
- if let entry = processTable[childPid] {
58
- result.append(entry)
59
- }
60
- if let grandchildren = childrenMap[childPid] {
61
- queue.append(contentsOf: grandchildren)
62
- }
63
- }
64
- return result
65
- }
66
-
67
- /// BFS descendants filtered to interesting commands only.
68
- func interestingDescendants(of pid: Int) -> [ProcessEntry] {
69
- descendants(of: pid).filter { ProcessQuery.interestingCommands.contains($0.comm) }
70
- }
71
-
72
- // MARK: - Enrichment
73
-
74
- struct Enrichment {
75
- let process: ProcessEntry
76
- let tmuxSession: String?
77
- let tmuxPaneId: String?
78
- let windowId: UInt32?
79
- }
80
-
81
- /// Walk ppid chain from a process upward until we find a tmux pane_pid.
82
- /// Returns (sessionName, paneId) or nil.
83
- func tmuxLinkage(for entry: ProcessEntry) -> (session: String, paneId: String)? {
84
- let paneLookup = buildPaneLookup()
85
- var current = entry.pid
86
- // Walk up at most 10 hops (typically 2-3)
87
- for _ in 0..<10 {
88
- if let match = paneLookup[current] {
89
- return match
90
- }
91
- guard let parent = processTable[current]?.ppid, parent != current, parent > 1 else {
92
- break
93
- }
94
- current = parent
95
- }
96
- return nil
97
- }
98
-
99
- /// Enrich a single process with tmux + window linkage.
100
- func enrich(_ entry: ProcessEntry) -> Enrichment {
101
- if let link = tmuxLinkage(for: entry) {
102
- let win = DesktopModel.shared.windowForSession(link.session)
103
- return Enrichment(
104
- process: entry,
105
- tmuxSession: link.session,
106
- tmuxPaneId: link.paneId,
107
- windowId: win?.wid
108
- )
109
- }
110
- return Enrichment(process: entry, tmuxSession: nil, tmuxPaneId: nil, windowId: nil)
111
- }
112
-
113
- /// Enrich all interesting processes.
114
- func enrichedProcesses() -> [Enrichment] {
115
- interesting.map { enrich($0) }
116
- }
117
-
118
- // MARK: - Terminal Synthesis (on-demand)
119
-
120
- /// Synthesize terminal instances on demand. Merges the current process table,
121
- /// tmux sessions, terminal tabs (cached), and window list into a unified view.
122
- /// Called by API endpoints — no background polling needed.
123
- func synthesizeTerminals() -> [TerminalInstance] {
124
- // Refresh tab cache if stale
125
- let now = Date()
126
- if now.timeIntervalSince(lastTabQueryTime) >= Self.tabCacheTTL {
127
- cachedTerminalTabs = TerminalQuery.queryAll()
128
- lastTabQueryTime = now
129
- }
130
-
131
- return TerminalSynthesizer.synthesize(
132
- processTable: processTable,
133
- interesting: interesting,
134
- tmuxSessions: TmuxModel.shared.sessions,
135
- terminalTabs: cachedTerminalTabs,
136
- windows: DesktopModel.shared.windows
137
- )
138
- }
139
-
140
- /// Force-refresh the terminal tab cache (e.g. on first query or explicit refresh).
141
- func refreshTerminalTabs() {
142
- cachedTerminalTabs = TerminalQuery.queryAll()
143
- lastTabQueryTime = Date()
144
- }
145
-
146
- // MARK: - Polling (runs on pollQueue)
147
-
148
- func poll() {
149
- // 1. Full process snapshot
150
- var table = ProcessQuery.snapshot()
151
-
152
- // 2. Build parent → children map
153
- var children: [Int: [Int]] = [:]
154
- for (pid, entry) in table {
155
- children[entry.ppid, default: []].append(pid)
156
- }
157
-
158
- // 3. Filter interesting, batch-resolve CWDs
159
- let interestingEntries = ProcessQuery.filterInteresting(table)
160
- let pids = interestingEntries.map(\.pid)
161
- let cwds = ProcessQuery.batchCWD(pids: pids)
162
-
163
- // 4. Merge CWDs back into table
164
- for (pid, cwd) in cwds {
165
- table[pid]?.cwd = cwd
166
- }
167
-
168
- let freshInteresting = pids.compactMap { table[$0] }
169
- let freshPidSet = Set(pids)
170
-
171
- // 5. Detect change
172
- let changed = freshPidSet != lastInterestingPids
173
-
174
- DispatchQueue.main.async {
175
- self.processTable = table
176
- self.childrenMap = children
177
- self.interesting = freshInteresting
178
- }
179
-
180
- lastInterestingPids = freshPidSet
181
-
182
- if changed {
183
- EventBus.shared.post(.processesChanged(interesting: Array(freshPidSet)))
184
- }
185
- }
186
-
187
- // MARK: - Private
188
-
189
- /// Build [pane_pid: (sessionName, paneId)] from current TmuxModel state.
190
- private func buildPaneLookup() -> [Int: (session: String, paneId: String)] {
191
- var lookup: [Int: (session: String, paneId: String)] = [:]
192
- for session in TmuxModel.shared.sessions {
193
- for pane in session.panes {
194
- lookup[pane.pid] = (session: session.name, paneId: pane.id)
195
- }
196
- }
197
- return lookup
198
- }
199
- }
@@ -1,151 +0,0 @@
1
- import Foundation
2
-
3
- // MARK: - Data Models
4
-
5
- struct ProcessEntry {
6
- let pid: Int
7
- let ppid: Int
8
- let pgid: Int
9
- let tty: String // "ttys003" or "??"
10
- let comm: String // basename, e.g. "node"
11
- let args: String // full command line
12
- var cwd: String? // filled by batchCWD
13
- }
14
-
15
- // MARK: - Query
16
-
17
- enum ProcessQuery {
18
-
19
- /// Process names we care about for developer workspace enrichment
20
- static let interestingCommands: Set<String> = [
21
- "claude", "node", "bun", "deno", "python", "python3",
22
- "ruby", "go", "cargo", "nvim", "vim", "npm", "npx",
23
- "pnpm", "swift", "make", "git"
24
- ]
25
-
26
- /// Snapshot the full process table in a single `ps` call.
27
- /// Returns [pid: ProcessEntry].
28
- static func snapshot() -> [Int: ProcessEntry] {
29
- let raw = shell([
30
- "/bin/ps", "-eo", "pid,ppid,pgid,tty,comm,args"
31
- ])
32
- guard !raw.isEmpty else { return [:] }
33
-
34
- var table: [Int: ProcessEntry] = [:]
35
- let lines = raw.split(separator: "\n", omittingEmptySubsequences: true)
36
-
37
- for line in lines.dropFirst() { // skip header
38
- let str = String(line)
39
- // Columns are whitespace-separated; args can contain spaces.
40
- // Format: " PID PPID PGID TTY COMM ARGS"
41
- let trimmed = str.trimmingCharacters(in: .whitespaces)
42
- let parts = trimmed.split(separator: " ", maxSplits: 5, omittingEmptySubsequences: true)
43
- guard parts.count >= 6 else { continue }
44
-
45
- guard let pid = Int(parts[0]),
46
- let ppid = Int(parts[1]),
47
- let pgid = Int(parts[2]) else { continue }
48
-
49
- let tty = String(parts[3])
50
- let commFull = String(parts[4])
51
- let args = String(parts[5])
52
-
53
- // comm from ps is the full path; take basename
54
- let comm = (commFull as NSString).lastPathComponent
55
-
56
- table[pid] = ProcessEntry(
57
- pid: pid, ppid: ppid, pgid: pgid,
58
- tty: tty, comm: comm, args: args, cwd: nil
59
- )
60
- }
61
-
62
- return table
63
- }
64
-
65
- /// Batch-resolve working directories for a set of PIDs via a single `lsof` call.
66
- /// Returns [pid: cwdPath].
67
- static func batchCWD(pids: [Int]) -> [Int: String] {
68
- guard !pids.isEmpty else { return [:] }
69
-
70
- let pidList = pids.map(String.init).joined(separator: ",")
71
- let raw = shell([
72
- "/usr/sbin/lsof", "-a", "-d", "cwd", "-p", pidList, "-Fn"
73
- ])
74
- guard !raw.isEmpty else { return [:] }
75
-
76
- var result: [Int: String] = [:]
77
- var currentPid: Int?
78
-
79
- for line in raw.split(separator: "\n", omittingEmptySubsequences: true) {
80
- let s = String(line)
81
- if s.hasPrefix("p") {
82
- currentPid = Int(s.dropFirst())
83
- } else if s.hasPrefix("n"), let pid = currentPid {
84
- result[pid] = String(s.dropFirst())
85
- }
86
- }
87
-
88
- return result
89
- }
90
-
91
- /// Filter a process table down to interesting developer processes.
92
- static func filterInteresting(_ table: [Int: ProcessEntry]) -> [ProcessEntry] {
93
- table.values.filter { interestingCommands.contains($0.comm) }
94
- }
95
-
96
- // MARK: - Shell helper
97
-
98
- /// Run a command and capture stdout using posix_spawn + waitpid.
99
- /// Avoids Process/NSTask's waitUntilExit() which deadlocks on macOS 26
100
- /// when called from GUI apps (CFRunLoop issue).
101
- static func shell(_ args: [String]) -> String {
102
- // Set up stdout pipe
103
- var pipeFds: [Int32] = [0, 0]
104
- guard pipe(&pipeFds) == 0 else { return "" }
105
-
106
- // File actions: stdout → write end of pipe, stderr → /dev/null
107
- var fileActions: posix_spawn_file_actions_t?
108
- posix_spawn_file_actions_init(&fileActions)
109
- posix_spawn_file_actions_adddup2(&fileActions, pipeFds[1], STDOUT_FILENO)
110
- posix_spawn_file_actions_addopen(&fileActions, STDERR_FILENO, "/dev/null", O_WRONLY, 0)
111
- posix_spawn_file_actions_addclose(&fileActions, pipeFds[0])
112
- posix_spawn_file_actions_addclose(&fileActions, pipeFds[1])
113
-
114
- // Build C strings
115
- let cPath = args[0]
116
- let cArgs = args.map { strdup($0) } + [nil]
117
- defer { cArgs.compactMap({ $0 }).forEach { free($0) } }
118
-
119
- var pid: pid_t = 0
120
- let spawnResult = cPath.withCString { path in
121
- posix_spawn(&pid, path, &fileActions, nil, cArgs, environ)
122
- }
123
- posix_spawn_file_actions_destroy(&fileActions)
124
-
125
- // Close write end in parent
126
- close(pipeFds[1])
127
-
128
- guard spawnResult == 0 else {
129
- close(pipeFds[0])
130
- return ""
131
- }
132
-
133
- // Read all stdout
134
- var data = Data()
135
- let bufSize = 65536
136
- var buf = [UInt8](repeating: 0, count: bufSize)
137
- while true {
138
- let n = read(pipeFds[0], &buf, bufSize)
139
- if n <= 0 { break }
140
- data.append(buf, count: n)
141
- }
142
- close(pipeFds[0])
143
-
144
- // Wait for child
145
- var status: Int32 = 0
146
- waitpid(pid, &status, 0)
147
-
148
- guard status == 0 else { return "" }
149
- return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
150
- }
151
- }
@@ -1,28 +0,0 @@
1
- import CryptoKit
2
- import Foundation
3
-
4
- struct Project: Identifiable {
5
- let id: String
6
- let path: String
7
- let name: String
8
- let devCommand: String?
9
- let packageManager: String?
10
- let hasConfig: Bool
11
- let paneCount: Int
12
- let paneNames: [String]
13
- let paneSummary: String
14
- var isRunning: Bool
15
-
16
- /// Unique session name: basename-{6-char SHA256 hash of full path}
17
- /// Must match the JS `toSessionName()` in lattices.js exactly
18
- var sessionName: String {
19
- let base = name.replacingOccurrences(
20
- of: "[^a-zA-Z0-9_-]",
21
- with: "-",
22
- options: .regularExpression
23
- )
24
- let hash = SHA256.hash(data: Data(path.utf8))
25
- let short = hash.prefix(3).map { String(format: "%02x", $0) }.joined()
26
- return "\(base)-\(short)"
27
- }
28
- }