@lattices/cli 0.4.2 → 0.4.6

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 (146) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/AppShell/App.swift +20 -0
  7. package/app/Sources/{AppDelegate.swift → AppShell/AppDelegate.swift} +94 -34
  8. package/app/Sources/{AppShellView.swift → AppShell/AppShellView.swift} +12 -1
  9. package/app/Sources/AppShell/AppUpdater.swift +92 -0
  10. package/app/Sources/AppShell/CliActionLauncher.swift +50 -0
  11. package/app/Sources/{HomeDashboardView.swift → AppShell/HomeDashboardView.swift} +18 -10
  12. package/app/Sources/AppShell/LatticesRuntime.swift +61 -0
  13. package/app/Sources/{MainView.swift → AppShell/MainView.swift} +351 -191
  14. package/app/Sources/{OnboardingView.swift → AppShell/OnboardingView.swift} +30 -16
  15. package/app/Sources/{Preferences.swift → AppShell/Preferences.swift} +78 -0
  16. package/app/Sources/{SettingsView.swift → AppShell/SettingsView.swift} +869 -152
  17. package/app/Sources/{HotkeyStore.swift → Core/Actions/HotkeyStore.swift} +9 -5
  18. package/app/Sources/{IntentEngine.swift → Core/Actions/IntentEngine.swift} +51 -27
  19. package/app/Sources/Core/Actions/IntentSchema.swift +94 -0
  20. package/app/Sources/{Intents → Core/Actions/Intents}/LatticeIntent.swift +0 -25
  21. package/app/Sources/{PaletteCommand.swift → Core/Actions/PaletteCommand.swift} +26 -6
  22. package/app/Sources/{VoiceIntentResolver.swift → Core/Actions/VoiceIntentResolver.swift} +46 -4
  23. package/app/Sources/Core/Companion/CompanionActivityLog.swift +70 -0
  24. package/app/Sources/Core/Companion/CompanionKeyboardController.swift +141 -0
  25. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +438 -0
  26. package/app/Sources/Core/Companion/LatticesCompanionCockpit.swift +555 -0
  27. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +594 -0
  28. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +204 -0
  29. package/app/Sources/Core/Companion/LatticesDeckHost.swift +1463 -0
  30. package/app/Sources/{LatticesApi.swift → Core/Daemon/LatticesApi.swift} +125 -4
  31. package/app/Sources/{AppTypeClassifier.swift → Core/Desktop/AppTypeClassifier.swift} +36 -0
  32. package/app/Sources/{DesktopModel.swift → Core/Desktop/DesktopModel.swift} +6 -8
  33. package/app/Sources/Core/Desktop/MouseFinder.swift +527 -0
  34. package/app/Sources/Core/Desktop/SessionWindowLocator.swift +139 -0
  35. package/app/Sources/Core/Desktop/WindowDragSnapController.swift +628 -0
  36. package/app/Sources/Core/Desktop/WindowPreviewCard.swift +100 -0
  37. package/app/Sources/Core/Desktop/WindowPreviewStore.swift +113 -0
  38. package/app/Sources/Core/Desktop/WindowSelectionStore.swift +76 -0
  39. package/app/Sources/{WindowTiler.swift → Core/Desktop/WindowTiler.swift} +351 -172
  40. package/app/Sources/Core/Input/MouseGestureConfig.swift +364 -0
  41. package/app/Sources/Core/Input/MouseGestureController.swift +1203 -0
  42. package/app/Sources/Core/Input/MouseInputDeviceStore.swift +98 -0
  43. package/app/Sources/Core/Input/MouseInputEventViewer.swift +272 -0
  44. package/app/Sources/Core/Input/MouseShortcutStore.swift +107 -0
  45. package/app/Sources/{CommandModeState.swift → Core/Overlays/CommandMode/CommandModeState.swift} +127 -24
  46. package/app/Sources/{CommandModeView.swift → Core/Overlays/CommandMode/CommandModeView.swift} +492 -79
  47. package/app/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +67 -0
  48. package/app/Sources/{CheatSheetHUD.swift → Core/Overlays/HUD/CheatSheetHUD.swift} +1 -0
  49. package/app/Sources/{HUDRightBar.swift → Core/Overlays/HUD/HUDRightBar.swift} +23 -201
  50. package/app/Sources/{LauncherHUD.swift → Core/Overlays/HUD/LauncherHUD.swift} +12 -26
  51. package/app/Sources/{OmniSearchView.swift → Core/Overlays/OmniSearch/OmniSearchView.swift} +136 -2
  52. package/app/Sources/{OmniSearchWindow.swift → Core/Overlays/OmniSearch/OmniSearchWindow.swift} +21 -32
  53. package/app/Sources/Core/Overlays/OverlayPanelShell.swift +241 -0
  54. package/app/Sources/{ScreenMapState.swift → Core/Overlays/ScreenMap/ScreenMapState.swift} +116 -32
  55. package/app/Sources/{ScreenMapView.swift → Core/Overlays/ScreenMap/ScreenMapView.swift} +510 -524
  56. package/app/Sources/{ScreenMapWindowController.swift → Core/Overlays/ScreenMap/ScreenMapWindowController.swift} +12 -4
  57. package/app/Sources/{VoiceCommandWindow.swift → Core/Overlays/Voice/VoiceCommandWindow.swift} +46 -53
  58. package/app/Sources/Core/Pi/PiAuthNextStepCard.swift +148 -0
  59. package/app/Sources/Core/Pi/PiAuthPromptCard.swift +90 -0
  60. package/app/Sources/{PiChatDock.swift → Core/Pi/PiChatDock.swift} +137 -74
  61. package/app/Sources/{PiChatSession.swift → Core/Pi/PiChatSession.swift} +608 -108
  62. package/app/Sources/Core/Pi/PiInstallCallout.swift +86 -0
  63. package/app/Sources/Core/Pi/PiProviderSetupCallout.swift +99 -0
  64. package/app/Sources/{PiWorkspaceView.swift → Core/Pi/PiWorkspaceView.swift} +174 -77
  65. package/app/Sources/{PermissionChecker.swift → Core/System/PermissionChecker.swift} +76 -2
  66. package/app/Sources/Core/System/SystemTelemetryMonitor.swift +273 -0
  67. package/app/Sources/{HandsOffSession.swift → Core/Voice/HandsOffSession.swift} +15 -4
  68. package/app/Sources/{WorkspaceManager.swift → Core/Workspace/WorkspaceManager.swift} +288 -0
  69. package/bin/assistant-intelligence.ts +874 -0
  70. package/bin/handsoff-infer.ts +16 -209
  71. package/bin/handsoff-worker.ts +45 -258
  72. package/bin/lattices-app.ts +62 -0
  73. package/bin/lattices-dev +4 -0
  74. package/bin/lattices.ts +125 -14
  75. package/docs/agents.md +14 -0
  76. package/docs/api.md +55 -0
  77. package/docs/app.md +3 -0
  78. package/docs/companion-deck.md +180 -0
  79. package/docs/component-extraction-roadmap.md +392 -0
  80. package/docs/config.md +25 -0
  81. package/docs/tiling-reference.md +55 -0
  82. package/docs/voice-error-model.md +73 -0
  83. package/package.json +4 -1
  84. package/app/Sources/App.swift +0 -10
  85. package/app/Sources/CommandPaletteWindow.swift +0 -134
  86. package/app/Sources/MouseFinder.swift +0 -222
  87. /package/app/Sources/{KeyRecorderView.swift → AppShell/KeyRecorderView.swift} +0 -0
  88. /package/app/Sources/{MainWindow.swift → AppShell/MainWindow.swift} +0 -0
  89. /package/app/Sources/{SettingsWindow.swift → AppShell/SettingsWindow.swift} +0 -0
  90. /package/app/Sources/{HotkeyManager.swift → Core/Actions/HotkeyManager.swift} +0 -0
  91. /package/app/Sources/{Intents → Core/Actions/Intents}/CreateLayerIntent.swift +0 -0
  92. /package/app/Sources/{Intents → Core/Actions/Intents}/DistributeIntent.swift +0 -0
  93. /package/app/Sources/{Intents → Core/Actions/Intents}/FocusIntent.swift +0 -0
  94. /package/app/Sources/{Intents → Core/Actions/Intents}/HelpIntent.swift +0 -0
  95. /package/app/Sources/{Intents → Core/Actions/Intents}/KillIntent.swift +0 -0
  96. /package/app/Sources/{Intents → Core/Actions/Intents}/LaunchIntent.swift +0 -0
  97. /package/app/Sources/{Intents → Core/Actions/Intents}/ListSessionsIntent.swift +0 -0
  98. /package/app/Sources/{Intents → Core/Actions/Intents}/ListWindowsIntent.swift +0 -0
  99. /package/app/Sources/{Intents → Core/Actions/Intents}/ScanIntent.swift +0 -0
  100. /package/app/Sources/{Intents → Core/Actions/Intents}/SearchIntent.swift +0 -0
  101. /package/app/Sources/{Intents → Core/Actions/Intents}/SwitchLayerIntent.swift +0 -0
  102. /package/app/Sources/{Intents → Core/Actions/Intents}/TileIntent.swift +0 -0
  103. /package/app/Sources/{DaemonProtocol.swift → Core/Daemon/DaemonProtocol.swift} +0 -0
  104. /package/app/Sources/{DaemonServer.swift → Core/Daemon/DaemonServer.swift} +0 -0
  105. /package/app/Sources/{AccessibilityTextExtractor.swift → Core/Desktop/AccessibilityTextExtractor.swift} +0 -0
  106. /package/app/Sources/{DesktopModelTypes.swift → Core/Desktop/DesktopModelTypes.swift} +0 -0
  107. /package/app/Sources/{InventoryManager.swift → Core/Desktop/InventoryManager.swift} +0 -0
  108. /package/app/Sources/{InventoryPath.swift → Core/Desktop/InventoryPath.swift} +0 -0
  109. /package/app/Sources/{OcrModel.swift → Core/Desktop/OcrModel.swift} +0 -0
  110. /package/app/Sources/{OcrStore.swift → Core/Desktop/OcrStore.swift} +0 -0
  111. /package/app/Sources/{PlacementSpec.swift → Core/Desktop/PlacementSpec.swift} +0 -0
  112. /package/app/Sources/{TilePickerView.swift → Core/Desktop/TilePickerView.swift} +0 -0
  113. /package/app/Sources/{AppWindowShell.swift → Core/Overlays/AppWindowShell.swift} +0 -0
  114. /package/app/Sources/{CommandModeWindow.swift → Core/Overlays/CommandMode/CommandModeWindow.swift} +0 -0
  115. /package/app/Sources/{CommandPaletteView.swift → Core/Overlays/CommandPalette/CommandPaletteView.swift} +0 -0
  116. /package/app/Sources/{HUDBottomBar.swift → Core/Overlays/HUD/HUDBottomBar.swift} +0 -0
  117. /package/app/Sources/{HUDController.swift → Core/Overlays/HUD/HUDController.swift} +0 -0
  118. /package/app/Sources/{HUDLeftBar.swift → Core/Overlays/HUD/HUDLeftBar.swift} +0 -0
  119. /package/app/Sources/{HUDMinimap.swift → Core/Overlays/HUD/HUDMinimap.swift} +0 -0
  120. /package/app/Sources/{HUDState.swift → Core/Overlays/HUD/HUDState.swift} +0 -0
  121. /package/app/Sources/{HUDTopBar.swift → Core/Overlays/HUD/HUDTopBar.swift} +0 -0
  122. /package/app/Sources/{LayerBezel.swift → Core/Overlays/HUD/LayerBezel.swift} +0 -0
  123. /package/app/Sources/{OmniSearchState.swift → Core/Overlays/OmniSearch/OmniSearchState.swift} +0 -0
  124. /package/app/Sources/{DiagnosticLog.swift → Core/System/DiagnosticLog.swift} +0 -0
  125. /package/app/Sources/{EventBus.swift → Core/System/EventBus.swift} +0 -0
  126. /package/app/Sources/{ProcessModel.swift → Core/System/ProcessModel.swift} +0 -0
  127. /package/app/Sources/{ProcessQuery.swift → Core/System/ProcessQuery.swift} +0 -0
  128. /package/app/Sources/{AdvisorLearningStore.swift → Core/Voice/AdvisorLearningStore.swift} +0 -0
  129. /package/app/Sources/{AgentSession.swift → Core/Voice/AgentSession.swift} +0 -0
  130. /package/app/Sources/{AudioProvider.swift → Core/Voice/AudioProvider.swift} +0 -0
  131. /package/app/Sources/{VoiceChatView.swift → Core/Voice/VoiceChatView.swift} +0 -0
  132. /package/app/Sources/{VoxClient.swift → Core/Voice/VoxClient.swift} +0 -0
  133. /package/app/Sources/{Project.swift → Core/Workspace/Project.swift} +0 -0
  134. /package/app/Sources/{ProjectScanner.swift → Core/Workspace/ProjectScanner.swift} +0 -0
  135. /package/app/Sources/{SessionLayerStore.swift → Core/Workspace/SessionLayerStore.swift} +0 -0
  136. /package/app/Sources/{SessionManager.swift → Core/Workspace/SessionManager.swift} +0 -0
  137. /package/app/Sources/{Terminal.swift → Core/Workspace/Terminal/Terminal.swift} +0 -0
  138. /package/app/Sources/{TerminalQuery.swift → Core/Workspace/Terminal/TerminalQuery.swift} +0 -0
  139. /package/app/Sources/{TerminalSynthesizer.swift → Core/Workspace/Terminal/TerminalSynthesizer.swift} +0 -0
  140. /package/app/Sources/{TmuxModel.swift → Core/Workspace/Tmux/TmuxModel.swift} +0 -0
  141. /package/app/Sources/{TmuxQuery.swift → Core/Workspace/Tmux/TmuxQuery.swift} +0 -0
  142. /package/app/Sources/{ActionRow.swift → UI/ActionRow.swift} +0 -0
  143. /package/app/Sources/{OrphanRow.swift → UI/OrphanRow.swift} +0 -0
  144. /package/app/Sources/{ProjectRow.swift → UI/ProjectRow.swift} +0 -0
  145. /package/app/Sources/{TabGroupRow.swift → UI/TabGroupRow.swift} +0 -0
  146. /package/app/Sources/{Theme.swift → UI/Theme.swift} +0 -0
