@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
@@ -0,0 +1,815 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ struct PiChatMessage: Identifiable, Equatable {
5
+ enum Role {
6
+ case system
7
+ case user
8
+ case assistant
9
+ }
10
+
11
+ let id = UUID()
12
+ let role: Role
13
+ let text: String
14
+ let timestamp: Date
15
+ }
16
+
17
+ struct PiAuthPrompt: Equatable {
18
+ let message: String
19
+ let placeholder: String?
20
+ let allowEmpty: Bool
21
+ }
22
+
23
+ struct PiProvider: Identifiable, Equatable {
24
+ enum AuthMode {
25
+ case apiKey
26
+ case oauth
27
+ }
28
+
29
+ let id: String
30
+ let name: String
31
+ let authMode: AuthMode
32
+ let tokenLabel: String
33
+ let tokenPlaceholder: String
34
+ let helpText: String
35
+
36
+ static let supported: [PiProvider] = [
37
+ PiProvider(
38
+ id: "github-copilot",
39
+ name: "GitHub Copilot",
40
+ authMode: .oauth,
41
+ tokenLabel: "OAuth",
42
+ tokenPlaceholder: "",
43
+ helpText: "Uses Pi's device-code login. Personal access tokens are not accepted on this path."
44
+ ),
45
+ PiProvider(
46
+ id: "openai-codex",
47
+ name: "OpenAI Codex",
48
+ authMode: .oauth,
49
+ tokenLabel: "OAuth",
50
+ tokenPlaceholder: "",
51
+ helpText: "Uses Pi's browser login for ChatGPT Plus/Pro Codex access."
52
+ ),
53
+ PiProvider(
54
+ id: "openai",
55
+ name: "OpenAI",
56
+ authMode: .apiKey,
57
+ tokenLabel: "API key",
58
+ tokenPlaceholder: "sk-...",
59
+ helpText: "Stores an OpenAI API key in Pi's auth.json for this app and Pi CLI to reuse."
60
+ ),
61
+ PiProvider(
62
+ id: "anthropic",
63
+ name: "Anthropic",
64
+ authMode: .apiKey,
65
+ tokenLabel: "API key",
66
+ tokenPlaceholder: "sk-ant-...",
67
+ helpText: "Stores an Anthropic API key for Pi. OAuth-capable Anthropic flows can be added later."
68
+ ),
69
+ PiProvider(
70
+ id: "google",
71
+ name: "Google Gemini",
72
+ authMode: .apiKey,
73
+ tokenLabel: "API key",
74
+ tokenPlaceholder: "AIza...",
75
+ helpText: "Stores a Gemini API key for Pi's Google provider."
76
+ ),
77
+ PiProvider(
78
+ id: "openrouter",
79
+ name: "OpenRouter",
80
+ authMode: .apiKey,
81
+ tokenLabel: "API key",
82
+ tokenPlaceholder: "sk-or-...",
83
+ helpText: "Stores an OpenRouter API key for Pi."
84
+ ),
85
+ PiProvider(
86
+ id: "groq",
87
+ name: "Groq",
88
+ authMode: .apiKey,
89
+ tokenLabel: "API key",
90
+ tokenPlaceholder: "gsk_...",
91
+ helpText: "Stores a Groq API key for Pi."
92
+ ),
93
+ PiProvider(
94
+ id: "xai",
95
+ name: "xAI",
96
+ authMode: .apiKey,
97
+ tokenLabel: "API key",
98
+ tokenPlaceholder: "xai-...",
99
+ helpText: "Stores an xAI API key for Pi."
100
+ ),
101
+ PiProvider(
102
+ id: "mistral",
103
+ name: "Mistral",
104
+ authMode: .apiKey,
105
+ tokenLabel: "API key",
106
+ tokenPlaceholder: "",
107
+ helpText: "Stores a Mistral API key for Pi."
108
+ ),
109
+ PiProvider(
110
+ id: "minimax",
111
+ name: "MiniMax",
112
+ authMode: .apiKey,
113
+ tokenLabel: "API key",
114
+ tokenPlaceholder: "",
115
+ helpText: "Stores a MiniMax API key for Pi."
116
+ ),
117
+ ]
118
+
119
+ static func provider(id: String) -> PiProvider {
120
+ supported.first(where: { $0.id == id }) ?? supported[0]
121
+ }
122
+ }
123
+
124
+ final class PiChatSession: ObservableObject {
125
+ static let shared = PiChatSession()
126
+
127
+ @Published private(set) var messages: [PiChatMessage] = [
128
+ PiChatMessage(
129
+ role: .system,
130
+ text: "Pi dock ready. This is a lightweight in-app conversation surface, not a full terminal.",
131
+ timestamp: Date()
132
+ )
133
+ ]
134
+ @Published var draft: String = ""
135
+ @Published var isVisible: Bool = false
136
+ @Published private(set) var isSending: Bool = false
137
+ @Published private(set) var statusText: String = "idle"
138
+ @Published var dockHeight: CGFloat = 230 {
139
+ didSet {
140
+ dockHeight = Self.clampDockHeight(dockHeight)
141
+ UserDefaults.standard.set(dockHeight, forKey: Self.dockHeightDefaultsKey)
142
+ }
143
+ }
144
+ @Published var isAuthPanelVisible: Bool = false
145
+ @Published var authProviderID: String = "minimax" {
146
+ didSet {
147
+ guard oldValue != authProviderID else { return }
148
+ UserDefaults.standard.set(authProviderID, forKey: Self.selectedProviderDefaultsKey)
149
+ authToken = ""
150
+ authPromptInput = ""
151
+ pendingAuthPrompt = nil
152
+ authNoticeText = nil
153
+ authErrorText = nil
154
+ }
155
+ }
156
+ @Published var authToken: String = ""
157
+ @Published var authPromptInput: String = ""
158
+ @Published private(set) var isAuthenticating: Bool = false
159
+ @Published private(set) var pendingAuthPrompt: PiAuthPrompt?
160
+ @Published private(set) var authNoticeText: String?
161
+ @Published private(set) var authErrorText: String?
162
+ @Published private(set) var storedCredentialKinds: [String: String] = [:]
163
+
164
+ private let queue = DispatchQueue(label: "pi-chat-session", qos: .userInitiated)
165
+ private let sessionFileURL: URL
166
+ private let authFileURL: URL
167
+ private var authProcess: Process?
168
+ private var authInputHandle: FileHandle?
169
+ private var authStdoutBuffer: String = ""
170
+ private var authStderrBuffer: String = ""
171
+
172
+ private static let selectedProviderDefaultsKey = "PiChatSelectedProvider"
173
+ private static let dockHeightDefaultsKey = "PiChatDockHeight"
174
+
175
+ private init() {
176
+ let fm = FileManager.default
177
+ let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
178
+ ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
179
+ let dir = base.appendingPathComponent("Lattices/pi-chat", isDirectory: true)
180
+ try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
181
+ sessionFileURL = dir.appendingPathComponent("session.jsonl")
182
+ authFileURL = Self.piAgentDirURL().appendingPathComponent("auth.json")
183
+
184
+ if let savedProvider = UserDefaults.standard.string(forKey: Self.selectedProviderDefaultsKey),
185
+ PiProvider.supported.contains(where: { $0.id == savedProvider }) {
186
+ authProviderID = savedProvider
187
+ }
188
+ let savedDockHeight = UserDefaults.standard.double(forKey: Self.dockHeightDefaultsKey)
189
+ if savedDockHeight > 0 {
190
+ dockHeight = Self.clampDockHeight(savedDockHeight)
191
+ }
192
+
193
+ reloadAuthState()
194
+ }
195
+
196
+ var hasPiBinary: Bool {
197
+ resolvePiPath() != nil
198
+ }
199
+
200
+ var providerOptions: [PiProvider] {
201
+ PiProvider.supported
202
+ }
203
+
204
+ var currentProvider: PiProvider {
205
+ PiProvider.provider(id: authProviderID)
206
+ }
207
+
208
+ var selectedCredentialSummary: String {
209
+ guard let kind = storedCredentialKinds[authProviderID] else { return "not authenticated" }
210
+ return kind == "oauth" ? "oauth saved" : "token saved"
211
+ }
212
+
213
+ var hasSelectedCredential: Bool {
214
+ storedCredentialKinds[authProviderID] != nil
215
+ }
216
+
217
+ var canSubmitAuthPrompt: Bool {
218
+ guard let prompt = pendingAuthPrompt else { return false }
219
+ let value = authPromptInput.trimmingCharacters(in: .whitespacesAndNewlines)
220
+ return prompt.allowEmpty || !value.isEmpty
221
+ }
222
+
223
+ func toggleVisibility() {
224
+ isVisible.toggle()
225
+ }
226
+
227
+ func toggleAuthPanel() {
228
+ isAuthPanelVisible.toggle()
229
+ if isAuthPanelVisible {
230
+ dockHeight = max(dockHeight, 300)
231
+ }
232
+ }
233
+
234
+ func clearConversation() {
235
+ try? FileManager.default.removeItem(at: sessionFileURL)
236
+ messages = [
237
+ PiChatMessage(
238
+ role: .system,
239
+ text: "Started a fresh Pi conversation.",
240
+ timestamp: Date()
241
+ )
242
+ ]
243
+ statusText = "idle"
244
+ }
245
+
246
+ func sendDraft() {
247
+ let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
248
+ guard !text.isEmpty else { return }
249
+ draft = ""
250
+ send(text)
251
+ }
252
+
253
+ func send(_ text: String) {
254
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
255
+ guard !trimmed.isEmpty else { return }
256
+ guard !isSending else { return }
257
+
258
+ messages.append(PiChatMessage(role: .user, text: trimmed, timestamp: Date()))
259
+
260
+ guard let piPath = resolvePiPath() else {
261
+ appendSystemMessage("Pi CLI not found. Install `pi` or add it to PATH.")
262
+ statusText = "missing pi"
263
+ return
264
+ }
265
+
266
+ let provider = currentProvider
267
+ isSending = true
268
+ statusText = "thinking..."
269
+
270
+ queue.async { [weak self] in
271
+ guard let self else { return }
272
+
273
+ let proc = Process()
274
+ proc.executableURL = URL(fileURLWithPath: piPath)
275
+ proc.arguments = [
276
+ "--provider", provider.id,
277
+ "-p",
278
+ "--session", self.sessionFileURL.path,
279
+ trimmed,
280
+ ]
281
+
282
+ var env = ProcessInfo.processInfo.environment
283
+ env.removeValue(forKey: "CLAUDECODE")
284
+ if provider.id == "github-copilot", self.storedCredentialKinds[provider.id] == nil {
285
+ env.removeValue(forKey: "COPILOT_GITHUB_TOKEN")
286
+ }
287
+ Self.sanitizeEnvironment(&env, for: provider.id, hasStoredCredential: self.storedCredentialKinds[provider.id] != nil)
288
+ proc.environment = env
289
+
290
+ let outPipe = Pipe()
291
+ let errPipe = Pipe()
292
+ proc.standardOutput = outPipe
293
+ proc.standardError = errPipe
294
+
295
+ let stdout: String
296
+ let stderr: String
297
+ let exitCode: Int32
298
+
299
+ do {
300
+ try proc.run()
301
+ proc.waitUntilExit()
302
+ stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
303
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
304
+ stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
305
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
306
+ exitCode = proc.terminationStatus
307
+ } catch {
308
+ DispatchQueue.main.async {
309
+ self.isSending = false
310
+ self.statusText = "launch failed"
311
+ self.appendSystemMessage("Failed to launch Pi: \(error.localizedDescription)")
312
+ }
313
+ return
314
+ }
315
+
316
+ DispatchQueue.main.async {
317
+ self.isSending = false
318
+
319
+ if exitCode == 0, !stdout.isEmpty {
320
+ self.statusText = "idle"
321
+ self.messages.append(PiChatMessage(
322
+ role: .assistant,
323
+ text: stdout,
324
+ timestamp: Date()
325
+ ))
326
+ return
327
+ }
328
+
329
+ let message = !stderr.isEmpty ? stderr : (stdout.isEmpty ? "Pi returned no output." : stdout)
330
+ self.statusText = "error"
331
+ self.appendSystemMessage(message)
332
+ if Self.looksLikeAuthError(message) {
333
+ self.isAuthPanelVisible = true
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ func saveSelectedToken() {
340
+ let token = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
341
+ guard !token.isEmpty else {
342
+ authErrorText = "Enter a token before saving."
343
+ return
344
+ }
345
+
346
+ do {
347
+ try mutateAuthFile { auth in
348
+ auth[authProviderID] = [
349
+ "type": "api_key",
350
+ "key": token,
351
+ ]
352
+ }
353
+ authToken = ""
354
+ authNoticeText = "Saved \(currentProvider.tokenLabel.lowercased()) for \(currentProvider.name)."
355
+ authErrorText = nil
356
+ reloadAuthState()
357
+ appendSystemMessage("Saved \(currentProvider.name) credentials to Pi auth storage.")
358
+ } catch {
359
+ authErrorText = "Failed to save token: \(error.localizedDescription)"
360
+ }
361
+ }
362
+
363
+ func removeSelectedCredential() {
364
+ do {
365
+ try mutateAuthFile { auth in
366
+ auth.removeValue(forKey: authProviderID)
367
+ }
368
+ authNoticeText = "Removed saved credentials for \(currentProvider.name)."
369
+ authErrorText = nil
370
+ reloadAuthState()
371
+ appendSystemMessage("Removed saved \(currentProvider.name) credentials from Pi auth storage.")
372
+ } catch {
373
+ authErrorText = "Failed to remove credentials: \(error.localizedDescription)"
374
+ }
375
+ }
376
+
377
+ func startSelectedAuthFlow() {
378
+ if currentProvider.authMode == .apiKey {
379
+ saveSelectedToken()
380
+ return
381
+ }
382
+
383
+ startOAuthLogin(for: currentProvider)
384
+ }
385
+
386
+ func submitAuthPrompt() {
387
+ guard let prompt = pendingAuthPrompt else { return }
388
+ let value = authPromptInput.trimmingCharacters(in: .whitespacesAndNewlines)
389
+ guard prompt.allowEmpty || !value.isEmpty else { return }
390
+
391
+ guard let handle = authInputHandle else {
392
+ authErrorText = "Pi auth input pipe is no longer available."
393
+ return
394
+ }
395
+
396
+ let line = value + "\n"
397
+ if let data = line.data(using: .utf8) {
398
+ do {
399
+ try handle.write(contentsOf: data)
400
+ authPromptInput = ""
401
+ pendingAuthPrompt = nil
402
+ } catch {
403
+ authErrorText = "Failed to send auth input: \(error.localizedDescription)"
404
+ }
405
+ }
406
+ }
407
+
408
+ func cancelAuthFlow() {
409
+ authProcess?.terminate()
410
+ cleanupAuthProcess()
411
+ isAuthenticating = false
412
+ authNoticeText = "Cancelled auth flow."
413
+ }
414
+
415
+ private func startOAuthLogin(for provider: PiProvider) {
416
+ guard !isAuthenticating else {
417
+ authErrorText = "An auth flow is already running."
418
+ return
419
+ }
420
+
421
+ guard let nodePath = resolveNodePath() else {
422
+ authErrorText = "Node.js is required for Pi OAuth login."
423
+ return
424
+ }
425
+
426
+ guard let oauthModuleURL = resolveOAuthModuleURL() else {
427
+ authErrorText = "Couldn't locate Pi's OAuth module next to the installed `pi` CLI."
428
+ return
429
+ }
430
+
431
+ let proc = Process()
432
+ proc.executableURL = URL(fileURLWithPath: nodePath)
433
+ proc.arguments = [
434
+ "--input-type=module",
435
+ "--eval",
436
+ Self.oauthDriverScript,
437
+ provider.id,
438
+ oauthModuleURL.absoluteString,
439
+ ]
440
+
441
+ let stdinPipe = Pipe()
442
+ let stdoutPipe = Pipe()
443
+ let stderrPipe = Pipe()
444
+ proc.standardInput = stdinPipe
445
+ proc.standardOutput = stdoutPipe
446
+ proc.standardError = stderrPipe
447
+
448
+ authStdoutBuffer = ""
449
+ authStderrBuffer = ""
450
+ authPromptInput = ""
451
+ pendingAuthPrompt = nil
452
+ authNoticeText = "Starting \(provider.name) login..."
453
+ authErrorText = nil
454
+ isAuthenticating = true
455
+
456
+ stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
457
+ let data = handle.availableData
458
+ guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
459
+ self?.handleAuthStdout(text)
460
+ }
461
+
462
+ stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
463
+ let data = handle.availableData
464
+ guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
465
+ self?.handleAuthStderr(text)
466
+ }
467
+
468
+ proc.terminationHandler = { [weak self] process in
469
+ DispatchQueue.main.async {
470
+ self?.handleAuthProcessExit(status: process.terminationStatus)
471
+ }
472
+ }
473
+
474
+ do {
475
+ try proc.run()
476
+ authProcess = proc
477
+ authInputHandle = stdinPipe.fileHandleForWriting
478
+ appendSystemMessage("Started \(provider.name) auth flow.")
479
+ } catch {
480
+ cleanupAuthProcess()
481
+ isAuthenticating = false
482
+ authErrorText = "Failed to launch auth flow: \(error.localizedDescription)"
483
+ }
484
+ }
485
+
486
+ private func handleAuthStdout(_ text: String) {
487
+ DispatchQueue.main.async {
488
+ self.authStdoutBuffer.append(text)
489
+ self.consumeBufferedAuthLines(buffer: &self.authStdoutBuffer, handler: self.handleAuthEventLine(_:))
490
+ }
491
+ }
492
+
493
+ private func handleAuthStderr(_ text: String) {
494
+ DispatchQueue.main.async {
495
+ self.authStderrBuffer.append(text)
496
+ self.consumeBufferedAuthLines(buffer: &self.authStderrBuffer) { line in
497
+ guard !line.isEmpty else { return }
498
+ self.authNoticeText = line
499
+ }
500
+ }
501
+ }
502
+
503
+ private func consumeBufferedAuthLines(buffer: inout String, handler: (String) -> Void) {
504
+ while let range = buffer.range(of: "\n") {
505
+ let line = String(buffer[..<range.lowerBound])
506
+ buffer.removeSubrange(buffer.startIndex...range.lowerBound)
507
+ handler(line.trimmingCharacters(in: .whitespacesAndNewlines))
508
+ }
509
+ }
510
+
511
+ private func handleAuthEventLine(_ line: String) {
512
+ guard !line.isEmpty else { return }
513
+ guard let data = line.data(using: .utf8),
514
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
515
+ let type = json["type"] as? String else {
516
+ authNoticeText = line
517
+ return
518
+ }
519
+
520
+ switch type {
521
+ case "prompt":
522
+ pendingAuthPrompt = PiAuthPrompt(
523
+ message: json["message"] as? String ?? "Continue",
524
+ placeholder: json["placeholder"] as? String,
525
+ allowEmpty: json["allowEmpty"] as? Bool ?? false
526
+ )
527
+ authNoticeText = pendingAuthPrompt?.message
528
+
529
+ case "auth":
530
+ let urlString = json["url"] as? String ?? ""
531
+ let instructions = json["instructions"] as? String
532
+ authNoticeText = instructions ?? "Continue in your browser."
533
+ if let url = URL(string: urlString) {
534
+ NSWorkspace.shared.open(url)
535
+ }
536
+ if let instructions, !instructions.isEmpty {
537
+ appendSystemMessage("Pi auth: \(instructions)")
538
+ }
539
+
540
+ case "progress":
541
+ authNoticeText = json["message"] as? String ?? "Working..."
542
+
543
+ case "success":
544
+ guard var credentials = json["credentials"] as? [String: Any] else {
545
+ authErrorText = "Pi auth completed but returned no credentials."
546
+ return
547
+ }
548
+ credentials["type"] = "oauth"
549
+ do {
550
+ try mutateAuthFile { auth in
551
+ auth[authProviderID] = credentials
552
+ }
553
+ reloadAuthState()
554
+ authNoticeText = "Saved OAuth credentials for \(currentProvider.name)."
555
+ authErrorText = nil
556
+ appendSystemMessage("Saved \(currentProvider.name) OAuth credentials to Pi auth storage.")
557
+ } catch {
558
+ authErrorText = "Failed to save OAuth credentials: \(error.localizedDescription)"
559
+ }
560
+
561
+ case "error":
562
+ let message = json["message"] as? String ?? "Unknown Pi auth error."
563
+ authErrorText = message
564
+ appendSystemMessage("Pi auth failed: \(message)")
565
+
566
+ default:
567
+ authNoticeText = line
568
+ }
569
+ }
570
+
571
+ private func handleAuthProcessExit(status: Int32) {
572
+ let hadExplicitError = authErrorText != nil
573
+ cleanupAuthProcess()
574
+ isAuthenticating = false
575
+ pendingAuthPrompt = nil
576
+
577
+ if status == 0 {
578
+ if !hadExplicitError {
579
+ authNoticeText = authNoticeText ?? "Auth flow finished."
580
+ }
581
+ } else if !hadExplicitError {
582
+ authErrorText = "Auth flow exited with status \(status)."
583
+ }
584
+ }
585
+
586
+ private func cleanupAuthProcess() {
587
+ authProcess?.standardInput = nil
588
+ if let output = authProcess?.standardOutput as? Pipe {
589
+ output.fileHandleForReading.readabilityHandler = nil
590
+ }
591
+ if let error = authProcess?.standardError as? Pipe {
592
+ error.fileHandleForReading.readabilityHandler = nil
593
+ }
594
+ authInputHandle = nil
595
+ authProcess = nil
596
+ }
597
+
598
+ private func appendSystemMessage(_ text: String) {
599
+ messages.append(PiChatMessage(role: .system, text: text, timestamp: Date()))
600
+ }
601
+
602
+ private func reloadAuthState() {
603
+ let auth = loadAuthFile()
604
+ var kinds: [String: String] = [:]
605
+
606
+ for (providerID, rawValue) in auth {
607
+ guard let record = rawValue as? [String: Any],
608
+ let type = record["type"] as? String else { continue }
609
+ kinds[providerID] = type
610
+ }
611
+
612
+ storedCredentialKinds = kinds
613
+ }
614
+
615
+ private func loadAuthFile() -> [String: Any] {
616
+ guard let data = try? Data(contentsOf: authFileURL), !data.isEmpty else { return [:] }
617
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
618
+ return json
619
+ }
620
+
621
+ private func mutateAuthFile(_ mutate: (inout [String: Any]) -> Void) throws {
622
+ let fm = FileManager.default
623
+ let dir = authFileURL.deletingLastPathComponent()
624
+ try fm.createDirectory(at: dir, withIntermediateDirectories: true)
625
+
626
+ var auth = loadAuthFile()
627
+ mutate(&auth)
628
+
629
+ let data = try JSONSerialization.data(withJSONObject: auth, options: [.prettyPrinted, .sortedKeys])
630
+ try data.write(to: authFileURL, options: .atomic)
631
+ try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path)
632
+ try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: authFileURL.path)
633
+ }
634
+
635
+ private func resolvePiPath() -> String? {
636
+ resolveCommandPath(
637
+ named: "pi",
638
+ candidates: [
639
+ "/opt/homebrew/bin/pi",
640
+ "/usr/local/bin/pi",
641
+ NSHomeDirectory() + "/.local/bin/pi",
642
+ ]
643
+ )
644
+ }
645
+
646
+ private func resolveNodePath() -> String? {
647
+ resolveCommandPath(
648
+ named: "node",
649
+ candidates: [
650
+ "/opt/homebrew/bin/node",
651
+ "/usr/local/bin/node",
652
+ "/usr/bin/node",
653
+ ]
654
+ )
655
+ }
656
+
657
+ private func resolveCommandPath(named command: String, candidates: [String]) -> String? {
658
+ for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
659
+ return path
660
+ }
661
+
662
+ let proc = Process()
663
+ proc.executableURL = URL(fileURLWithPath: "/bin/sh")
664
+ proc.arguments = ["-c", "which \(command) 2>/dev/null"]
665
+ let pipe = Pipe()
666
+ proc.standardOutput = pipe
667
+ proc.standardError = FileHandle.nullDevice
668
+ try? proc.run()
669
+ proc.waitUntilExit()
670
+ let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
671
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
672
+ return output.isEmpty ? nil : output
673
+ }
674
+
675
+ private func resolveOAuthModuleURL() -> URL? {
676
+ guard let packageRoot = resolvePiPackageRoot() else { return nil }
677
+ let moduleURL = packageRoot
678
+ .appendingPathComponent("node_modules")
679
+ .appendingPathComponent("@mariozechner")
680
+ .appendingPathComponent("pi-ai")
681
+ .appendingPathComponent("dist")
682
+ .appendingPathComponent("utils")
683
+ .appendingPathComponent("oauth")
684
+ .appendingPathComponent("index.js")
685
+ return FileManager.default.fileExists(atPath: moduleURL.path) ? moduleURL : nil
686
+ }
687
+
688
+ private func resolvePiPackageRoot() -> URL? {
689
+ guard let piPath = resolvePiPath() else { return nil }
690
+ let resolved = URL(fileURLWithPath: piPath).resolvingSymlinksInPath()
691
+ guard resolved.lastPathComponent == "cli.js",
692
+ resolved.deletingLastPathComponent().lastPathComponent == "dist" else { return nil }
693
+ return resolved.deletingLastPathComponent().deletingLastPathComponent()
694
+ }
695
+
696
+ private static func piAgentDirURL() -> URL {
697
+ if let override = ProcessInfo.processInfo.environment["PI_CODING_AGENT_DIR"],
698
+ !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
699
+ return URL(fileURLWithPath: override, isDirectory: true)
700
+ }
701
+
702
+ return URL(fileURLWithPath: NSHomeDirectory())
703
+ .appendingPathComponent(".pi", isDirectory: true)
704
+ .appendingPathComponent("agent", isDirectory: true)
705
+ }
706
+
707
+ private static func looksLikeAuthError(_ message: String) -> Bool {
708
+ let lowercased = message.lowercased()
709
+ return lowercased.contains("api key")
710
+ || lowercased.contains("oauth")
711
+ || lowercased.contains("token")
712
+ || lowercased.contains("authentication")
713
+ || lowercased.contains("unauthorized")
714
+ || lowercased.contains("bad request")
715
+ }
716
+
717
+ private static func clampDockHeight(_ height: CGFloat) -> CGFloat {
718
+ min(max(height, 170), 520)
719
+ }
720
+
721
+ private static func sanitizeEnvironment(_ env: inout [String: String], for providerID: String, hasStoredCredential: Bool) {
722
+ let providerEnvVars: [String: [String]] = [
723
+ "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
724
+ "anthropic": ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
725
+ "openai": ["OPENAI_API_KEY"],
726
+ "google": ["GEMINI_API_KEY"],
727
+ "groq": ["GROQ_API_KEY"],
728
+ "xai": ["XAI_API_KEY"],
729
+ "openrouter": ["OPENROUTER_API_KEY"],
730
+ "mistral": ["MISTRAL_API_KEY"],
731
+ "minimax": ["MINIMAX_API_KEY"],
732
+ "openai-codex": [],
733
+ ]
734
+
735
+ for (id, keys) in providerEnvVars where id != providerID {
736
+ for key in keys {
737
+ env.removeValue(forKey: key)
738
+ }
739
+ }
740
+
741
+ if providerID == "github-copilot", !hasStoredCredential {
742
+ env.removeValue(forKey: "COPILOT_GITHUB_TOKEN")
743
+ env.removeValue(forKey: "GH_TOKEN")
744
+ env.removeValue(forKey: "GITHUB_TOKEN")
745
+ }
746
+ }
747
+
748
+ private static let oauthDriverScript = #"""
749
+ import readline from 'node:readline';
750
+
751
+ const providerId = process.argv[1];
752
+ const oauthModuleUrl = process.argv[2];
753
+ const { getOAuthProvider } = await import(oauthModuleUrl);
754
+
755
+ const provider = getOAuthProvider(providerId);
756
+ if (!provider) {
757
+ process.stdout.write(JSON.stringify({ type: 'error', message: `Unknown OAuth provider: ${providerId}` }) + '\n');
758
+ process.exit(1);
759
+ }
760
+
761
+ const rl = readline.createInterface({
762
+ input: process.stdin,
763
+ output: process.stderr,
764
+ terminal: false,
765
+ });
766
+
767
+ function emit(event) {
768
+ process.stdout.write(JSON.stringify(event) + '\n');
769
+ }
770
+
771
+ function readLine() {
772
+ return new Promise((resolve) => {
773
+ rl.once('line', (line) => resolve(line));
774
+ });
775
+ }
776
+
777
+ try {
778
+ const credentials = await provider.login({
779
+ onAuth: (info) => emit({
780
+ type: 'auth',
781
+ url: info.url,
782
+ instructions: info.instructions ?? null,
783
+ }),
784
+ onPrompt: async (prompt) => {
785
+ emit({
786
+ type: 'prompt',
787
+ message: prompt.message,
788
+ placeholder: prompt.placeholder ?? null,
789
+ allowEmpty: Boolean(prompt.allowEmpty),
790
+ });
791
+ const input = await readLine();
792
+ return typeof input === 'string' ? input : '';
793
+ },
794
+ onProgress: (message) => emit({
795
+ type: 'progress',
796
+ message,
797
+ }),
798
+ });
799
+
800
+ emit({
801
+ type: 'success',
802
+ credentials,
803
+ });
804
+ rl.close();
805
+ process.exit(0);
806
+ } catch (error) {
807
+ emit({
808
+ type: 'error',
809
+ message: error instanceof Error ? error.message : String(error),
810
+ });
811
+ rl.close();
812
+ process.exit(1);
813
+ }
814
+ """#
815
+ }