@hasna/recordings 0.3.13 → 0.5.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 (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -16,19 +16,23 @@ extension KeyboardShortcuts.Name {
16
16
 
17
17
  public struct TranscriptionResult: Identifiable, Sendable {
18
18
  public let id = UUID()
19
- let rawText: String
20
- let processedText: String?
21
- let timestamp: Date
22
- let projectId: String?
23
- let projectName: String?
19
+ public let rawText: String
20
+ public let processedText: String?
21
+ public let timestamp: Date
22
+ public let projectId: String?
23
+ public let projectName: String?
24
+ public let captureID: String?
25
+ public let audioURL: URL?
24
26
  public var displayText: String { processedText ?? rawText }
25
27
 
26
- init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?) {
28
+ public init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?, captureID: String? = nil, audioURL: URL? = nil) {
27
29
  self.rawText = rawText
28
30
  self.processedText = processedText
29
31
  self.timestamp = timestamp
30
32
  self.projectId = projectId
31
33
  self.projectName = projectName
34
+ self.captureID = captureID
35
+ self.audioURL = audioURL
32
36
  }
33
37
  }
34
38
 
@@ -95,7 +99,7 @@ struct FrontmostAppSnapshot: Equatable, Sendable {
95
99
 
96
100
  /// The one capability `RecordingEngine` needs from an audio recorder; lets tests run the
97
101
  /// production start path without microphone hardware or TCC grants.
98
- protocol PCMRecordingSource: AnyObject {
102
+ protocol PCMRecordingSource: AnyObject, Sendable {
99
103
  func start() throws
100
104
  func stop()
101
105
  }
@@ -671,7 +675,7 @@ private final class PCMStreamPipe: @unchecked Sendable {
671
675
  private let continuation: AsyncStream<Data>.Continuation
672
676
  private let processor: Task<Data, Never>
673
677
 
674
- init(chunkSize: Int, client: RealtimeTranscriptionClient?) {
678
+ init(chunkSize: Int, client: RealtimeTranscriptionClient?, providerSession: (any RecordingTranscriptionSession)? = nil) {
675
679
  var streamContinuation: AsyncStream<Data>.Continuation!
676
680
  let stream = AsyncStream<Data>(bufferingPolicy: .unbounded) { continuation in
677
681
  streamContinuation = continuation
@@ -682,17 +686,22 @@ private final class PCMStreamPipe: @unchecked Sendable {
682
686
  var pendingChunk = Data()
683
687
 
684
688
  for await data in stream {
689
+ guard !Task.isCancelled else { break }
685
690
  guard !data.isEmpty else { continue }
686
691
  recordedPCM.append(data)
692
+ // Providers need every admitted packet before a pause can settle.
693
+ // Only the legacy realtime client waits for network-sized chunks.
694
+ providerSession?.appendPCM(data)
687
695
  pendingChunk.append(data)
688
696
 
689
697
  while pendingChunk.count >= chunkSize {
690
- await client?.sendAudio(pendingChunk.prefixData(count: chunkSize))
698
+ let chunk = pendingChunk.prefixData(count: chunkSize)
699
+ await client?.sendAudio(chunk)
691
700
  pendingChunk.removeFirst(chunkSize)
692
701
  }
693
702
  }
694
703
 
695
- if !pendingChunk.isEmpty {
704
+ if !Task.isCancelled && !pendingChunk.isEmpty {
696
705
  await client?.sendAudio(pendingChunk)
697
706
  }
698
707
  return recordedPCM
@@ -737,7 +746,7 @@ public final class RecordingEngine: ObservableObject {
737
746
  @Published public private(set) var isWarmingUpCapture = false
738
747
  @Published public var useFnKey: Bool = false {
739
748
  didSet {
740
- UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
749
+ preferences.set(useFnKey, forKey: "useFnKey")
741
750
  updateFnMonitor()
742
751
  refreshTriggerDiagnostics()
743
752
  }
@@ -854,9 +863,9 @@ public final class RecordingEngine: ObservableObject {
854
863
  private var deliveryBlockedReasonGeneration: UInt64?
855
864
  /// Advanced fallback policy (Settings only): when off, every recording is dictated
856
865
  /// literally and the classifier is never consulted.
857
- @Published public var intentDetectionEnabled: Bool = true {
866
+ @Published public var intentDetectionEnabled: Bool = false {
858
867
  didSet {
859
- UserDefaults.standard.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
868
+ preferences.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
860
869
  }
861
870
  }
862
871
  /// Typed Record-page state; views render idle/listening/finalizing/processing/ready/error
@@ -874,13 +883,68 @@ public final class RecordingEngine: ObservableObject {
874
883
  @Published public var isTranscribing = false
875
884
  @Published public var recordingDuration: TimeInterval = 0
876
885
  @Published public var liveTranscriptionText = ""
886
+ @Published public private(set) var isPaused = false
887
+ @Published public private(set) var audioLevel: Double = 0
888
+ @Published public private(set) var recentPastes: [RecentPaste] = []
889
+ @Published public private(set) var latestAudioPath: String?
890
+ @Published public var autoPasteEnabled = true {
891
+ didSet {
892
+ if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
893
+ preferences.set(autoPasteEnabled, forKey: "recordingsAutoPaste")
894
+ }
895
+ }
896
+ }
897
+ private var captureMonitor = CaptureMonitor()
898
+
899
+ public func togglePause() {
900
+ guard isRecording else { return }
901
+ isPaused.toggle()
902
+ captureMonitor.setPaused(isPaused)
903
+ statusMessage = isPaused ? "Paused" : "Recording…"
904
+ }
905
+
906
+ public func clearRecentPastes() { recentPastes.removeAll() }
907
+
908
+ #if DEBUG
909
+ /// Explicitly isolated design review only; does not open a microphone or connection.
910
+ public func configureVisualPreview() {
911
+ isRecording = true
912
+ recordingDuration = 12
913
+ audioLevel = 0.65
914
+ liveTranscriptionText = "Here are the key takeaways from today’s meeting…"
915
+ let samples = [
916
+ ("com.apple.MobileSMS", "To: James", "Schedule a review for tomorrow at 2 PM…"),
917
+ ("com.apple.Notes", "New Note", "Here are the key takeaways from today’s…"),
918
+ ("com.tinyspeck.slackmacgap", "#product", "Can you confirm we’re still on for Friday?"),
919
+ ("com.apple.mail", "New message", "Sharing the updated timeline and next steps."),
920
+ ("notion.id", "Page", "Write a short summary of the discussion…")
921
+ ]
922
+ recentPastes = zip(samples, ["Messages", "Notes", "Slack", "Mail", "Notion"]).map { sample, name in RecentPaste(text: sample.2, bundleIdentifier: sample.0, appName: name, location: sample.1, status: "Pasted", verified: true) }
923
+ }
924
+ #endif
925
+
877
926
  @Published public var transcriptionLanguage = OpenAIAPIKeyStore.defaultLanguage {
878
927
  didSet {
879
- UserDefaults.standard.set(transcriptionLanguage, forKey: "recordingsLanguage")
880
- try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
928
+ preferences.set(transcriptionLanguage, forKey: "recordingsLanguage")
929
+ if !usesIsolatedProvider {
930
+ try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
931
+ }
881
932
  }
882
933
  }
883
934
 
935
+ private let preferences: UserDefaults
936
+ private let preferencesSuiteName: String?
937
+ private let installsGlobalHandlers: Bool
938
+ private let transcriptionProvider: (any RecordingTranscriptionProvider)?
939
+ private var usesIsolatedProvider: Bool { transcriptionProvider != nil }
940
+ private var providerSession: (any RecordingTranscriptionSession)?
941
+ private var providerConfiguration: RecordingProviderSessionConfiguration?
942
+ private var providerCompletionTask: Task<Void, Never>?
943
+ /// Injection keeps persistence ordering testable without changing the public file contract.
944
+ var providerAudioWriter: @Sendable (Data, URL) throws -> Void = { pcm, url in
945
+ try RecordingEngine.writeWAV(pcmData: pcm, sampleRate: 24_000, channelCount: 1, bitsPerSample: 16, to: url)
946
+ }
947
+
884
948
  private var nativeRecorder: PCMRecordingSource?
885
949
  private var recordingTimer: Timer?
886
950
  private var activeTrigger: RecordingTrigger?
@@ -889,10 +953,21 @@ public final class RecordingEngine: ObservableObject {
889
953
  private var fnKeyIsDown = false
890
954
  private var targetAppBundleIdentifier: String?
891
955
  private var targetAppPid: pid_t?
956
+ private var frozenPasteTargetsByGeneration: [UInt64: RecordingPasteTargetSelection] = [:]
892
957
  private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
893
958
  public var projectStore: ProjectStore?
959
+ /// The minimal app uses only global cleanup preferences from the legacy settings
960
+ /// file. Keeping this separate prevents old active projects from tagging new captures.
961
+ public var globalRecordingPreferences: ProjectStore?
894
962
  public var voiceShortcuts: VoiceShortcuts?
895
963
 
964
+ var recordingCleanupPreferences: (prompt: String, mode: String) {
965
+ if let settings = globalRecordingPreferences?.settings {
966
+ return (settings.globalSystemPrompt, (PostProcessingMode(rawValue: settings.postProcessingMode) ?? .auto).rawValue)
967
+ }
968
+ return (projectStore?.effectiveSystemPrompt ?? "", projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue)
969
+ }
970
+
896
971
  // MARK: - Injectable boundaries
897
972
  // Production defaults perform the real I/O; tests replace them to drive the production
898
973
  // start/delivery paths without microphone, Accessibility, network, or CLI access.
@@ -911,6 +986,12 @@ public final class RecordingEngine: ObservableObject {
911
986
  launchDate: app.launchDate
912
987
  )
913
988
  }
989
+ var pasteTargetApplicationLookup: (pid_t) -> PasteApplicationObservation? = {
990
+ NSRunningApplication(processIdentifier: $0).map(PasteApplicationObservation.init)
991
+ }
992
+ var pasteFallbackWriter: (String) -> Bool = { text in
993
+ RecordingEngine.writeClipboardPreservingOnFailure(text, to: .general)
994
+ }
914
995
  var recorderFactory: (@escaping @Sendable (Data) -> Void) -> PCMRecordingSource = {
915
996
  NativePCMRecorder(onPCM: $0)
916
997
  }
@@ -921,12 +1002,15 @@ public final class RecordingEngine: ObservableObject {
921
1002
  RecordingEngine.focusedWindowTitle(pid: $0)
922
1003
  }
923
1004
  var commandCLI: @Sendable (_ args: [String], _ home: String, _ timeout: TimeInterval) -> String = { args, home, ceiling in
924
- // The caller's timeout is the public ceiling on *observable* wall time. CLIRunner's
1005
+ // The caller supplies the remaining public budget after queue admission. CLIRunner's
925
1006
  // total deadline (execution, termination grace, kill grace, pipe drain) sits a full
926
1007
  // return margin below it: spawn setup, waitid poll granularity, capture shutdown,
927
1008
  // and the hop back to the caller all run outside CLIRunner's clamped waits and must
928
1009
  // fit inside the reserved margin.
929
1010
  let cliDeadline = ceiling - RecordingEngine.commandRewriteReturnMargin
1011
+ guard cliDeadline > CLIRunner.wallClockCleanupReserve else {
1012
+ return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
1013
+ }
930
1014
  return CLIRunner.run(args, home: home, timeout: cliDeadline, totalWallClockBudget: cliDeadline)
931
1015
  }
932
1016
  /// Resolves and revalidates the frozen rewrite target immediately before a rewrite:
@@ -1158,6 +1242,23 @@ public final class RecordingEngine: ObservableObject {
1158
1242
  /// *observable* rewrite time under the public ceiling even when the execution window,
1159
1243
  /// termination grace, and pipe drain all run to exhaustion.
1160
1244
  nonisolated static let commandRewriteReturnMargin: TimeInterval = 1
1245
+
1246
+ /// Create this operation synchronously before awaiting the blocking queue: creating
1247
+ /// its deadline inside the submitted closure would give delayed work a fresh budget.
1248
+ nonisolated static func makeCommandRewriteOperation(
1249
+ args: [String],
1250
+ home: String,
1251
+ runCLI: @escaping @Sendable ([String], String, TimeInterval) -> String,
1252
+ deadline: CLIRunner.WallClockDeadline = .init(after: commandRewriteTimeout)
1253
+ ) -> @Sendable () -> String {
1254
+ return {
1255
+ let remaining = min(commandRewriteTimeout, deadline.remaining())
1256
+ guard remaining > commandRewriteReturnMargin + CLIRunner.wallClockCleanupReserve else {
1257
+ return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
1258
+ }
1259
+ return runCLI(args, home, remaining)
1260
+ }
1261
+ }
1161
1262
  /// Wait before each read-back of the target app's focused field. The window server
1162
1263
  /// delivers the posted keystroke asynchronously and the app then does its own work, so a
1163
1264
  /// read taken on the posting turn would report "unchanged" for a paste that is simply
@@ -1188,15 +1289,37 @@ public final class RecordingEngine: ObservableObject {
1188
1289
  let home: String
1189
1290
  private var audioDir: String { "\(home)/.hasna/recordings/audio" }
1190
1291
 
1191
- public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
1292
+ public convenience init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path, installsGlobalHandlers: Bool = true) {
1293
+ self.init(homePath: homePath, preferences: .standard, preferencesSuiteName: nil, installsGlobalHandlers: installsGlobalHandlers, transcriptionProvider: nil)
1294
+ }
1295
+
1296
+ /// Reuses native capture and paste with isolated preferences and an explicit provider.
1297
+ /// No global handlers, legacy keys, environment routing or helper CLI are consulted.
1298
+ public convenience init(configuration: RecordingEngineConfiguration, transcriptionProvider: any RecordingTranscriptionProvider) {
1299
+ // A validated non-empty suite name is supported by Foundation on macOS.
1300
+ let preferences = UserDefaults(suiteName: configuration.preferencesSuiteName)!
1301
+ self.init(homePath: configuration.isolatedHomePath, preferences: preferences, preferencesSuiteName: configuration.preferencesSuiteName, installsGlobalHandlers: false, transcriptionProvider: transcriptionProvider)
1302
+ }
1303
+
1304
+ private init(homePath: String, preferences: UserDefaults, preferencesSuiteName: String?, installsGlobalHandlers: Bool, transcriptionProvider: (any RecordingTranscriptionProvider)?) {
1192
1305
  home = homePath
1306
+ self.preferences = preferences
1307
+ self.preferencesSuiteName = preferencesSuiteName
1308
+ self.installsGlobalHandlers = installsGlobalHandlers
1309
+ self.transcriptionProvider = transcriptionProvider
1193
1310
  try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
1194
1311
  log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
1195
1312
 
1196
1313
  // Load preferences
1197
- intentDetectionEnabled = UserDefaults.standard.object(forKey: "intentDetectionEnabled") as? Bool ?? true
1198
- transcriptionLanguage = OpenAIAPIKeyStore.loadLanguage(homePath: home)
1199
- useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
1314
+ if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
1315
+ autoPasteEnabled = storedPreference("recordingsAutoPaste") as? Bool ?? true
1316
+ }
1317
+ intentDetectionEnabled = storedPreference("intentDetectionEnabled") as? Bool ?? false
1318
+ transcriptionLanguage = usesIsolatedProvider
1319
+ ? (storedPreference("recordingsLanguage") as? String ?? "en")
1320
+ : OpenAIAPIKeyStore.loadLanguage(homePath: home)
1321
+ useFnKey = storedPreference("useFnKey") as? Bool ?? false
1322
+ guard installsGlobalHandlers else { statusMessage = "Ready"; return }
1200
1323
  if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
1201
1324
  KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
1202
1325
  }
@@ -1268,6 +1391,15 @@ public final class RecordingEngine: ObservableObject {
1268
1391
  updateStatus()
1269
1392
  }
1270
1393
 
1394
+ private func storedPreference(_ key: String) -> Any? {
1395
+ if let preferencesSuiteName {
1396
+ // UserDefaults.object also consults process/global domains. A separate app's
1397
+ // engine reads only its explicitly named persistent domain.
1398
+ return preferences.persistentDomain(forName: preferencesSuiteName)?[key]
1399
+ }
1400
+ return preferences.object(forKey: key)
1401
+ }
1402
+
1271
1403
  public var microphonePermissionLabel: String {
1272
1404
  switch AVCaptureDevice.authorizationStatus(for: .audio) {
1273
1405
  case .authorized:
@@ -1331,6 +1463,7 @@ public final class RecordingEngine: ObservableObject {
1331
1463
  /// was indistinguishable from a working one. Log the resolved binding so "is the
1332
1464
  /// trigger armed, and to what" is answerable from the log alone.
1333
1465
  public func logResolvedTrigger() {
1466
+ guard installsGlobalHandlers else { return }
1334
1467
  let stored = KeyboardShortcuts.getShortcut(for: .toggleRecording)
1335
1468
  let bound = stored
1336
1469
  .map { "carbonKeyCode=\($0.carbonKeyCode) carbonModifiers=\($0.carbonModifiers)" }
@@ -1406,6 +1539,7 @@ public final class RecordingEngine: ObservableObject {
1406
1539
  }
1407
1540
 
1408
1541
  private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
1542
+ guard installsGlobalHandlers else { return }
1409
1543
  // Decided as a local first, then handed to the single writer once. Assigning the
1410
1544
  // published property from each branch is how the per-source erasure bug got in.
1411
1545
  var reason: String?
@@ -1512,6 +1646,7 @@ public final class RecordingEngine: ObservableObject {
1512
1646
  /// Re-evaluate every trigger's health, push it to the UI, and record it. The one entry
1513
1647
  /// point callers should use after anything changes a binding.
1514
1648
  public func refreshTriggerDiagnostics() {
1649
+ guard installsGlobalHandlers else { updateStatus(); return }
1515
1650
  refreshHotkeyDiagnostics()
1516
1651
  updateStatus()
1517
1652
  logResolvedTrigger()
@@ -1635,7 +1770,8 @@ public final class RecordingEngine: ObservableObject {
1635
1770
 
1636
1771
  // MARK: - Start Recording (Streaming)
1637
1772
 
1638
- public func startRecording(trigger: RecordingTrigger = .manual) {
1773
+ public func startRecording(trigger: RecordingTrigger = .manual,
1774
+ pasteTarget: RecordingPasteTargetSelection = .frontmostApplication) {
1639
1775
  guard Self.canBeginRecording(
1640
1776
  isRecording: isRecording,
1641
1777
  isTranscribing: isTranscribing,
@@ -1652,11 +1788,12 @@ public final class RecordingEngine: ObservableObject {
1652
1788
  }
1653
1789
  log("startRecording trigger=\(trigger) microphoneStatus=\(microphoneAuthorization().rawValue) accessibility=\(accessibilityTrustCheck())")
1654
1790
  recordingGeneration &+= 1
1655
- if pasteTargetProcessIdentityByGeneration.count >= 32 {
1791
+ if pasteTargetProcessIdentityByGeneration.count >= 32 || frozenPasteTargetsByGeneration.count >= 32 {
1656
1792
  let oldestRetainedGeneration = recordingGeneration > 16 ? recordingGeneration - 16 : 0
1657
1793
  pasteTargetProcessIdentityByGeneration = pasteTargetProcessIdentityByGeneration.filter {
1658
1794
  $0.key >= oldestRetainedGeneration
1659
1795
  }
1796
+ frozenPasteTargetsByGeneration = frozenPasteTargetsByGeneration.filter { $0.key >= oldestRetainedGeneration }
1660
1797
  }
1661
1798
  activeTrigger = trigger
1662
1799
  keyboardShortcutIsDown = trigger == .keyboardShortcut
@@ -1675,7 +1812,19 @@ public final class RecordingEngine: ObservableObject {
1675
1812
  setBlockedReason(nil, for: .delivery)
1676
1813
 
1677
1814
  let myPID = ProcessInfo.processInfo.processIdentifier
1678
- let frontmostApp = frontmostAppSnapshot()
1815
+ let frontmostApp: FrontmostAppSnapshot?
1816
+ switch pasteTarget {
1817
+ case .frontmostApplication:
1818
+ frontmostApp = frontmostAppSnapshot()
1819
+ case .frozen(let target):
1820
+ // Keep explicit nil distinct from omission, including when a previously
1821
+ // observed target terminated between the host's snapshot and this call.
1822
+ frozenPasteTargetsByGeneration[recordingGeneration] = pasteTarget
1823
+ if let target, target.processIdentifier != myPID,
1824
+ let app = pasteTargetApplicationLookup(target.processIdentifier), target.matches(app) {
1825
+ frontmostApp = FrontmostAppSnapshot(pid: target.processIdentifier, bundleIdentifier: target.bundleIdentifier, launchDate: target.launchDate)
1826
+ } else { frontmostApp = nil }
1827
+ }
1679
1828
  let isOwnApp = frontmostApp?.pid == myPID
1680
1829
  targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
1681
1830
  targetAppPid = isOwnApp ? nil : frontmostApp?.pid
@@ -1695,26 +1844,28 @@ public final class RecordingEngine: ObservableObject {
1695
1844
  // The selection is still frozen for every recording (not only an exposed "command
1696
1845
  // mode"), so a later command decision can only ever act on the exact text and
1697
1846
  // element that were selected when the user started speaking. The Accessibility IPC
1698
- // that reads it runs on a detached task, concurrently with recorder start: the
1699
- // microphone must never wait on a beachballing target app, and the MainActor stays
1847
+ // that reads it runs on a blocking-work queue, concurrently with recorder start:
1848
+ // neither the microphone nor a cooperative worker waits on a beachballing app. The MainActor stays
1700
1849
  // free to process the key-up that stops the recording. Skipped entirely when intent
1701
1850
  // detection is off — no command route exists to consume it.
1702
1851
  let shouldCaptureSelection = Self.shouldCaptureSelection(
1703
1852
  targetPid: targetAppPid,
1704
1853
  accessibilityTrusted: accessibilityTrustCheck(),
1705
- intentDetectionEnabled: intentDetectionEnabled
1854
+ intentDetectionEnabled: !usesIsolatedProvider && intentDetectionEnabled
1706
1855
  )
1707
1856
  let capturePid = targetAppPid
1708
1857
  let captureSelection = selectionCapture
1709
1858
  let windowTitleLookup = focusedWindowTitleLookup
1710
1859
  let windowTitlePid = frontmostApp?.pid
1711
1860
  let axSnapshotTask = Task.detached(priority: .userInitiated) { () -> RecordingStartAXSnapshot in
1712
- let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
1713
- let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
1714
- return RecordingStartAXSnapshot(
1715
- selectionToken: selectionToken,
1716
- focusedWindowTitle: focusedWindowTitle
1717
- )
1861
+ await BlockingOperation.run {
1862
+ let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
1863
+ let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
1864
+ return RecordingStartAXSnapshot(
1865
+ selectionToken: selectionToken,
1866
+ focusedWindowTitle: focusedWindowTitle
1867
+ )
1868
+ }
1718
1869
  }
1719
1870
 
1720
1871
  // Project auto-selection and the processing configuration resolve with the
@@ -1722,9 +1873,11 @@ public final class RecordingEngine: ObservableObject {
1722
1873
  // recording pipeline awaits this context only after the recorder has stopped.
1723
1874
  let generation = recordingGeneration
1724
1875
  let projectStore = projectStore
1876
+ let cleanupPreferences = recordingCleanupPreferences
1725
1877
  let targetBundleIdentifierForProjects = targetAppBundleIdentifier
1726
1878
  let transcriptionLanguageAtStart = transcriptionLanguage
1727
- let intentDetectionEnabledAtStart = intentDetectionEnabled
1879
+ let intentDetectionEnabledAtStart = !usesIsolatedProvider && intentDetectionEnabled
1880
+ let usesIsolatedProvider = usesIsolatedProvider
1728
1881
  let homePath = home
1729
1882
  let startContext = Task { @MainActor [weak self] () -> RecordingStartResolvedContext in
1730
1883
  let axSnapshot = await axSnapshotTask.value
@@ -1748,7 +1901,9 @@ public final class RecordingEngine: ObservableObject {
1748
1901
  self.log("project synchronization degraded; continuing capture: \(warning)")
1749
1902
  }
1750
1903
  }
1751
- let modelSelection = OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
1904
+ let modelSelection = usesIsolatedProvider
1905
+ ? ProcessingModelSelection(transcriptionPrompt: "", transcriptionModel: "", transcriberModel: "", enhancementModel: "", intentModel: "", enhanceTriggersJSON: "[]", keywordTransformsJSON: "{}")
1906
+ : OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
1752
1907
  return RecordingStartResolvedContext(
1753
1908
  selectionToken: axSnapshot.selectionToken,
1754
1909
  canonicalProjectId: projectStore?.activeCanonicalProjectIdForRecording,
@@ -1756,8 +1911,8 @@ public final class RecordingEngine: ObservableObject {
1756
1911
  activeProjectName: projectStore?.activeProject?.name,
1757
1912
  processing: RecordingProcessingConfiguration(
1758
1913
  transcriptionPrompt: modelSelection.transcriptionPrompt,
1759
- transcriberPrompt: projectStore?.effectiveSystemPrompt ?? "",
1760
- postProcessingMode: projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue,
1914
+ transcriberPrompt: cleanupPreferences.prompt,
1915
+ postProcessingMode: cleanupPreferences.mode,
1761
1916
  transcriptionLanguage: transcriptionLanguageAtStart,
1762
1917
  transcriptionModel: modelSelection.transcriptionModel,
1763
1918
  transcriberModel: modelSelection.transcriberModel,
@@ -1945,7 +2100,26 @@ public final class RecordingEngine: ObservableObject {
1945
2100
  }
1946
2101
 
1947
2102
  private func startNativeRecording(startContext: Task<RecordingStartResolvedContext, Never>) {
1948
- let apiKey = openAIAPIKeyProvider()
2103
+ let apiKey = usesIsolatedProvider ? "" : openAIAPIKeyProvider()
2104
+ if let transcriptionProvider {
2105
+ let generation = recordingGeneration
2106
+ let configuration = RecordingProviderSessionConfiguration(captureID: UUID().uuidString, language: transcriptionLanguage)
2107
+ do {
2108
+ providerSession = try transcriptionProvider.makeSession(configuration: configuration) { [weak self] text in
2109
+ Task { @MainActor [weak self] in
2110
+ guard let self, self.recordingGeneration == generation,
2111
+ self.captureIsActive || self.isTranscribing else { return }
2112
+ self.liveTranscriptionText = text
2113
+ }
2114
+ }
2115
+ providerConfiguration = configuration
2116
+ } catch {
2117
+ resetRecordingIntent()
2118
+ statusMessage = error.localizedDescription
2119
+ flowPhase = .failed(statusMessage)
2120
+ return
2121
+ }
2122
+ }
1949
2123
  let captureConfiguration = RecordingCaptureConfiguration(
1950
2124
  targetAppBundleIdentifier: targetAppBundleIdentifier,
1951
2125
  targetAppPid: targetAppPid,
@@ -1964,7 +2138,7 @@ public final class RecordingEngine: ObservableObject {
1964
2138
  : RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
1965
2139
  realtimeClient = client
1966
2140
 
1967
- let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
2141
+ let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client, providerSession: providerSession)
1968
2142
  pcmStreamPipe = streamPipe
1969
2143
  let homePath = home
1970
2144
  let captureGeneration = recordingGeneration
@@ -1972,7 +2146,12 @@ public final class RecordingEngine: ObservableObject {
1972
2146
  self?.confirmCaptureIsLive(generation: generation)
1973
2147
  }
1974
2148
  let firstChunkLogged = LockedFlag()
2149
+ let monitor = CaptureMonitor()
2150
+ captureMonitor = monitor
2151
+ isPaused = false
2152
+ audioLevel = 0
1975
2153
  let recorder = recorderFactory { data in
2154
+ guard monitor.admit(data) else { return }
1976
2155
  if firstChunkLogged.take() {
1977
2156
  NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
1978
2157
  // The first sample is the only honest signal that this recording exists.
@@ -2004,10 +2183,14 @@ public final class RecordingEngine: ObservableObject {
2004
2183
 
2005
2184
  recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
2006
2185
  Task { @MainActor [weak self] in
2007
- self?.recordingDuration += 0.1
2186
+ guard let self else { return }
2187
+ let sample = monitor.snapshot()
2188
+ self.recordingDuration = sample.duration
2189
+ self.audioLevel = sample.level
2008
2190
  }
2009
2191
  }
2010
2192
  } catch {
2193
+ cancelProviderSession()
2011
2194
  log("native recorder failed error=\(error.localizedDescription)")
2012
2195
  // Unreachable today — nothing between `recorder.start()` and `isWarmingUpCapture`
2013
2196
  // can throw — but a warming flag left set here wedges the engine permanently: the
@@ -2049,6 +2232,7 @@ public final class RecordingEngine: ObservableObject {
2049
2232
  private func abandonWarmingCapture(reason: String, alert: RecordingAttemptAlert?) {
2050
2233
  guard isWarmingUpCapture else { return }
2051
2234
  log("capture abandoned before first audio reason=\(reason)")
2235
+ cancelProviderSession()
2052
2236
  // Supersede the attempt so every completion still bound to it — a queued first-chunk
2053
2237
  // confirmation, the resolved start context — is stale and cannot apply.
2054
2238
  recordingGeneration &+= 1
@@ -2154,6 +2338,18 @@ public final class RecordingEngine: ObservableObject {
2154
2338
  // MARK: - Cancel (discard without transcribing)
2155
2339
 
2156
2340
  public func cancelRecording() {
2341
+ if usesIsolatedProvider && isTranscribing {
2342
+ pipelineDeliveryGate.abandonPipeline(recordingGeneration)
2343
+ recordingGeneration &+= 1
2344
+ cancelProviderSession()
2345
+ isTranscribing = false
2346
+ liveTranscriptionText = ""
2347
+ recordedPCM.removeAll(keepingCapacity: true)
2348
+ activeAudioPath = nil
2349
+ resetRecordingIntent()
2350
+ updateStatus()
2351
+ return
2352
+ }
2157
2353
  // Discard during warm-up: identical teardown, but the user asked for it, so the glyph
2158
2354
  // stays quiet.
2159
2355
  if isWarmingUpCapture {
@@ -2162,6 +2358,7 @@ public final class RecordingEngine: ObservableObject {
2162
2358
  }
2163
2359
  guard isRecording else { return }
2164
2360
  log("cancelRecording")
2361
+ cancelProviderSession()
2165
2362
 
2166
2363
  recordingTimer?.invalidate()
2167
2364
  recordingTimer = nil
@@ -2178,6 +2375,9 @@ public final class RecordingEngine: ObservableObject {
2178
2375
  pcmStreamPipe = nil
2179
2376
 
2180
2377
  isRecording = false
2378
+ isPaused = false
2379
+ audioLevel = 0
2380
+ recordingDuration = 0
2181
2381
  isTranscribing = false
2182
2382
  liveTranscriptionText = ""
2183
2383
  recordedPCM.removeAll(keepingCapacity: true)
@@ -2191,6 +2391,98 @@ public final class RecordingEngine: ObservableObject {
2191
2391
 
2192
2392
  // MARK: - Stop & Transcribe
2193
2393
 
2394
+ private func cancelProviderSession() {
2395
+ providerCompletionTask?.cancel()
2396
+ providerCompletionTask = nil
2397
+ providerSession?.cancel()
2398
+ providerSession = nil
2399
+ providerConfiguration = nil
2400
+ }
2401
+
2402
+ /// The provider path shares capture, WAV writing and verified paste delivery with the
2403
+ /// legacy recorder. It never enters the legacy CLI, credential or intent-model paths.
2404
+ private func stopWithProvider(
2405
+ recorder: PCMRecordingSource?, pipe: PCMStreamPipe?,
2406
+ session: any RecordingTranscriptionSession,
2407
+ configuration: RecordingProviderSessionConfiguration, audioPath: String?,
2408
+ targetAppBundleIdentifier: String?, targetAppPid: pid_t?,
2409
+ pipelineGeneration: UInt64, pipelineTrace: RecordingPipelineTrace
2410
+ ) {
2411
+ providerCompletionTask = Task { [weak self] in
2412
+ // Shutdown and drain belong to this capture even if the user cancels and starts
2413
+ // another one before the native input tap has stopped.
2414
+ await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
2415
+ let pcm = await pipe?.finish() ?? Data()
2416
+ guard !Task.isCancelled, let self,
2417
+ self.recordingGeneration == pipelineGeneration else { return }
2418
+ var timings = [pipelineTrace.message(stage: "pcm_drain_complete", detail: "pcm_bytes=\(pcm.count)")]
2419
+ // Capture timestamps now, but batch their I/O after completion. Instrumentation
2420
+ // must not add a log-file write before the early network commit or WAV write.
2421
+ defer { self.log(timings.joined(separator: "\n")) }
2422
+ do {
2423
+ guard !pcm.isEmpty, let audioPath else { throw RecordingProviderError.noAudio }
2424
+ let audioURL = URL(fileURLWithPath: audioPath)
2425
+ session.inputEnded()
2426
+ timings.append(pipelineTrace.message(stage: "provider_input_ended"))
2427
+ let writeAudio = self.providerAudioWriter
2428
+ try await Task.detached(priority: .userInitiated) {
2429
+ try writeAudio(pcm, audioURL)
2430
+ }.value
2431
+ try Task.checkCancellation()
2432
+ guard self.recordingGeneration == pipelineGeneration else { return }
2433
+ timings.append(pipelineTrace.message(stage: "wav_write_complete"))
2434
+ let duration = Double(pcm.count) / 48_000
2435
+ self.recordingDuration = duration
2436
+ let result = try await session.finish(RecordingTranscriptionRequest(
2437
+ captureID: configuration.captureID, audioURL: audioURL,
2438
+ duration: duration, language: configuration.language
2439
+ ))
2440
+ try Task.checkCancellation()
2441
+ guard self.recordingGeneration == pipelineGeneration else { return }
2442
+ timings.append(pipelineTrace.message(stage: "provider_finish_complete"))
2443
+ let rawText = result.rawText.trimmingCharacters(in: .whitespacesAndNewlines)
2444
+ let processed = result.processedText?.trimmingCharacters(in: .whitespacesAndNewlines)
2445
+ let text = (processed?.isEmpty == false ? processed : nil) ?? rawText
2446
+ guard !text.isEmpty else { throw RecordingProviderError.emptyTranscript }
2447
+ self.isTranscribing = false
2448
+ self.liveTranscriptionText = ""
2449
+ self.activeAudioPath = nil
2450
+ self.providerSession = nil
2451
+ self.providerConfiguration = nil
2452
+ self.providerCompletionTask = nil
2453
+ // Publication precedes delivery. Consumers may persist asynchronously without
2454
+ // delaying paste; this is an in-memory result, not a persistence receipt.
2455
+ self.recentTranscriptions.insert(TranscriptionResult(
2456
+ rawText: rawText, processedText: processed?.isEmpty == false ? processed : nil,
2457
+ timestamp: Date(), projectId: nil, projectName: nil,
2458
+ captureID: configuration.captureID, audioURL: audioURL
2459
+ ), at: 0)
2460
+ if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
2461
+ guard self.pipelineDeliveryGate.claimDelivery(for: pipelineGeneration) else { return }
2462
+ self.pasteIntoFrontApp(
2463
+ text, targetAppBundleIdentifier: targetAppBundleIdentifier,
2464
+ targetAppPid: targetAppPid, restoreClipboard: true,
2465
+ deliveryKind: .ordinaryDictation, captureID: configuration.captureID, pipelineTrace: pipelineTrace,
2466
+ pipelineGeneration: pipelineGeneration, deliveryCompleted: nil
2467
+ )
2468
+ } catch {
2469
+ guard self.recordingGeneration == pipelineGeneration, !Task.isCancelled else { return }
2470
+ session.cancel()
2471
+ self.providerSession = nil
2472
+ self.providerConfiguration = nil
2473
+ self.providerCompletionTask = nil
2474
+ self.activeAudioPath = nil
2475
+ self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
2476
+ // Do not log provider errors: third-party error text can contain a token or
2477
+ // request URL. The host UI receives the localized description for diagnosis.
2478
+ self.isTranscribing = false
2479
+ self.liveTranscriptionText = ""
2480
+ self.statusMessage = error.localizedDescription
2481
+ self.flowPhase = .failed(self.statusMessage)
2482
+ }
2483
+ }
2484
+ }
2485
+
2194
2486
  public func stopAndTranscribe() {
2195
2487
  // Stop during the warm-up window has nothing to transcribe — the microphone was opened
2196
2488
  // but has not delivered a sample. Abandon visibly instead of spending a transcription
@@ -2208,12 +2500,12 @@ public final class RecordingEngine: ObservableObject {
2208
2500
 
2209
2501
  let recorder = nativeRecorder
2210
2502
  nativeRecorder = nil
2211
- recorder?.stop()
2212
2503
 
2213
2504
  isRecording = false
2214
2505
  isTranscribing = true
2215
2506
 
2216
2507
  guard let captureConfiguration = activeCaptureConfiguration else {
2508
+ recorder?.stop()
2217
2509
  realtimeClient?.stop()
2218
2510
  realtimeClient = nil
2219
2511
  streamingTask?.cancel()
@@ -2230,6 +2522,9 @@ public final class RecordingEngine: ObservableObject {
2230
2522
  let targetAppBundleIdentifier = captureConfiguration.targetAppBundleIdentifier
2231
2523
  let targetAppPid = captureConfiguration.targetAppPid
2232
2524
  let audioPath = activeAudioPath
2525
+ latestAudioPath = audioPath
2526
+ isPaused = false
2527
+ audioLevel = 0
2233
2528
  let pcmStreamPipe = pcmStreamPipe
2234
2529
  let client = realtimeClient
2235
2530
  let pipelineGeneration = recordingGeneration
@@ -2239,9 +2534,23 @@ public final class RecordingEngine: ObservableObject {
2239
2534
  resetRecordingIntent()
2240
2535
  self.pcmStreamPipe = nil
2241
2536
 
2537
+ if let providerSession, let providerConfiguration {
2538
+ stopWithProvider(
2539
+ recorder: recorder, pipe: pcmStreamPipe, session: providerSession,
2540
+ configuration: providerConfiguration, audioPath: audioPath,
2541
+ targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid,
2542
+ pipelineGeneration: pipelineGeneration, pipelineTrace: pipelineTrace
2543
+ )
2544
+ return
2545
+ }
2546
+
2242
2547
  Task {
2548
+ // AVAudioEngine shutdown can block for hundreds of milliseconds. Keep
2549
+ // receiving realtime text and repainting while capture drains completely.
2550
+ await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
2243
2551
  if let pcmStreamPipe {
2244
2552
  self.recordedPCM = await pcmStreamPipe.finish()
2553
+ self.recordingDuration = Double(self.recordedPCM.count) / 48_000
2245
2554
  }
2246
2555
  self.log(pipelineTrace.message(
2247
2556
  stage: "pcm_drain_complete",
@@ -3815,10 +4124,12 @@ public final class RecordingEngine: ObservableObject {
3815
4124
  activeProjectId: canonicalProjectId,
3816
4125
  processingConfiguration: processingConfiguration
3817
4126
  )
3818
- let runCLI = self.commandCLI
3819
- let result = await Task.detached {
3820
- runCLI(rewriteArguments, homePath, Self.commandRewriteTimeout)
3821
- }.value
4127
+ let rewriteOperation = Self.makeCommandRewriteOperation(
4128
+ args: rewriteArguments,
4129
+ home: homePath,
4130
+ runCLI: self.commandCLI
4131
+ )
4132
+ let result = await BlockingOperation.run(rewriteOperation)
3822
4133
  if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
3823
4134
  self.isTranscribing = false
3824
4135
  self.liveTranscriptionText = ""
@@ -3969,7 +4280,8 @@ public final class RecordingEngine: ObservableObject {
3969
4280
  _ text: String,
3970
4281
  targetAppBundleIdentifier: String? = nil,
3971
4282
  targetAppPid: pid_t? = nil,
3972
- restoreClipboard: Bool = false
4283
+ restoreClipboard: Bool = false,
4284
+ captureID: String? = nil
3973
4285
  ) {
3974
4286
  pasteIntoFrontApp(
3975
4287
  text,
@@ -3977,6 +4289,7 @@ public final class RecordingEngine: ObservableObject {
3977
4289
  targetAppPid: targetAppPid,
3978
4290
  restoreClipboard: restoreClipboard,
3979
4291
  deliveryKind: .manualPaste,
4292
+ captureID: captureID,
3980
4293
  pipelineTrace: nil,
3981
4294
  pipelineGeneration: nil,
3982
4295
  deliveryCompleted: nil
@@ -3989,6 +4302,7 @@ public final class RecordingEngine: ObservableObject {
3989
4302
  targetAppPid: pid_t? = nil,
3990
4303
  restoreClipboard: Bool = false,
3991
4304
  deliveryKind: PasteDeliveryKind,
4305
+ captureID: String? = nil,
3992
4306
  selectionToken: AccessibilitySelectionToken? = nil,
3993
4307
  pipelineTrace: RecordingPipelineTrace?,
3994
4308
  pipelineGeneration: UInt64?,
@@ -4007,18 +4321,31 @@ public final class RecordingEngine: ObservableObject {
4007
4321
  deliveryCompleted?()
4008
4322
  return
4009
4323
  }
4324
+ if !autoPasteEnabled && deliveryKind != .manualPaste {
4325
+ updateDeliveryStatus("Transcript ready — auto-paste is off", kind: .success, pipelineGeneration: pipelineGeneration)
4326
+ deliveryCompleted?()
4327
+ return
4328
+ }
4010
4329
  if let pasteInterceptorForTesting {
4011
4330
  pasteInterceptorForTesting(text, deliveryKind, pipelineGeneration)
4012
4331
  deliveryCompleted?()
4013
4332
  return
4014
4333
  }
4015
- let pb = NSPasteboard.general
4334
+ if let generation = pipelineGeneration, frozenPasteTargetsByGeneration[generation] != nil,
4335
+ pasteTargetProcessIdentityByGeneration[generation] == nil {
4336
+ completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
4337
+ pipelineGeneration: pipelineGeneration)
4338
+ deliveryCompleted?()
4339
+ return
4340
+ }
4016
4341
  var previousClipboard: ClipboardSnapshot?
4017
4342
 
4018
4343
  let accessibility = protectedOperationTrust()
4019
4344
  guard accessibility.trusted else {
4020
4345
  let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
4021
- let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
4346
+ let copied = shouldCopy && pasteFallbackWriter(text)
4347
+ appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
4348
+ pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
4022
4349
  log("paste blocked by accessibility permission")
4023
4350
  let message = if deliveryKind == .commandRewrite {
4024
4351
  "Paste cancelled because Accessibility permission changed"
@@ -4046,18 +4373,8 @@ public final class RecordingEngine: ObservableObject {
4046
4373
  )
4047
4374
 
4048
4375
  guard let app = targetApp else {
4049
- let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
4050
- let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
4051
- log("paste target app not found")
4052
- updateDeliveryStatus(
4053
- deliveryKind == .commandRewrite
4054
- ? "Paste cancelled because the target app is unavailable"
4055
- : copied
4056
- ? "Copied — no target app found"
4057
- : "Transcription ready, but the clipboard could not be updated",
4058
- kind: .failure,
4059
- pipelineGeneration: pipelineGeneration
4060
- )
4376
+ completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
4377
+ pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
4061
4378
  deliveryCompleted?()
4062
4379
  return
4063
4380
  }
@@ -4176,6 +4493,14 @@ public final class RecordingEngine: ObservableObject {
4176
4493
  detail: "chars=\(transaction.text.count)"
4177
4494
  ))
4178
4495
  }
4496
+ let verified = Self.pasteTraceStage(for: outcome) == "paste_delivery_confirmed"
4497
+ let deliveryStatus = Self.recentPasteDeliveryStatus(for: outcome)
4498
+ self.recentPastes.insert(RecentPaste(
4499
+ text: transaction.text, bundleIdentifier: app.bundleIdentifier, appName: app.localizedName ?? "Application",
4500
+ location: "Focused field", status: verified ? "Pasted" : "Unconfirmed", verified: verified,
4501
+ captureID: captureID, deliveryStatus: deliveryStatus
4502
+ ), at: 0)
4503
+ if self.recentPastes.count > 50 { self.recentPastes.removeLast() }
4179
4504
  deliveryCompleted?()
4180
4505
  let message = switch outcome {
4181
4506
  case .pasted: "Pasted (\(transaction.text.count) chars)"
@@ -4521,6 +4846,15 @@ public final class RecordingEngine: ObservableObject {
4521
4846
  /// Pipeline-timing stage name. `paste_posted` used to be emitted for every posted
4522
4847
  /// keystroke, which made the timing trace read like a delivery record; the three delivery
4523
4848
  /// verdicts are now distinct stages.
4849
+ nonisolated static func recentPasteDeliveryStatus(for outcome: PasteDeliveryOutcome) -> RecentPasteDeliveryStatus {
4850
+ switch outcome {
4851
+ case .pasted: .confirmed
4852
+ case .deliveredUnverified: .unconfirmed
4853
+ case .deliveryNotObserved, .secureInputActive, .targetUnavailable,
4854
+ .clipboardOwnershipLost, .clipboardWriteFailed, .eventPostFailed: .notDelivered
4855
+ }
4856
+ }
4857
+
4524
4858
  nonisolated static func pasteTraceStage(for outcome: PasteDeliveryOutcome) -> String {
4525
4859
  switch outcome {
4526
4860
  case .pasted: "paste_delivery_confirmed"
@@ -4563,15 +4897,37 @@ public final class RecordingEngine: ObservableObject {
4563
4897
  setBlockedReason(nil, for: .pressConsumed)
4564
4898
  }
4565
4899
 
4900
+ private func appendUndeliveredPaste(text: String, copied: Bool, captureID: String?,
4901
+ pipelineGeneration: UInt64?, fallbackBundle: String? = nil) {
4902
+ let selection = pipelineGeneration.flatMap { frozenPasteTargetsByGeneration[$0] }
4903
+ let target: RecordingPasteTarget? = if case .frozen(let value) = selection { value } else { nil }
4904
+ recentPastes.insert(RecentPaste(text: text, bundleIdentifier: target?.bundleIdentifier ?? fallbackBundle,
4905
+ appName: target?.applicationName ?? fallbackBundle ?? "No target app",
4906
+ location: copied ? "Clipboard only" : "", status: copied ? "Copied; paste not delivered" : "Paste not delivered",
4907
+ verified: false, captureID: captureID, deliveryStatus: .notDelivered), at: 0)
4908
+ if recentPastes.count > 50 { recentPastes.removeLast() }
4909
+ }
4910
+
4911
+ private func completeUnavailablePaste(_ text: String, deliveryKind: PasteDeliveryKind, captureID: String?,
4912
+ pipelineGeneration: UInt64?, fallbackBundle: String? = nil) {
4913
+ let copied = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind) && pasteFallbackWriter(text)
4914
+ appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
4915
+ pipelineGeneration: pipelineGeneration, fallbackBundle: fallbackBundle)
4916
+ log("paste target app not found")
4917
+ updateDeliveryStatus(deliveryKind == .commandRewrite
4918
+ ? "Paste cancelled because the target app is unavailable"
4919
+ : copied ? "Copied — no target app found" : "Transcription ready, but the clipboard could not be updated",
4920
+ kind: .failure, pipelineGeneration: pipelineGeneration)
4921
+ }
4922
+
4566
4923
  private func selectedRunningPasteTarget(
4567
4924
  targetAppBundleIdentifier: String?,
4568
4925
  targetAppPid: pid_t?,
4569
4926
  frontmostPid: pid_t?,
4570
4927
  pipelineGeneration: UInt64?
4571
4928
  ) -> NSRunningApplication? {
4572
- let myPID = ProcessInfo.processInfo.processIdentifier
4573
4929
  let runningApps = NSWorkspace.shared.runningApplications
4574
- let candidates = runningApps.map {
4930
+ let candidates = runningApps.filter { !$0.isTerminated }.map {
4575
4931
  PasteTargetCandidate(
4576
4932
  pid: $0.processIdentifier,
4577
4933
  bundleIdentifier: $0.bundleIdentifier,
@@ -4579,23 +4935,30 @@ public final class RecordingEngine: ObservableObject {
4579
4935
  launchDate: $0.launchDate
4580
4936
  )
4581
4937
  }
4582
- let requiredProcessIdentity = pipelineGeneration.flatMap {
4583
- pasteTargetProcessIdentityByGeneration[$0]
4584
- }
4585
- let selectedTarget = Self.selectPasteTarget(
4586
- candidates: candidates,
4587
- currentPid: myPID,
4588
- targetBundleIdentifier: targetAppBundleIdentifier,
4589
- targetPid: targetAppPid,
4590
- frontmostPid: frontmostPid,
4591
- requiredProcessIdentity: requiredProcessIdentity,
4592
- requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
4938
+ let selectedTarget = resolvePasteTarget(
4939
+ candidates: candidates, targetBundleIdentifier: targetAppBundleIdentifier,
4940
+ targetPid: targetAppPid, frontmostPid: frontmostPid, pipelineGeneration: pipelineGeneration
4593
4941
  )
4594
4942
  return selectedTarget.flatMap { selected in
4595
4943
  runningApps.first { $0.processIdentifier == selected.pid }
4596
4944
  }
4597
4945
  }
4598
4946
 
4947
+ func resolvePasteTarget(candidates: [PasteTargetCandidate], targetBundleIdentifier: String?,
4948
+ targetPid: pid_t?, frontmostPid: pid_t?, pipelineGeneration: UInt64?) -> PasteTargetCandidate? {
4949
+ let frozen = pipelineGeneration.flatMap { frozenPasteTargetsByGeneration[$0] }
4950
+ let identity = pipelineGeneration.flatMap { pasteTargetProcessIdentityByGeneration[$0] }
4951
+ if frozen != nil {
4952
+ // No bundle-only or foreground fallback for an explicitly frozen capture.
4953
+ guard let identity, targetPid == identity.pid,
4954
+ targetBundleIdentifier == identity.bundleIdentifier else { return nil }
4955
+ }
4956
+ return Self.selectPasteTarget(candidates: frozen == nil ? candidates : candidates.filter(\.isRegularApp),
4957
+ currentPid: ProcessInfo.processInfo.processIdentifier, targetBundleIdentifier: targetBundleIdentifier,
4958
+ targetPid: targetPid, frontmostPid: frontmostPid, requiredProcessIdentity: identity,
4959
+ requiresProcessIdentity: frozen != nil || (pipelineGeneration != nil && targetPid != nil))
4960
+ }
4961
+
4599
4962
  nonisolated static func selectPasteTarget(
4600
4963
  candidates: [PasteTargetCandidate],
4601
4964
  currentPid: pid_t,
@@ -4754,12 +5117,15 @@ enum CLIRunner: Sendable {
4754
5117
 
4755
5118
  enum ExecutionError: Error, LocalizedError, Equatable {
4756
5119
  case timedOut(executable: String, seconds: TimeInterval)
5120
+ case deadlineExhausted
4757
5121
  case captureFailed(operation: CaptureOperation, code: Int32)
4758
5122
 
4759
5123
  var errorDescription: String? {
4760
5124
  switch self {
4761
5125
  case let .timedOut(executable, seconds):
4762
5126
  return "Command timed out after \(seconds.formatted()) seconds: \(executable)"
5127
+ case .deadlineExhausted:
5128
+ return "Command timed out: insufficient time remains to start safely."
4763
5129
  case let .captureFailed(operation, code):
4764
5130
  return "Failed to capture command output while \(operation.description): \(String(cString: strerror(code)))"
4765
5131
  }
@@ -4788,24 +5154,76 @@ enum CLIRunner: Sendable {
4788
5154
  _ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
4789
5155
  ) throws -> Int32
4790
5156
 
5157
+ /// An explicit monotonic deadline can cross queue and preparation boundaries without
5158
+ /// resetting the budget. The clock is injectable for deterministic boundary tests.
5159
+ struct WallClockDeadline: Sendable {
5160
+ private let expiresAt: UInt64
5161
+ private let now: @Sendable () -> UInt64
5162
+
5163
+ init(
5164
+ after seconds: TimeInterval,
5165
+ now: @escaping @Sendable () -> UInt64 = { DispatchTime.now().uptimeNanoseconds }
5166
+ ) {
5167
+ precondition(seconds.isFinite && seconds >= 0)
5168
+ let startedAt = now()
5169
+ let maximumDelay = min(UInt64(Int64.max), UInt64.max - startedAt)
5170
+ let requested = seconds * 1_000_000_000
5171
+ // Truncate fractional nanoseconds: rounding up could extend the configured
5172
+ // budget and reject its own deadline at a subsequent handoff validation.
5173
+ let delay = requested >= Double(maximumDelay)
5174
+ ? maximumDelay : UInt64(requested)
5175
+ expiresAt = startedAt + delay
5176
+ self.now = now
5177
+ }
5178
+
5179
+ func remaining(reserving reserve: TimeInterval = 0) -> TimeInterval {
5180
+ let current = now()
5181
+ guard expiresAt > current else { return 0 }
5182
+ return max(0, Double(expiresAt - current) / 1_000_000_000 - reserve)
5183
+ }
5184
+ }
5185
+
5186
+ private static func requireStartBudget(_ deadline: WallClockDeadline?) throws {
5187
+ if let deadline, deadline.remaining() <= wallClockCleanupReserve {
5188
+ throw ExecutionError.deadlineExhausted
5189
+ }
5190
+ }
5191
+
4791
5192
  static func run(
4792
5193
  _ args: [String],
4793
5194
  home: String,
4794
5195
  timeout: TimeInterval = 120,
4795
- totalWallClockBudget: TimeInterval? = nil
5196
+ totalWallClockBudget: TimeInterval? = nil,
5197
+ environment suppliedEnvironment: [String: String]? = nil,
5198
+ wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
5199
+ environmentProvider: (() throws -> [String: String])? = nil
4796
5200
  ) -> String {
4797
- let command = resolveCommand(home: home)
4798
- let arguments = command.argumentsPrefix + args
4799
- let environment = ProcessInfo.processInfo.environment.merging([
4800
- "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
4801
- ]) { _, new in new }
5201
+ if let totalWallClockBudget {
5202
+ precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
5203
+ }
5204
+ precondition(suppliedDeadline == nil || totalWallClockBudget != nil)
5205
+ let deadline = suppliedDeadline ?? totalWallClockBudget.map { WallClockDeadline(after: $0) }
5206
+ if let suppliedDeadline, let totalWallClockBudget {
5207
+ precondition(suppliedDeadline.remaining() <= totalWallClockBudget)
5208
+ }
4802
5209
  do {
5210
+ try requireStartBudget(deadline)
5211
+ let command = resolveCommand(home: home)
5212
+ let arguments = command.argumentsPrefix + args
5213
+ try requireStartBudget(deadline)
5214
+ let environment = try suppliedEnvironment ?? environmentProvider?() ?? ServiceAPIConfiguration.childEnvironment(
5215
+ base: OpenAIAPIKeyStore.childEnvironment(base: ProcessInfo.processInfo.environment.merging([
5216
+ "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
5217
+ ]) { _, new in new }, homePath: home)
5218
+ )
5219
+ try requireStartBudget(deadline)
4803
5220
  let output = try runExecutable(
4804
5221
  command.executable,
4805
5222
  arguments: arguments,
4806
5223
  environment: environment,
4807
5224
  executionTimeout: timeout,
4808
- totalWallClockBudget: totalWallClockBudget
5225
+ totalWallClockBudget: totalWallClockBudget,
5226
+ wallClockDeadline: deadline
4809
5227
  )
4810
5228
  if output.terminationStatus != 0 {
4811
5229
  let details = output.stderr.isEmpty ? output.stdout : output.stderr
@@ -4853,6 +5271,7 @@ enum CLIRunner: Sendable {
4853
5271
  forceKillGracePeriod: TimeInterval = 1,
4854
5272
  pipeDrainTimeout: TimeInterval = 2,
4855
5273
  totalWallClockBudget: TimeInterval? = nil,
5274
+ wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
4856
5275
  beforeExecutionDeadline: (() -> Void)? = nil,
4857
5276
  lifecycleObserver: ((ProcessLifecycleEvent) -> Void)? = nil,
4858
5277
  leaderReaper: LeaderReaper? = nil,
@@ -4866,23 +5285,24 @@ enum CLIRunner: Sendable {
4866
5285
  precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
4867
5286
  }
4868
5287
 
4869
- // The budget clock starts before the spawn so setup latency cannot extend the
4870
- // observable wall time. Every wait below is clamped to what is left of it.
4871
- let wallClockDeadline = totalWallClockBudget.map { monotonicUptimeDeadline(after: $0) }
5288
+ precondition(suppliedDeadline == nil || totalWallClockBudget != nil)
5289
+ if let suppliedDeadline, let totalWallClockBudget {
5290
+ precondition(suppliedDeadline.remaining() <= totalWallClockBudget)
5291
+ }
5292
+ // A caller that prepared the command supplies its original deadline. Direct
5293
+ // executable callers begin here; neither path resets a previously spent budget.
5294
+ let wallClockDeadline = suppliedDeadline ?? totalWallClockBudget.map { WallClockDeadline(after: $0) }
4872
5295
  func clampedToWallClockBudget(
4873
5296
  _ phaseTimeout: TimeInterval,
4874
5297
  reserving reserve: TimeInterval = 0
4875
5298
  ) -> TimeInterval {
4876
5299
  guard let wallClockDeadline else { return phaseTimeout }
4877
- let now = DispatchTime.now().uptimeNanoseconds
4878
- let remaining = wallClockDeadline > now
4879
- ? Double(wallClockDeadline - now) / 1_000_000_000 - reserve
4880
- : 0
4881
- return max(0, min(phaseTimeout, remaining))
5300
+ return min(phaseTimeout, wallClockDeadline.remaining(reserving: reserve))
4882
5301
  }
4883
5302
  let contractualExecutionTimeout = totalWallClockBudget
4884
5303
  .map { min(executionTimeout, $0 - wallClockCleanupReserve) } ?? executionTimeout
4885
5304
 
5305
+ try requireStartBudget(wallClockDeadline)
4886
5306
  let stdoutReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
4887
5307
  let stderrReader: PipeCaptureReader
4888
5308
  do {
@@ -4896,6 +5316,7 @@ enum CLIRunner: Sendable {
4896
5316
 
4897
5317
  let processIdentifier: pid_t
4898
5318
  do {
5319
+ try requireStartBudget(wallClockDeadline)
4899
5320
  processIdentifier = try spawnProcessGroup(
4900
5321
  executable,
4901
5322
  arguments: arguments,
@@ -5075,6 +5496,30 @@ enum CLIRunner: Sendable {
5075
5496
  )
5076
5497
  }
5077
5498
 
5499
+ // CLOEXEC_DEFAULT also closes stdin unless it is explicitly inherited. Keep
5500
+ // the existing stdin behavior, including an already closed/CLOEXEC stream;
5501
+ // a capture pipe that reused fd 0 must still be closed by the actions above.
5502
+ if !inheritedDescriptors.contains(STDIN_FILENO) {
5503
+ var inputFlags: Int32
5504
+ repeat {
5505
+ inputFlags = Darwin.fcntl(STDIN_FILENO, F_GETFD)
5506
+ } while inputFlags == -1 && errno == EINTR
5507
+ let inputError = errno
5508
+ if inputFlags == -1 && inputError != EBADF {
5509
+ throw NSError(
5510
+ domain: NSPOSIXErrorDomain,
5511
+ code: Int(inputError),
5512
+ userInfo: [NSLocalizedDescriptionKey: "Failed to inspect command standard input"]
5513
+ )
5514
+ }
5515
+ if inputFlags >= 0 && inputFlags & FD_CLOEXEC == 0 {
5516
+ try checkPOSIX(
5517
+ posix_spawn_file_actions_addinherit_np(&fileActions, STDIN_FILENO),
5518
+ operation: "inherit command standard input"
5519
+ )
5520
+ }
5521
+ }
5522
+
5078
5523
  var attributes: posix_spawnattr_t?
5079
5524
  try checkPOSIX(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes")
5080
5525
  defer { posix_spawnattr_destroy(&attributes) }
@@ -5091,7 +5536,10 @@ enum CLIRunner: Sendable {
5091
5536
  posix_spawnattr_setsigmask(&attributes, &unblockedSignals),
5092
5537
  operation: "unblock command signals"
5093
5538
  )
5094
- let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK
5539
+ // A different reader may still be between pipe() and FD_CLOEXEC setup.
5540
+ // Inherit only our explicit standard streams, never that unrelated pipe.
5541
+ let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF
5542
+ | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT
5095
5543
  try checkPOSIX(
5096
5544
  posix_spawnattr_setflags(&attributes, Int16(spawnFlags)),
5097
5545
  operation: "configure command process group"
@@ -5611,7 +6059,7 @@ enum CLIRunner: Sendable {
5611
6059
  }
5612
6060
  }
5613
6061
 
5614
- static func parseError(_ output: String) -> String? {
6062
+ static func parseError(_ output: String, serviceAPI: Bool = false) -> String? {
5615
6063
  let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
5616
6064
  guard trimmed.hasPrefix("ERROR:") else { return nil }
5617
6065
  let message = NativeErrorSanitizer.sanitize(
@@ -5620,10 +6068,12 @@ enum CLIRunner: Sendable {
5620
6068
  let lowercased = message.lowercased()
5621
6069
  if lowercased.contains("401") || lowercased.contains("incorrect api key")
5622
6070
  || lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
6071
+ if serviceAPI { return "Recordings API authentication failed — check the API connection in Settings" }
5623
6072
  return "OpenAI API key invalid or expired — update it in Recordings Settings"
5624
6073
  }
5625
6074
  if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
5626
6075
  || lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
6076
+ if serviceAPI { return "Recordings API request limit reached — try again later" }
5627
6077
  return "OpenAI quota exceeded — check the OpenAI account billing"
5628
6078
  }
5629
6079
  if message.contains("OpenAI API key not configured") {