@@ -123,6 +123,7 @@ struct PiProvider: Identifiable, Equatable {
123
123
 
124
124
  final class PiChatSession: ObservableObject {
125
125
  static let shared = PiChatSession()
126
+ private static let installCommand = "npm install -g @mariozechner/pi-coding-agent@latest"
126
127
 
127
128
  @Published private(set) var messages: [PiChatMessage] = [
128
129
  PiChatMessage(
@@ -142,32 +143,50 @@ final class PiChatSession: ObservableObject {
142
143
  }
143
144
  }
144
145
  @Published var isAuthPanelVisible: Bool = false
145
- @Published var authProviderID: String = "minimax" {
146
+ @Published var authProviderID: String = "openai-codex" {
146
147
  didSet {
147
148
  guard oldValue != authProviderID else { return }
149
+ if isAuthenticating {
150
+ cancelAuthFlow(silently: true)
151
+ }
148
152
  UserDefaults.standard.set(authProviderID, forKey: Self.selectedProviderDefaultsKey)
149
153
  authToken = ""
150
154
  authPromptInput = ""
151
155
  pendingAuthPrompt = nil
152
156
  authNoticeText = nil
153
157
  authErrorText = nil
158
+ latestAuthURL = nil
159
+ latestAuthInstructions = nil
160
+ authVerificationCodeCopied = false
161
+ lastCopiedAuthVerificationCode = nil
162
+ prepareForDisplay()
154
163
  }
155
164
  }
156
165
  @Published var authToken: String = ""
157
166
  @Published var authPromptInput: String = ""
158
167
  @Published private(set) var isAuthenticating: Bool = false
168
+ @Published private(set) var authenticatingProviderID: String?
159
169
  @Published private(set) var pendingAuthPrompt: PiAuthPrompt?
160
170
  @Published private(set) var authNoticeText: String?
161
171
  @Published private(set) var authErrorText: String?
162
172
  @Published private(set) var storedCredentialKinds: [String: String] = [:]
173
+ @Published private(set) var piBinaryPath: String?
174
+ @Published private(set) var latestAuthURL: URL?
175
+ @Published private(set) var latestAuthInstructions: String?
176
+ @Published private(set) var authVerificationCodeCopied: Bool = false
163
177
 
164
178
  private let queue = DispatchQueue(label: "pi-chat-session", qos: .userInitiated)
165
179
  private let sessionFileURL: URL
166
180
  private let authFileURL: URL
167
181
  private var authProcess: Process?
182
+ private var authProcessIdentifier: Int32?
168
183
  private var authInputHandle: FileHandle?
184
+ private var authStdoutPipe: Pipe?
185
+ private var authStderrPipe: Pipe?
169
186
  private var authStdoutBuffer: String = ""
170
187
  private var authStderrBuffer: String = ""
188
+ private var nodeBinaryPath: String?
189
+ private var lastCopiedAuthVerificationCode: String?
171
190
 
172
191
  private static let selectedProviderDefaultsKey = "PiChatSelectedProvider"
173
192
  private static let dockHeightDefaultsKey = "PiChatDockHeight"
@@ -191,10 +210,16 @@ final class PiChatSession: ObservableObject {
191
210
  }
192
211
 
193
212
  reloadAuthState()
213
+ refreshBinaryAvailability()
214
+ cleanupLingeringAuthHelpers()
194
215
  }
195
216
 
196
217
  var hasPiBinary: Bool {
197
- resolvePiPath() != nil
218
+ piBinaryPath != nil
219
+ }
220
+
221
+ var piInstallCommand: String {
222
+ Self.installCommand
198
223
  }
199
224
 
200
225
  var providerOptions: [PiProvider] {
@@ -205,6 +230,19 @@ final class PiChatSession: ObservableObject {
205
230
  PiProvider.provider(id: authProviderID)
206
231
  }
207
232
 
233
+ var authenticatingProvider: PiProvider? {
234
+ guard let authenticatingProviderID else { return nil }
235
+ return PiProvider.provider(id: authenticatingProviderID)
236
+ }
237
+
238
+ var needsProviderSetup: Bool {
239
+ hasPiBinary && !hasSelectedCredential
240
+ }
241
+
242
+ var hasConversationHistory: Bool {
243
+ messages.contains { $0.role != .system }
244
+ }
245
+
208
246
  var selectedCredentialSummary: String {
209
247
  guard let kind = storedCredentialKinds[authProviderID] else { return "not authenticated" }
210
248
  return kind == "oauth" ? "oauth saved" : "token saved"
@@ -214,6 +252,78 @@ final class PiChatSession: ObservableObject {
214
252
  storedCredentialKinds[authProviderID] != nil
215
253
  }
216
254
 
255
+ var authVerificationCode: String? {
256
+ guard let latestAuthInstructions else { return nil }
257
+ let prefix = "Enter code:"
258
+ guard let range = latestAuthInstructions.range(of: prefix, options: [.caseInsensitive]) else { return nil }
259
+ let value = latestAuthInstructions[range.upperBound...]
260
+ .trimmingCharacters(in: .whitespacesAndNewlines)
261
+ return value.isEmpty ? nil : value
262
+ }
263
+
264
+ var authStepLabel: String {
265
+ if pendingAuthPrompt != nil || latestAuthURL == nil {
266
+ return "STEP 1"
267
+ }
268
+ return "STEP 2"
269
+ }
270
+
271
+ var authStepTitle: String {
272
+ if pendingAuthPrompt != nil {
273
+ return "Answer one quick question"
274
+ }
275
+ if latestAuthURL == nil {
276
+ return "Opening your sign-in page"
277
+ }
278
+ if authVerificationCode != nil {
279
+ return authVerificationCodeCopied
280
+ ? "Paste the copied code in your browser"
281
+ : "Copy the code, then paste it in your browser"
282
+ }
283
+ return "Finish sign-in in your browser"
284
+ }
285
+
286
+ var authStepDescription: String {
287
+ if let prompt = pendingAuthPrompt {
288
+ return prompt.message
289
+ }
290
+ if latestAuthURL == nil {
291
+ return "Stay here for a second while Pi prepares the browser step."
292
+ }
293
+ if authVerificationCode != nil {
294
+ return authVerificationCodeCopied
295
+ ? "The code is already on your clipboard. Switch to the browser page and paste it."
296
+ : "Use the code below on the browser page, or copy it here first."
297
+ }
298
+ return "Your browser sign-in page is ready. Finish the provider flow there."
299
+ }
300
+
301
+ var authStepShortText: String {
302
+ if pendingAuthPrompt != nil {
303
+ return "Answer one quick question"
304
+ }
305
+ if latestAuthURL == nil {
306
+ return "Opening browser sign-in"
307
+ }
308
+ if authVerificationCode != nil {
309
+ return authVerificationCodeCopied ? "Paste the copied code" : "Copy the code and paste it"
310
+ }
311
+ return "Finish sign-in in browser"
312
+ }
313
+
314
+ var setupStatusSummary: String {
315
+ if !hasPiBinary {
316
+ return "Install Pi to enable the assistant"
317
+ }
318
+ if isAuthenticating {
319
+ return authStepShortText
320
+ }
321
+ if needsProviderSetup {
322
+ return "Next: connect \(currentProvider.name)"
323
+ }
324
+ return currentProvider.name
325
+ }
326
+
217
327
  var canSubmitAuthPrompt: Bool {
218
328
  guard let prompt = pendingAuthPrompt else { return false }
219
329
  let value = authPromptInput.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -225,6 +335,11 @@ final class PiChatSession: ObservableObject {
225
335
  }
226
336
 
227
337
  func toggleAuthPanel() {
338
+ if needsProviderSetup || isAuthenticating {
339
+ isAuthPanelVisible = true
340
+ dockHeight = max(dockHeight, 300)
341
+ return
342
+ }
228
343
  isAuthPanelVisible.toggle()
229
344
  if isAuthPanelVisible {
230
345
  dockHeight = max(dockHeight, 300)
@@ -233,14 +348,53 @@ final class PiChatSession: ObservableObject {
233
348
 
234
349
  func clearConversation() {
235
350
  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"
351
+ messages = []
352
+ prepareForDisplay()
353
+ }
354
+
355
+ func prepareForDisplay() {
356
+ reconcileAuthState()
357
+ refreshBinaryAvailability()
358
+
359
+ if isAuthenticating {
360
+ isAuthPanelVisible = true
361
+ statusText = "connecting..."
362
+ } else if needsProviderSetup {
363
+ isAuthPanelVisible = true
364
+ statusText = "setup ai"
365
+ } else if hasPiBinary && (statusText == "setup ai" || statusText == "missing pi") {
366
+ statusText = "idle"
367
+ }
368
+
369
+ syncStructuredWelcomeMessage()
370
+ }
371
+
372
+ func refreshBinaryAvailability() {
373
+ piBinaryPath = resolvePiPath()
374
+ nodeBinaryPath = resolveNodePath()
375
+
376
+ if piBinaryPath == nil {
377
+ if statusText == "idle" || statusText == "missing pi" {
378
+ statusText = "missing pi"
379
+ }
380
+ } else if !hasSelectedCredential {
381
+ if statusText == "idle" || statusText == "setup ai" {
382
+ statusText = "setup ai"
383
+ }
384
+ } else if statusText == "missing pi" {
385
+ statusText = "idle"
386
+ }
387
+ }
388
+
389
+ func copyPiInstallCommand() {
390
+ NSPasteboard.general.clearContents()
391
+ NSPasteboard.general.setString(piInstallCommand, forType: .string)
392
+ appendSystemMessage("Copied the Pi install command to the clipboard.")
393
+ }
394
+
395
+ func installPiInTerminal() {
396
+ Preferences.shared.terminal.launch(command: piInstallCommand, in: NSHomeDirectory())
397
+ appendSystemMessage("Opened \(Preferences.shared.terminal.rawValue) and started the Pi install.")
244
398
  }
245
399
 
246
400
  func sendDraft() {
@@ -255,14 +409,21 @@ final class PiChatSession: ObservableObject {
255
409
  guard !trimmed.isEmpty else { return }
256
410
  guard !isSending else { return }
257
411
 
258
- messages.append(PiChatMessage(role: .user, text: trimmed, timestamp: Date()))
412
+ refreshBinaryAvailability()
259
413
 
260
- guard let piPath = resolvePiPath() else {
261
- appendSystemMessage("Pi CLI not found. Install `pi` or add it to PATH.")
414
+ guard let piPath = piBinaryPath else {
415
+ prepareForDisplay()
262
416
  statusText = "missing pi"
263
417
  return
264
418
  }
265
419
 
420
+ guard !needsProviderSetup else {
421
+ prepareForDisplay()
422
+ return
423
+ }
424
+
425
+ messages.append(PiChatMessage(role: .user, text: trimmed, timestamp: Date()))
426
+
266
427
  let provider = currentProvider
267
428
  isSending = true
268
429
  statusText = "thinking..."
@@ -327,6 +488,13 @@ final class PiChatSession: ObservableObject {
327
488
  }
328
489
 
329
490
  let message = !stderr.isEmpty ? stderr : (stdout.isEmpty ? "Pi returned no output." : stdout)
491
+ if let friendly = self.friendlyAuthFailureMessage(for: message) {
492
+ self.statusText = "setup ai"
493
+ self.authErrorText = friendly
494
+ self.isAuthPanelVisible = true
495
+ self.syncStructuredWelcomeMessage()
496
+ return
497
+ }
330
498
  self.statusText = "error"
331
499
  self.appendSystemMessage(message)
332
500
  if Self.looksLikeAuthError(message) {
@@ -355,6 +523,8 @@ final class PiChatSession: ObservableObject {
355
523
  authErrorText = nil
356
524
  reloadAuthState()
357
525
  appendSystemMessage("Saved \(currentProvider.name) credentials to Pi auth storage.")
526
+ isAuthPanelVisible = false
527
+ prepareForDisplay()
358
528
  } catch {
359
529
  authErrorText = "Failed to save token: \(error.localizedDescription)"
360
530
  }
@@ -369,6 +539,7 @@ final class PiChatSession: ObservableObject {
369
539
  authErrorText = nil
370
540
  reloadAuthState()
371
541
  appendSystemMessage("Removed saved \(currentProvider.name) credentials from Pi auth storage.")
542
+ prepareForDisplay()
372
543
  } catch {
373
544
  authErrorText = "Failed to remove credentials: \(error.localizedDescription)"
374
545
  }
@@ -387,7 +558,10 @@ final class PiChatSession: ObservableObject {
387
558
  guard let prompt = pendingAuthPrompt else { return }
388
559
  let value = authPromptInput.trimmingCharacters(in: .whitespacesAndNewlines)
389
560
  guard prompt.allowEmpty || !value.isEmpty else { return }
561
+ submitAuthPromptValue(value)
562
+ }
390
563
 
564
+ private func submitAuthPromptValue(_ value: String) {
391
565
  guard let handle = authInputHandle else {
392
566
  authErrorText = "Pi auth input pipe is no longer available."
393
567
  return
@@ -405,20 +579,71 @@ final class PiChatSession: ObservableObject {
405
579
  }
406
580
  }
407
581
 
408
- func cancelAuthFlow() {
409
- authProcess?.terminate()
582
+ func reopenLatestAuthURL() {
583
+ guard let latestAuthURL else {
584
+ authNoticeText = "Still preparing the browser sign-in link..."
585
+ return
586
+ }
587
+
588
+ autoCopyAuthVerificationCodeIfNeeded()
589
+ NSWorkspace.shared.open(latestAuthURL)
590
+ authNoticeText = authVerificationCode != nil
591
+ ? "Reopened the sign-in page. Paste the copied code there."
592
+ : "Reopened \(authenticatingProvider?.name ?? currentProvider.name) sign-in in your browser."
593
+ }
594
+
595
+ func copyAuthVerificationCode() {
596
+ copyAuthVerificationCode(silently: false)
597
+ }
598
+
599
+ private func copyAuthVerificationCode(silently: Bool) {
600
+ guard let authVerificationCode else {
601
+ authNoticeText = "No sign-in code is ready yet."
602
+ return
603
+ }
604
+
605
+ NSPasteboard.general.clearContents()
606
+ NSPasteboard.general.setString(authVerificationCode, forType: .string)
607
+ authVerificationCodeCopied = true
608
+ lastCopiedAuthVerificationCode = authVerificationCode
609
+ if !silently {
610
+ authNoticeText = "Copied the sign-in code. Paste it into the browser page."
611
+ }
612
+ }
613
+
614
+ private func autoCopyAuthVerificationCodeIfNeeded() {
615
+ guard let authVerificationCode else { return }
616
+ guard !authVerificationCodeCopied || lastCopiedAuthVerificationCode != authVerificationCode else { return }
617
+ copyAuthVerificationCode(silently: true)
618
+ }
619
+
620
+ func cancelAuthFlow(silently: Bool = false) {
621
+ let process = authProcess
410
622
  cleanupAuthProcess()
623
+ terminateProcess(process, escalateAfter: 0.8)
411
624
  isAuthenticating = false
412
- authNoticeText = "Cancelled auth flow."
625
+ statusText = hasPiBinary && !hasSelectedCredential ? "setup ai" : "idle"
626
+ if !silently {
627
+ authNoticeText = "Cancelled auth flow."
628
+ }
413
629
  }
414
630
 
415
631
  private func startOAuthLogin(for provider: PiProvider) {
416
- guard !isAuthenticating else {
417
- authErrorText = "An auth flow is already running."
632
+ reconcileAuthState()
633
+ cleanupLingeringAuthHelpers()
634
+
635
+ if isAuthenticating {
636
+ cancelAuthFlow(silently: true)
637
+ }
638
+
639
+ refreshBinaryAvailability()
640
+
641
+ guard hasPiBinary else {
642
+ authErrorText = "Install Pi before starting auth."
418
643
  return
419
644
  }
420
645
 
421
- guard let nodePath = resolveNodePath() else {
646
+ guard let nodePath = nodeBinaryPath else {
422
647
  authErrorText = "Node.js is required for Pi OAuth login."
423
648
  return
424
649
  }
@@ -449,9 +674,15 @@ final class PiChatSession: ObservableObject {
449
674
  authStderrBuffer = ""
450
675
  authPromptInput = ""
451
676
  pendingAuthPrompt = nil
452
- authNoticeText = "Starting \(provider.name) login..."
677
+ latestAuthURL = nil
678
+ latestAuthInstructions = nil
679
+ authVerificationCodeCopied = false
680
+ lastCopiedAuthVerificationCode = nil
681
+ authNoticeText = "Preparing \(provider.name) sign-in..."
453
682
  authErrorText = nil
454
683
  isAuthenticating = true
684
+ authenticatingProviderID = provider.id
685
+ statusText = "connecting..."
455
686
 
456
687
  stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
457
688
  let data = handle.availableData
@@ -467,18 +698,23 @@ final class PiChatSession: ObservableObject {
467
698
 
468
699
  proc.terminationHandler = { [weak self] process in
469
700
  DispatchQueue.main.async {
470
- self?.handleAuthProcessExit(status: process.terminationStatus)
701
+ self?.handleAuthProcessExit(processID: process.processIdentifier, status: process.terminationStatus)
471
702
  }
472
703
  }
473
704
 
474
705
  do {
475
706
  try proc.run()
476
707
  authProcess = proc
708
+ authProcessIdentifier = proc.processIdentifier
477
709
  authInputHandle = stdinPipe.fileHandleForWriting
710
+ authStdoutPipe = stdoutPipe
711
+ authStderrPipe = stderrPipe
712
+ recordAuthHelperProcess(proc.processIdentifier)
478
713
  appendSystemMessage("Started \(provider.name) auth flow.")
479
714
  } catch {
480
715
  cleanupAuthProcess()
481
716
  isAuthenticating = false
717
+ statusText = hasPiBinary && !hasSelectedCredential ? "setup ai" : "idle"
482
718
  authErrorText = "Failed to launch auth flow: \(error.localizedDescription)"
483
719
  }
484
720
  }
@@ -519,22 +755,39 @@ final class PiChatSession: ObservableObject {
519
755
 
520
756
  switch type {
521
757
  case "prompt":
522
- pendingAuthPrompt = PiAuthPrompt(
758
+ let prompt = PiAuthPrompt(
523
759
  message: json["message"] as? String ?? "Continue",
524
760
  placeholder: json["placeholder"] as? String,
525
761
  allowEmpty: json["allowEmpty"] as? Bool ?? false
526
762
  )
527
- authNoticeText = pendingAuthPrompt?.message
763
+ pendingAuthPrompt = prompt
764
+ authNoticeText = prompt.message
765
+ if shouldAutoSubmitPrompt(prompt) {
766
+ authNoticeText = "Using github.com. If you need GitHub Enterprise, cancel and enter your domain instead."
767
+ submitAuthPromptValue("")
768
+ }
528
769
 
529
770
  case "auth":
530
771
  let urlString = json["url"] as? String ?? ""
531
772
  let instructions = json["instructions"] as? String
532
- authNoticeText = instructions ?? "Continue in your browser."
533
- if let url = URL(string: urlString) {
773
+ latestAuthURL = URL(string: urlString)
774
+ latestAuthInstructions = instructions
775
+ if authVerificationCode != lastCopiedAuthVerificationCode {
776
+ authVerificationCodeCopied = false
777
+ }
778
+ autoCopyAuthVerificationCodeIfNeeded()
779
+ authNoticeText = authVerificationCode != nil
780
+ ? "The sign-in code is copied. Paste it into the browser page."
781
+ : "Your browser sign-in page is ready."
782
+ if let url = latestAuthURL {
534
783
  NSWorkspace.shared.open(url)
535
784
  }
536
- if let instructions, !instructions.isEmpty {
537
- appendSystemMessage("Pi auth: \(instructions)")
785
+ if authVerificationCode != nil {
786
+ appendSystemMessage("Pi auth is ready. The sign-in code is copied, and you can reopen the browser page here if needed.")
787
+ } else if let instructions, !instructions.isEmpty {
788
+ appendSystemMessage("Pi auth: \(instructions) If nothing opened, use OPEN AGAIN.")
789
+ } else {
790
+ appendSystemMessage("Pi auth is ready in your browser. If nothing opened, use OPEN AGAIN.")
538
791
  }
539
792
 
540
793
  case "progress":
@@ -545,15 +798,19 @@ final class PiChatSession: ObservableObject {
545
798
  authErrorText = "Pi auth completed but returned no credentials."
546
799
  return
547
800
  }
801
+ let providerID = authenticatingProviderID ?? authProviderID
802
+ let provider = PiProvider.provider(id: providerID)
548
803
  credentials["type"] = "oauth"
549
804
  do {
550
805
  try mutateAuthFile { auth in
551
- auth[authProviderID] = credentials
806
+ auth[providerID] = credentials
552
807
  }
553
808
  reloadAuthState()
554
- authNoticeText = "Saved OAuth credentials for \(currentProvider.name)."
809
+ authNoticeText = "Saved OAuth credentials for \(provider.name)."
555
810
  authErrorText = nil
556
- appendSystemMessage("Saved \(currentProvider.name) OAuth credentials to Pi auth storage.")
811
+ appendSystemMessage("Saved \(provider.name) OAuth credentials to Pi auth storage.")
812
+ isAuthPanelVisible = false
813
+ prepareForDisplay()
557
814
  } catch {
558
815
  authErrorText = "Failed to save OAuth credentials: \(error.localizedDescription)"
559
816
  }
@@ -568,7 +825,9 @@ final class PiChatSession: ObservableObject {
568
825
  }
569
826
  }
570
827
 
571
- private func handleAuthProcessExit(status: Int32) {
828
+ private func handleAuthProcessExit(processID: Int32, status: Int32) {
829
+ guard authProcessIdentifier == processID else { return }
830
+
572
831
  let hadExplicitError = authErrorText != nil
573
832
  cleanupAuthProcess()
574
833
  isAuthenticating = false
@@ -581,24 +840,119 @@ final class PiChatSession: ObservableObject {
581
840
  } else if !hadExplicitError {
582
841
  authErrorText = "Auth flow exited with status \(status)."
583
842
  }
843
+
844
+ if status == 0, hasSelectedCredential {
845
+ statusText = "idle"
846
+ } else if hasPiBinary && !hasSelectedCredential {
847
+ statusText = "setup ai"
848
+ }
584
849
  }
585
850
 
586
851
  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
- }
852
+ authProcess?.terminationHandler = nil
853
+ authStdoutPipe?.fileHandleForReading.readabilityHandler = nil
854
+ authStderrPipe?.fileHandleForReading.readabilityHandler = nil
855
+ try? authInputHandle?.close()
594
856
  authInputHandle = nil
857
+ authStdoutPipe = nil
858
+ authStderrPipe = nil
859
+ authStdoutBuffer = ""
860
+ authStderrBuffer = ""
861
+ latestAuthURL = nil
862
+ latestAuthInstructions = nil
863
+ authVerificationCodeCopied = false
864
+ lastCopiedAuthVerificationCode = nil
595
865
  authProcess = nil
866
+ authProcessIdentifier = nil
867
+ authenticatingProviderID = nil
868
+ clearRecordedAuthHelperProcess()
596
869
  }
597
870
 
598
871
  private func appendSystemMessage(_ text: String) {
599
872
  messages.append(PiChatMessage(role: .system, text: text, timestamp: Date()))
600
873
  }
601
874
 
875
+ private func syncStructuredWelcomeMessage() {
876
+ guard !hasConversationHistory else { return }
877
+ messages = [
878
+ PiChatMessage(
879
+ role: .system,
880
+ text: structuredWelcomeMessage(),
881
+ timestamp: Date()
882
+ )
883
+ ]
884
+ }
885
+
886
+ private func structuredWelcomeMessage() -> String {
887
+ if !hasPiBinary {
888
+ return """
889
+ Welcome to Pi Workspace.
890
+
891
+ Pi powers the in-app assistant. Install it first, then come back here and refresh.
892
+
893
+ Install command:
894
+ \(piInstallCommand)
895
+ """
896
+ }
897
+
898
+ if isAuthenticating {
899
+ return """
900
+ Welcome to Pi Workspace.
901
+
902
+ \(authStepTitle)
903
+
904
+ \(authStepDescription)
905
+ """
906
+ }
907
+
908
+ if needsProviderSetup {
909
+ return """
910
+ Welcome to Pi Workspace.
911
+
912
+ Next step: connect \(currentProvider.name).
913
+
914
+ The setup panel above is open. Once you finish that one step, the chat box unlocks automatically.
915
+ """
916
+ }
917
+
918
+ return """
919
+ Welcome to Pi Workspace.
920
+
921
+ You're connected with \(currentProvider.name). Ask for code help, planning, debugging, or a second opinion.
922
+ """
923
+ }
924
+
925
+ private func friendlyAuthFailureMessage(for message: String) -> String? {
926
+ let lowercased = message.lowercased()
927
+ let authHints = [
928
+ "use /login",
929
+ "set an api key environment variable",
930
+ "authentication",
931
+ "unauthorized",
932
+ "api key",
933
+ "oauth",
934
+ "token",
935
+ ]
936
+
937
+ guard authHints.contains(where: lowercased.contains) else { return nil }
938
+
939
+ if currentProvider.authMode == .oauth {
940
+ return "This provider is not connected yet. Use the setup panel to sign in with \(currentProvider.name), then come back and send your first prompt."
941
+ }
942
+
943
+ return "This provider still needs an API key. Paste your \(currentProvider.tokenLabel.lowercased()) into the setup panel above, save it, and then try again."
944
+ }
945
+
946
+ private func shouldAutoSubmitPrompt(_ prompt: PiAuthPrompt) -> Bool {
947
+ guard authenticatingProviderID == "github-copilot" else { return false }
948
+ guard prompt.allowEmpty else { return false }
949
+
950
+ let message = prompt.message.lowercased()
951
+ return message.contains("github enterprise url")
952
+ || message.contains("github enterprise")
953
+ || message.contains("blank for github.com")
954
+ }
955
+
602
956
  private func reloadAuthState() {
603
957
  let auth = loadAuthFile()
604
958
  var kinds: [String: String] = [:]
@@ -612,6 +966,21 @@ final class PiChatSession: ObservableObject {
612
966
  storedCredentialKinds = kinds
613
967
  }
614
968
 
969
+ private func reconcileAuthState() {
970
+ guard isAuthenticating else { return }
971
+
972
+ if let authProcess, authProcess.isRunning {
973
+ return
974
+ }
975
+
976
+ cleanupAuthProcess()
977
+ isAuthenticating = false
978
+ pendingAuthPrompt = nil
979
+ if hasPiBinary && !hasSelectedCredential {
980
+ statusText = "setup ai"
981
+ }
982
+ }
983
+
615
984
  private func loadAuthFile() -> [String: Any] {
616
985
  guard let data = try? Data(contentsOf: authFileURL), !data.isEmpty else { return [:] }
617
986
  guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
@@ -639,6 +1008,7 @@ final class PiChatSession: ObservableObject {
639
1008
  "/opt/homebrew/bin/pi",
640
1009
  "/usr/local/bin/pi",
641
1010
  NSHomeDirectory() + "/.local/bin/pi",
1011
+ NSHomeDirectory() + "/.bun/bin/pi",
642
1012
  ]
643
1013
  )
644
1014
  }
@@ -650,26 +1020,78 @@ final class PiChatSession: ObservableObject {
650
1020
  "/opt/homebrew/bin/node",
651
1021
  "/usr/local/bin/node",
652
1022
  "/usr/bin/node",
1023
+ NSHomeDirectory() + "/.local/bin/node",
653
1024
  ]
654
1025
  )
655
1026
  }
656
1027
 
657
1028
  private func resolveCommandPath(named command: String, candidates: [String]) -> String? {
658
- for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
1029
+ var orderedCandidates: [String] = []
1030
+ var seen: Set<String> = []
1031
+
1032
+ for rawPath in candidates + managedInstallCandidates(for: command) {
1033
+ let path = (rawPath as NSString).expandingTildeInPath
1034
+ guard !path.isEmpty else { continue }
1035
+ guard seen.insert(path).inserted else { continue }
1036
+ orderedCandidates.append(path)
1037
+ }
1038
+
1039
+ for path in orderedCandidates where FileManager.default.isExecutableFile(atPath: path) {
659
1040
  return path
660
1041
  }
661
1042
 
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
1043
+ let lookups = [
1044
+ ProcessQuery.shell(["/usr/bin/which", command]),
1045
+ ProcessQuery.shell(["/bin/sh", "-lc", "command -v \(command) 2>/dev/null"]),
1046
+ ProcessQuery.shell(["/bin/zsh", "-lc", "command -v \(command) 2>/dev/null"]),
1047
+ ]
1048
+
1049
+ for output in lookups {
1050
+ let path = output.trimmingCharacters(in: .whitespacesAndNewlines)
1051
+ if !path.isEmpty, FileManager.default.isExecutableFile(atPath: path) {
1052
+ return path
1053
+ }
1054
+ }
1055
+
1056
+ return nil
1057
+ }
1058
+
1059
+ private func managedInstallCandidates(for command: String) -> [String] {
1060
+ let home = NSHomeDirectory()
1061
+ var candidates = [
1062
+ "\(home)/.bun/bin/\(command)",
1063
+ "\(home)/.npm-global/bin/\(command)",
1064
+ "\(home)/Library/pnpm/\(command)",
1065
+ "\(home)/.local/share/mise/shims/\(command)",
1066
+ ]
1067
+
1068
+ let fnmRoot = URL(fileURLWithPath: home, isDirectory: true)
1069
+ .appendingPathComponent(".local", isDirectory: true)
1070
+ .appendingPathComponent("share", isDirectory: true)
1071
+ .appendingPathComponent("fnm", isDirectory: true)
1072
+ .appendingPathComponent("node-versions", isDirectory: true)
1073
+
1074
+ if let installs = try? FileManager.default.contentsOfDirectory(
1075
+ at: fnmRoot,
1076
+ includingPropertiesForKeys: nil,
1077
+ options: [.skipsHiddenFiles]
1078
+ ) {
1079
+ let sortedInstalls = installs.sorted {
1080
+ $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedDescending
1081
+ }
1082
+
1083
+ for install in sortedInstalls {
1084
+ candidates.append(
1085
+ install
1086
+ .appendingPathComponent("installation", isDirectory: true)
1087
+ .appendingPathComponent("bin", isDirectory: true)
1088
+ .appendingPathComponent(command)
1089
+ .path
1090
+ )
1091
+ }
1092
+ }
1093
+
1094
+ return candidates
673
1095
  }
674
1096
 
675
1097
  private func resolveOAuthModuleURL() -> URL? {
@@ -693,6 +1115,74 @@ final class PiChatSession: ObservableObject {
693
1115
  return resolved.deletingLastPathComponent().deletingLastPathComponent()
694
1116
  }
695
1117
 
1118
+ private func recordAuthHelperProcess(_ pid: Int32) {
1119
+ let payload: [String: Any] = [
1120
+ "pid": Int(pid),
1121
+ "recordedAt": Date().timeIntervalSince1970,
1122
+ ]
1123
+ guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) else {
1124
+ return
1125
+ }
1126
+ try? data.write(to: authRuntimeURL, options: .atomic)
1127
+ }
1128
+
1129
+ private func clearRecordedAuthHelperProcess() {
1130
+ try? FileManager.default.removeItem(at: authRuntimeURL)
1131
+ }
1132
+
1133
+ private func cleanupLingeringAuthHelpers() {
1134
+ let fm = FileManager.default
1135
+
1136
+ if let data = try? Data(contentsOf: authRuntimeURL),
1137
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1138
+ let pid = json["pid"] as? Int {
1139
+ terminateRecordedAuthHelper(pid)
1140
+ try? fm.removeItem(at: authRuntimeURL)
1141
+ }
1142
+
1143
+ let currentPID = Int(ProcessInfo.processInfo.processIdentifier)
1144
+ for entry in ProcessQuery.snapshot().values where Self.looksLikePiOAuthHelper(entry.args) {
1145
+ guard entry.pid != currentPID else { continue }
1146
+ guard authProcess?.processIdentifier != Int32(entry.pid) else { continue }
1147
+ terminateRecordedAuthHelper(entry.pid)
1148
+ }
1149
+ }
1150
+
1151
+ private func terminateRecordedAuthHelper(_ pid: Int) {
1152
+ guard pid > 1 else { return }
1153
+ guard kill(Int32(pid), 0) == 0 else { return }
1154
+
1155
+ let args = ProcessQuery.shell(["/bin/ps", "-p", "\(pid)", "-o", "args="])
1156
+ guard Self.looksLikePiOAuthHelper(args) else { return }
1157
+
1158
+ _ = kill(Int32(pid), SIGTERM)
1159
+ let deadline = Date().addingTimeInterval(1.0)
1160
+ while Date() < deadline {
1161
+ if kill(Int32(pid), 0) != 0 {
1162
+ return
1163
+ }
1164
+ usleep(100_000)
1165
+ }
1166
+
1167
+ _ = kill(Int32(pid), SIGKILL)
1168
+ }
1169
+
1170
+ private func terminateProcess(_ process: Process?, escalateAfter delay: TimeInterval) {
1171
+ guard let process else { return }
1172
+ let pid = process.processIdentifier
1173
+ process.terminate()
1174
+
1175
+ let deadline = Date().addingTimeInterval(delay)
1176
+ while Date() < deadline {
1177
+ if kill(pid, 0) != 0 {
1178
+ return
1179
+ }
1180
+ usleep(100_000)
1181
+ }
1182
+
1183
+ _ = kill(pid, SIGKILL)
1184
+ }
1185
+
696
1186
  private static func piAgentDirURL() -> URL {
697
1187
  if let override = ProcessInfo.processInfo.environment["PI_CODING_AGENT_DIR"],
698
1188
  !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -714,6 +1204,12 @@ final class PiChatSession: ObservableObject {
714
1204
  || lowercased.contains("bad request")
715
1205
  }
716
1206
 
1207
+ private static func looksLikePiOAuthHelper(_ args: String) -> Bool {
1208
+ args.contains("node:readline")
1209
+ && args.contains("getOAuthProvider")
1210
+ && args.contains("oauthModuleUrl")
1211
+ }
1212
+
717
1213
  private static func clampDockHeight(_ height: CGFloat) -> CGFloat {
718
1214
  min(max(height, 170), 520)
719
1215
  }
@@ -746,70 +1242,74 @@ final class PiChatSession: ObservableObject {
746
1242
  }
747
1243
 
748
1244
  private static let oauthDriverScript = #"""
749
- import readline from 'node:readline';
1245
+ import readline from 'node:readline';
750
1246
 
751
- const providerId = process.argv[1];
752
- const oauthModuleUrl = process.argv[2];
753
- const { getOAuthProvider } = await import(oauthModuleUrl);
1247
+ const providerId = process.argv[1];
1248
+ const oauthModuleUrl = process.argv[2];
1249
+ const { getOAuthProvider } = await import(oauthModuleUrl);
754
1250
 
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
- }
1251
+ const provider = getOAuthProvider(providerId);
1252
+ if (!provider) {
1253
+ process.stdout.write(JSON.stringify({ type: 'error', message: `Unknown OAuth provider: ${providerId}` }) + '\n');
1254
+ process.exit(1);
1255
+ }
760
1256
 
761
- const rl = readline.createInterface({
762
- input: process.stdin,
763
- output: process.stderr,
764
- terminal: false,
765
- });
1257
+ const rl = readline.createInterface({
1258
+ input: process.stdin,
1259
+ output: process.stderr,
1260
+ terminal: false,
1261
+ });
766
1262
 
767
- function emit(event) {
768
- process.stdout.write(JSON.stringify(event) + '\n');
769
- }
1263
+ function emit(event) {
1264
+ process.stdout.write(JSON.stringify(event) + '\n');
1265
+ }
770
1266
 
771
- function readLine() {
772
- return new Promise((resolve) => {
773
- rl.once('line', (line) => resolve(line));
774
- });
775
- }
1267
+ function readLine() {
1268
+ return new Promise((resolve) => {
1269
+ rl.once('line', (line) => resolve(line));
1270
+ });
1271
+ }
1272
+
1273
+ try {
1274
+ const credentials = await provider.login({
1275
+ onAuth: (info) => emit({
1276
+ type: 'auth',
1277
+ url: info.url,
1278
+ instructions: info.instructions ?? null,
1279
+ }),
1280
+ onPrompt: async (prompt) => {
1281
+ emit({
1282
+ type: 'prompt',
1283
+ message: prompt.message,
1284
+ placeholder: prompt.placeholder ?? null,
1285
+ allowEmpty: Boolean(prompt.allowEmpty),
1286
+ });
1287
+ const input = await readLine();
1288
+ return typeof input === 'string' ? input : '';
1289
+ },
1290
+ onProgress: (message) => emit({
1291
+ type: 'progress',
1292
+ message,
1293
+ }),
1294
+ });
776
1295
 
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
1296
  emit({
786
- type: 'prompt',
787
- message: prompt.message,
788
- placeholder: prompt.placeholder ?? null,
789
- allowEmpty: Boolean(prompt.allowEmpty),
1297
+ type: 'success',
1298
+ credentials,
790
1299
  });
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
- """#
1300
+ rl.close();
1301
+ process.exit(0);
1302
+ } catch (error) {
1303
+ emit({
1304
+ type: 'error',
1305
+ message: error instanceof Error ? error.message : String(error),
1306
+ });
1307
+ rl.close();
1308
+ process.exit(1);
1309
+ }
1310
+ """#
1311
+
1312
+ private var authRuntimeURL: URL {
1313
+ sessionFileURL.deletingLastPathComponent().appendingPathComponent("oauth-runtime.json")
1314
+ }
815
1315
  }