@hasna/recordings 0.2.11 → 0.2.13

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 (167) hide show
  1. package/README.md +349 -69
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1919 -98
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1973 -236
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -1,47 +1,494 @@
1
1
  import AVFoundation
2
2
  @preconcurrency import ApplicationServices
3
+ import Darwin
3
4
  import SwiftUI
4
5
  @preconcurrency import KeyboardShortcuts
5
6
 
6
7
  // MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
7
8
 
8
9
  extension KeyboardShortcuts.Name {
9
- @MainActor static let toggleRecording = Self("toggleRecording", default: .init(.f5))
10
+ @MainActor public static let toggleRecording = Self("toggleRecording", default: .init(.f5))
10
11
  }
11
12
 
12
- // MARK: - Recording Mode
13
+ // MARK: - Transcription Result
14
+
15
+ public struct TranscriptionResult: Identifiable, Sendable {
16
+ public let id = UUID()
17
+ let rawText: String
18
+ let processedText: String?
19
+ let timestamp: Date
20
+ let projectId: String?
21
+ let projectName: String?
22
+ public var displayText: String { processedText ?? rawText }
23
+
24
+ init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?) {
25
+ self.rawText = rawText
26
+ self.processedText = processedText
27
+ self.timestamp = timestamp
28
+ self.projectId = projectId
29
+ self.projectName = projectName
30
+ }
31
+ }
32
+
33
+ struct RealtimeFastPathSaveResult: Sendable {
34
+ let text: String?
35
+ let error: String?
36
+ }
37
+
38
+ enum FallbackCompletionAction: Equatable, Sendable {
39
+ case deliver(String)
40
+ case fail(String)
41
+ case backgroundRecovered
42
+ case backgroundFailed(String)
43
+ }
44
+
45
+ struct RecordingProcessingConfiguration: Equatable, Sendable {
46
+ let transcriptionPrompt: String
47
+ let transcriberPrompt: String
48
+ let postProcessingMode: String
49
+ let transcriptionLanguage: String
50
+ let transcriptionModel: String
51
+ let transcriberModel: String
52
+ let enhancementModel: String
53
+ let intentModel: String
54
+ let intentDetectionEnabled: Bool
55
+ let enhanceTriggersJSON: String
56
+ let keywordTransformsJSON: String
57
+ }
58
+
59
+ /// Accessibility state frozen near recording start. Captured on a detached task so the
60
+ /// recorder never waits on Accessibility IPC to a possibly-unresponsive target app.
61
+ struct RecordingStartAXSnapshot: Sendable {
62
+ let selectionToken: AccessibilitySelectionToken?
63
+ let focusedWindowTitle: String?
64
+ }
65
+
66
+ /// Everything about the recording that is only known once the start-time Accessibility
67
+ /// snapshot and project auto-selection have resolved. Bound to one recording generation via
68
+ /// the task stored in `RecordingCaptureConfiguration`.
69
+ struct RecordingStartResolvedContext: Sendable {
70
+ let selectionToken: AccessibilitySelectionToken?
71
+ let canonicalProjectId: String?
72
+ let displayProjectId: String?
73
+ let activeProjectName: String?
74
+ let processing: RecordingProcessingConfiguration
75
+ }
76
+
77
+ struct RecordingCaptureConfiguration: Sendable {
78
+ let targetAppBundleIdentifier: String?
79
+ let targetAppPid: pid_t?
80
+ /// Resolves the frozen selection token, project binding, and processing configuration.
81
+ /// Started at recording start and awaited only after the recorder has stopped, so
82
+ /// capture latency can never delay microphone start.
83
+ let startContext: Task<RecordingStartResolvedContext, Never>
84
+ }
85
+
86
+ /// The frontmost-application identity `startRecording` freezes. Abstracted from
87
+ /// `NSWorkspace` so production-path tests can drive recording starts headlessly.
88
+ struct FrontmostAppSnapshot: Equatable, Sendable {
89
+ let pid: pid_t
90
+ let bundleIdentifier: String?
91
+ let launchDate: Date?
92
+ }
93
+
94
+ /// The one capability `RecordingEngine` needs from an audio recorder; lets tests run the
95
+ /// production start path without microphone hardware or TCC grants.
96
+ protocol PCMRecordingSource: AnyObject {
97
+ func start() throws
98
+ func stop()
99
+ }
100
+
101
+ extension NativePCMRecorder: PCMRecordingSource {}
102
+
103
+ struct AccessibilitySelectionIdentity<Element: Equatable & Sendable>: Equatable, Sendable {
104
+ let element: Element
105
+ let window: Element
106
+ let documentIdentifier: String
107
+ let rangeLocation: Int
108
+ let rangeLength: Int
109
+ let selectedText: String
110
+
111
+ func matches(
112
+ element currentElement: Element,
113
+ window currentWindow: Element,
114
+ documentIdentifier currentDocumentIdentifier: String,
115
+ rangeLocation currentRangeLocation: Int,
116
+ rangeLength currentRangeLength: Int,
117
+ selectedText currentSelectedText: String
118
+ ) -> Bool {
119
+ element == currentElement
120
+ && window == currentWindow
121
+ && documentIdentifier == currentDocumentIdentifier
122
+ && rangeLocation == currentRangeLocation
123
+ && rangeLength == currentRangeLength
124
+ && selectedText == currentSelectedText
125
+ }
126
+ }
127
+
128
+ private struct AXElementIdentity: Equatable, @unchecked Sendable {
129
+ let element: AXUIElement
13
130
 
14
- public enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
15
- case pushToTalk = "Push to Talk"
16
- case dictation = "Dictation"
17
- case command = "Command"
131
+ static func == (lhs: Self, rhs: Self) -> Bool {
132
+ let element = lhs.element
133
+ let currentElement = rhs.element
134
+ return CFEqual(element, currentElement)
135
+ }
136
+ }
137
+
138
+ /// Captured off the MainActor (Accessibility calls are Mach IPC and thread-safe); the token
139
+ /// itself is immutable after capture, so later MainActor revalidation reads are safe.
140
+ final class AccessibilitySelectionToken: @unchecked Sendable {
141
+ private let identity: AccessibilitySelectionIdentity<AXElementIdentity>
142
+
143
+ var selectedText: String { identity.selectedText }
144
+
145
+ private init(
146
+ element: AXUIElement,
147
+ window: AXUIElement,
148
+ documentIdentifier: String,
149
+ range: CFRange,
150
+ selectedText: String
151
+ ) {
152
+ identity = AccessibilitySelectionIdentity(
153
+ element: AXElementIdentity(element: element),
154
+ window: AXElementIdentity(element: window),
155
+ documentIdentifier: documentIdentifier,
156
+ rangeLocation: range.location,
157
+ rangeLength: range.length,
158
+ selectedText: selectedText
159
+ )
160
+ }
161
+
162
+ /// Cap for each Accessibility IPC round trip during capture. Capture runs off the
163
+ /// recorder-start path, but revalidation still happens synchronously before a paste or
164
+ /// rewrite, so a beachballing target app must never stall behind the multi-second
165
+ /// system default.
166
+ static let captureMessagingTimeout: Float = 0.25
167
+
168
+ #if DEBUG
169
+ /// Test-only token whose AX elements point at this process; revalidation against a real
170
+ /// target fails closed, which is exactly what delivery tests need to observe.
171
+ static func unsafeTestToken(selectedText: String) -> AccessibilitySelectionToken {
172
+ let element = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier)
173
+ return AccessibilitySelectionToken(
174
+ element: element,
175
+ window: element,
176
+ documentIdentifier: "document:test",
177
+ range: CFRange(location: 0, length: (selectedText as NSString).length),
178
+ selectedText: selectedText
179
+ )
180
+ }
181
+ #endif
182
+
183
+ static func capture(for pid: pid_t) -> AccessibilitySelectionToken? {
184
+ let application = AXUIElementCreateApplication(pid)
185
+ AXUIElementSetMessagingTimeout(application, captureMessagingTimeout)
186
+ var focusedElementRef: CFTypeRef?
187
+ guard AXUIElementCopyAttributeValue(
188
+ application,
189
+ kAXFocusedUIElementAttribute as CFString,
190
+ &focusedElementRef
191
+ ) == .success,
192
+ let focusedElementRef,
193
+ CFGetTypeID(focusedElementRef) == AXUIElementGetTypeID() else { return nil }
194
+ let focusedElement = focusedElementRef as! AXUIElement
195
+ AXUIElementSetMessagingTimeout(focusedElement, captureMessagingTimeout)
196
+
197
+ var focusedWindowRef: CFTypeRef?
198
+ guard AXUIElementCopyAttributeValue(
199
+ application,
200
+ kAXFocusedWindowAttribute as CFString,
201
+ &focusedWindowRef
202
+ ) == .success,
203
+ let focusedWindowRef,
204
+ CFGetTypeID(focusedWindowRef) == AXUIElementGetTypeID() else { return nil }
205
+ let focusedWindow = focusedWindowRef as! AXUIElement
206
+ AXUIElementSetMessagingTimeout(focusedWindow, captureMessagingTimeout)
207
+
208
+ let documentIdentifier = stringAttribute(
209
+ kAXDocumentAttribute as CFString,
210
+ on: focusedElement
211
+ ) ?? stringAttribute(
212
+ kAXDocumentAttribute as CFString,
213
+ on: focusedWindow
214
+ )
215
+ guard let contextIdentifier = RecordingEngine.stableAccessibilityContextIdentifier(
216
+ documentIdentifier: documentIdentifier,
217
+ elementIdentifier: stringAttribute(kAXIdentifierAttribute as CFString, on: focusedElement)
218
+ ) else { return nil }
219
+
220
+ guard let selectedRange = selectedRange(for: focusedElement),
221
+ let selectedText = selectedText(for: focusedElement, range: selectedRange) else {
222
+ return nil
223
+ }
224
+ return AccessibilitySelectionToken(
225
+ element: focusedElement,
226
+ window: focusedWindow,
227
+ documentIdentifier: contextIdentifier,
228
+ range: selectedRange,
229
+ selectedText: selectedText
230
+ )
231
+ }
232
+
233
+ func matchesCurrentSelection(for pid: pid_t) -> Bool {
234
+ guard let current = Self.capture(for: pid) else { return false }
235
+ return identity.matches(
236
+ element: current.identity.element,
237
+ window: current.identity.window,
238
+ documentIdentifier: current.identity.documentIdentifier,
239
+ rangeLocation: current.identity.rangeLocation,
240
+ rangeLength: current.identity.rangeLength,
241
+ selectedText: current.identity.selectedText
242
+ )
243
+ }
18
244
 
19
- public var id: String { rawValue }
20
- public var icon: String {
21
- switch self {
22
- case .pushToTalk: return "hand.tap.fill"
23
- case .dictation: return "text.bubble.fill"
24
- case .command: return "wand.and.stars"
245
+ private static func stringAttribute(_ attribute: CFString, on element: AXUIElement) -> String? {
246
+ var valueRef: CFTypeRef?
247
+ guard AXUIElementCopyAttributeValue(element, attribute, &valueRef) == .success else {
248
+ return nil
25
249
  }
250
+ return valueRef as? String
251
+ }
252
+
253
+ private static func selectedRange(for element: AXUIElement) -> CFRange? {
254
+ var rangeRef: CFTypeRef?
255
+ guard AXUIElementCopyAttributeValue(
256
+ element,
257
+ kAXSelectedTextRangeAttribute as CFString,
258
+ &rangeRef
259
+ ) == .success,
260
+ let rangeRef,
261
+ CFGetTypeID(rangeRef) == AXValueGetTypeID() else { return nil }
262
+
263
+ let rangeValue = rangeRef as! AXValue
264
+ var selectedRange = CFRange()
265
+ guard AXValueGetType(rangeValue) == .cfRange,
266
+ AXValueGetValue(rangeValue, .cfRange, &selectedRange),
267
+ selectedRange.location >= 0,
268
+ selectedRange.length > 0 else { return nil }
269
+ return selectedRange
26
270
  }
27
- public var hint: String {
28
- switch self {
29
- case .pushToTalk: return "Hold your recording shortcut, then release to paste"
30
- case .dictation: return "Hold your recording shortcut to dictate, then release to paste"
31
- case .command: return "Select text, hold your recording shortcut, then release to rewrite"
271
+
272
+ private static func selectedText(for element: AXUIElement, range: CFRange) -> String? {
273
+ var selectedTextRef: CFTypeRef?
274
+ if AXUIElementCopyAttributeValue(
275
+ element,
276
+ kAXSelectedTextAttribute as CFString,
277
+ &selectedTextRef
278
+ ) == .success,
279
+ let selectedText = selectedTextRef as? String {
280
+ return (selectedText as NSString).length == range.length ? selectedText : nil
32
281
  }
282
+
283
+ var valueRef: CFTypeRef?
284
+ guard AXUIElementCopyAttributeValue(
285
+ element,
286
+ kAXValueAttribute as CFString,
287
+ &valueRef
288
+ ) == .success,
289
+ let value = valueRef as? String else { return nil }
290
+ let valueLength = (value as NSString).length
291
+ guard range.location <= valueLength,
292
+ range.length <= valueLength - range.location else { return nil }
293
+ return (value as NSString).substring(
294
+ with: NSRange(location: range.location, length: range.length)
295
+ )
33
296
  }
34
297
  }
35
298
 
36
- // MARK: - Transcription Result
299
+ struct PasteDeliveryTransaction: Equatable, Sendable {
300
+ let id: UUID
301
+ let text: String
302
+ let generation: UInt64?
303
+ }
37
304
 
38
- public struct TranscriptionResult: Sendable {
39
- let rawText: String
40
- let processedText: String?
41
- let timestamp: Date
42
- let projectId: String?
43
- let projectName: String?
44
- var displayText: String { processedText ?? rawText }
305
+ enum PasteDeliveryOutcome: Equatable, Sendable {
306
+ case pasted
307
+ case targetUnavailable
308
+ case clipboardOwnershipLost
309
+ case clipboardWriteFailed
310
+ case eventPostFailed
311
+ }
312
+
313
+ struct PasteboardWriteResult: Equatable, Sendable {
314
+ let verified: Bool
315
+ let ownershipChangeCount: Int
316
+ }
317
+
318
+ /// Outcome of revalidating the frozen rewrite target immediately before a rewrite runs.
319
+ /// Anything but a live, matching selection fails the rewrite closed.
320
+ enum RewriteTargetResolution: Equatable, Sendable {
321
+ case selection(String)
322
+ case targetAppMissing
323
+ case selectionUnavailable
324
+ }
325
+
326
+ @MainActor
327
+ final class PasteTransactionCoordinator {
328
+ private enum State: Equatable {
329
+ case idle
330
+ case scheduled(UUID)
331
+ case settling(UUID)
332
+ }
333
+
334
+ typealias ScheduledOperation = @MainActor @Sendable () -> Void
335
+ typealias Scheduler = @MainActor @Sendable (TimeInterval, @escaping ScheduledOperation) -> Void
336
+ typealias PayloadWriter = @MainActor @Sendable (String) -> PasteboardWriteResult
337
+ typealias PastePoster = @MainActor @Sendable () -> Bool
338
+ typealias WriteObserver = @MainActor @Sendable (PasteboardWriteResult) -> Void
339
+ typealias Completion = @MainActor @Sendable (PasteDeliveryTransaction, PasteDeliveryOutcome) -> Void
340
+ typealias Settlement = @MainActor @Sendable (PasteDeliveryTransaction, PasteDeliveryOutcome) -> Void
341
+
342
+ private let schedule: Scheduler
343
+ private let writeAndVerify: PayloadWriter
344
+ private let postPaste: PastePoster
345
+ /// Fires immediately before `hasPendingTransaction` changes value. The settlement hop
346
+ /// back to idle runs on its own scheduled turn with no other state write, so an owner
347
+ /// deriving gates from this coordinator (e.g. `canStartRecording`) must publish here or
348
+ /// its observers never recompute after settlement.
349
+ var pendingTransactionWillChange: (@MainActor () -> Void)?
350
+ private var state: State = .idle {
351
+ willSet {
352
+ if (newValue == .idle) != (state == .idle) {
353
+ pendingTransactionWillChange?()
354
+ }
355
+ }
356
+ }
357
+
358
+ init(
359
+ schedule: @escaping Scheduler,
360
+ writeAndVerify: @escaping PayloadWriter,
361
+ postPaste: @escaping PastePoster
362
+ ) {
363
+ self.schedule = schedule
364
+ self.writeAndVerify = writeAndVerify
365
+ self.postPaste = postPaste
366
+ }
367
+
368
+ var hasPendingTransaction: Bool {
369
+ state != .idle
370
+ }
371
+
372
+ @discardableResult
373
+ func submit(
374
+ text: String,
375
+ generation: UInt64?,
376
+ delay: TimeInterval,
377
+ settlementDelay: TimeInterval = 0,
378
+ targetIsReady: @escaping @MainActor @Sendable () -> Bool = { true },
379
+ payloadIsReady: @escaping @MainActor @Sendable () -> Bool = { true },
380
+ prepare: @escaping ScheduledOperation = {},
381
+ writeAttempted: @escaping WriteObserver = { _ in },
382
+ completion: @escaping Completion,
383
+ settlement: @escaping Settlement = { _, _ in }
384
+ ) -> Bool {
385
+ guard state == .idle else { return false }
386
+ let transaction = PasteDeliveryTransaction(id: UUID(), text: text, generation: generation)
387
+ state = .scheduled(transaction.id)
388
+ schedule(delay) { [weak self] in
389
+ guard let self, self.state == .scheduled(transaction.id) else { return }
390
+ self.state = .settling(transaction.id)
391
+ guard targetIsReady() else {
392
+ settlement(transaction, .targetUnavailable)
393
+ self.state = .idle
394
+ completion(transaction, .targetUnavailable)
395
+ return
396
+ }
397
+ prepare()
398
+ guard targetIsReady() else {
399
+ settlement(transaction, .targetUnavailable)
400
+ self.state = .idle
401
+ completion(transaction, .targetUnavailable)
402
+ return
403
+ }
404
+ let writeResult = self.writeAndVerify(transaction.text)
405
+ writeAttempted(writeResult)
406
+ guard writeResult.verified else {
407
+ settlement(transaction, .clipboardWriteFailed)
408
+ self.state = .idle
409
+ completion(transaction, .clipboardWriteFailed)
410
+ return
411
+ }
412
+ guard targetIsReady() else {
413
+ settlement(transaction, .targetUnavailable)
414
+ self.state = .idle
415
+ completion(transaction, .targetUnavailable)
416
+ return
417
+ }
418
+ guard payloadIsReady() else {
419
+ settlement(transaction, .clipboardOwnershipLost)
420
+ self.state = .idle
421
+ completion(transaction, .clipboardOwnershipLost)
422
+ return
423
+ }
424
+ guard self.postPaste() else {
425
+ settlement(transaction, .eventPostFailed)
426
+ self.state = .idle
427
+ completion(transaction, .eventPostFailed)
428
+ return
429
+ }
430
+ completion(transaction, .pasted)
431
+ guard settlementDelay > 0 else {
432
+ settlement(transaction, .pasted)
433
+ self.state = .idle
434
+ return
435
+ }
436
+ self.schedule(settlementDelay) { [weak self] in
437
+ guard let self, self.state == .settling(transaction.id) else { return }
438
+ settlement(transaction, .pasted)
439
+ self.state = .idle
440
+ }
441
+ }
442
+ return true
443
+ }
444
+ }
445
+
446
+ struct PipelineDeliveryGate: Sendable {
447
+ private var pendingGenerations = Set<UInt64>()
448
+ private var highestClaimedGeneration: UInt64?
449
+
450
+ mutating func registerPipeline(_ generation: UInt64) {
451
+ pendingGenerations.insert(generation)
452
+ }
453
+
454
+ mutating func abandonPipeline(_ generation: UInt64) {
455
+ pendingGenerations.remove(generation)
456
+ }
457
+
458
+ mutating func claimDelivery(for generation: UInt64) -> Bool {
459
+ if pendingGenerations.remove(generation) != nil {
460
+ highestClaimedGeneration = max(highestClaimedGeneration ?? generation, generation)
461
+ return true
462
+ }
463
+ if let highestClaimedGeneration, generation <= highestClaimedGeneration {
464
+ return false
465
+ }
466
+ highestClaimedGeneration = generation
467
+ return true
468
+ }
469
+
470
+ func shouldApplyStatus(
471
+ deliveryGeneration: UInt64,
472
+ currentGeneration: UInt64,
473
+ isRecording: Bool,
474
+ isTranscribing: Bool
475
+ ) -> Bool {
476
+ deliveryGeneration == currentGeneration && !isRecording && !isTranscribing
477
+ }
478
+ }
479
+
480
+ struct RecordingPipelineTrace: Sendable {
481
+ let id = UUID().uuidString
482
+ let startedUptimeMilliseconds = UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
483
+
484
+ func message(stage: String, detail: String = "") -> String {
485
+ let nowMilliseconds = UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
486
+ let elapsedMilliseconds = nowMilliseconds >= startedUptimeMilliseconds
487
+ ? nowMilliseconds - startedUptimeMilliseconds
488
+ : 0
489
+ let suffix = detail.isEmpty ? "" : " \(detail)"
490
+ return "pipeline_timing pipeline_id=\(id) stage=\(stage) elapsed_ms=\(elapsedMilliseconds)\(suffix)"
491
+ }
45
492
  }
46
493
 
47
494
  public enum RecordingTrigger: Equatable, Sendable {
@@ -50,10 +497,65 @@ public enum RecordingTrigger: Equatable, Sendable {
50
497
  case keyboardShortcut
51
498
  }
52
499
 
500
+ struct MicrophonePermissionStartGate {
501
+ private(set) var activeRequestID: UUID?
502
+
503
+ var isAwaitingResponse: Bool {
504
+ activeRequestID != nil
505
+ }
506
+
507
+ mutating func reserve(requestID: UUID = UUID()) -> UUID? {
508
+ guard activeRequestID == nil else { return nil }
509
+ activeRequestID = requestID
510
+ return requestID
511
+ }
512
+
513
+ mutating func consumeResponse(for requestID: UUID) -> Bool {
514
+ guard activeRequestID == requestID else { return false }
515
+ activeRequestID = nil
516
+ return true
517
+ }
518
+
519
+ mutating func cancel() {
520
+ activeRequestID = nil
521
+ }
522
+ }
523
+
53
524
  struct PasteTargetCandidate: Equatable, Sendable {
54
525
  let pid: pid_t
55
526
  let bundleIdentifier: String?
56
527
  let isRegularApp: Bool
528
+ let launchDate: Date?
529
+
530
+ init(
531
+ pid: pid_t,
532
+ bundleIdentifier: String?,
533
+ isRegularApp: Bool,
534
+ launchDate: Date? = nil
535
+ ) {
536
+ self.pid = pid
537
+ self.bundleIdentifier = bundleIdentifier
538
+ self.isRegularApp = isRegularApp
539
+ self.launchDate = launchDate
540
+ }
541
+ }
542
+
543
+ struct PasteTargetProcessIdentity: Equatable, Sendable {
544
+ let pid: pid_t
545
+ let bundleIdentifier: String
546
+ let launchDate: Date
547
+
548
+ func matches(_ candidate: PasteTargetCandidate) -> Bool {
549
+ candidate.pid == pid
550
+ && candidate.bundleIdentifier == bundleIdentifier
551
+ && candidate.launchDate == launchDate
552
+ }
553
+ }
554
+
555
+ enum PasteDeliveryKind: Equatable, Sendable {
556
+ case ordinaryDictation
557
+ case commandRewrite
558
+ case manualPaste
57
559
  }
58
560
 
59
561
  private final class PCMStreamPipe: @unchecked Sendable {
@@ -114,12 +616,6 @@ private extension Data {
114
616
  @MainActor
115
617
  public final class RecordingEngine: ObservableObject {
116
618
  @Published public var isRecording = false
117
- @Published public var mode: RecordingMode = .pushToTalk {
118
- didSet {
119
- UserDefaults.standard.set(mode.rawValue, forKey: "recordingMode")
120
- updateStatus()
121
- }
122
- }
123
619
  @Published public var useFnKey: Bool = false {
124
620
  didSet {
125
621
  UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
@@ -127,8 +623,24 @@ public final class RecordingEngine: ObservableObject {
127
623
  updateStatus()
128
624
  }
129
625
  }
130
- @Published public var isWhisperMode = false
626
+ /// Advanced fallback policy (Settings only): when off, every recording is dictated
627
+ /// literally and the classifier is never consulted.
628
+ @Published public var intentDetectionEnabled: Bool = true {
629
+ didSet {
630
+ UserDefaults.standard.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
631
+ }
632
+ }
633
+ /// Typed Record-page state; views render idle/listening/finalizing/processing/ready/error
634
+ /// from this instead of parsing `statusMessage`.
635
+ @Published public private(set) var flowPhase: RecordingFlowPhase = .idle
636
+ /// Latest conversational answer. Cleared whenever a new recording starts so a stale reply
637
+ /// can never be attributed to a later recording.
638
+ @Published public private(set) var conversationReply: ConversationReply?
131
639
  @Published public var recentTranscriptions: [TranscriptionResult] = []
640
+ /// Advances only after the CLI confirms that a recording has been persisted. The app
641
+ /// store observes this independently of the Record pane so asynchronous saves and
642
+ /// background recovery refresh the Library even after that pane has been unmounted.
643
+ @Published public private(set) var persistedRecordingRevision: UInt64 = 0
132
644
  @Published public var statusMessage = "Starting..."
133
645
  @Published public var isTranscribing = false
134
646
  @Published public var recordingDuration: TimeInterval = 0
@@ -140,16 +652,80 @@ public final class RecordingEngine: ObservableObject {
140
652
  }
141
653
  }
142
654
 
143
- private var nativeRecorder: NativePCMRecorder?
655
+ private var nativeRecorder: PCMRecordingSource?
144
656
  private var recordingTimer: Timer?
145
657
  private var activeTrigger: RecordingTrigger?
658
+ private var microphonePermissionStartGate = MicrophonePermissionStartGate()
146
659
  private var keyboardShortcutIsDown = false
147
660
  private var fnKeyIsDown = false
148
661
  private var targetAppBundleIdentifier: String?
149
662
  private var targetAppPid: pid_t?
663
+ private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
150
664
  public var projectStore: ProjectStore?
151
665
  public var voiceShortcuts: VoiceShortcuts?
152
666
 
667
+ // MARK: - Injectable boundaries
668
+ // Production defaults perform the real I/O; tests replace them to drive the production
669
+ // start/delivery paths without microphone, Accessibility, network, or CLI access.
670
+ var microphoneAuthorization: () -> AVAuthorizationStatus = {
671
+ AVCaptureDevice.authorizationStatus(for: .audio)
672
+ }
673
+ var accessibilityTrustCheck: @Sendable () -> Bool = { AXIsProcessTrusted() }
674
+ lazy var protectedOperationTrust: () -> AccessibilityTrustResult = { [accessibilityPromptGate] in
675
+ accessibilityPromptGate.trustForProtectedOperation()
676
+ }
677
+ var frontmostAppSnapshot: () -> FrontmostAppSnapshot? = {
678
+ guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
679
+ return FrontmostAppSnapshot(
680
+ pid: app.processIdentifier,
681
+ bundleIdentifier: app.bundleIdentifier,
682
+ launchDate: app.launchDate
683
+ )
684
+ }
685
+ var recorderFactory: (@escaping @Sendable (Data) -> Void) -> PCMRecordingSource = {
686
+ NativePCMRecorder(onPCM: $0)
687
+ }
688
+ var selectionCapture: @Sendable (pid_t) -> AccessibilitySelectionToken? = {
689
+ AccessibilitySelectionToken.capture(for: $0)
690
+ }
691
+ var focusedWindowTitleLookup: @Sendable (pid_t) -> String? = {
692
+ RecordingEngine.focusedWindowTitle(pid: $0)
693
+ }
694
+ var commandCLI: @Sendable (_ args: [String], _ home: String, _ timeout: TimeInterval) -> String = { args, home, ceiling in
695
+ // The caller's timeout is the public ceiling on *observable* wall time. CLIRunner's
696
+ // total deadline (execution, termination grace, kill grace, pipe drain) sits a full
697
+ // return margin below it: spawn setup, waitid poll granularity, capture shutdown,
698
+ // and the hop back to the caller all run outside CLIRunner's clamped waits and must
699
+ // fit inside the reserved margin.
700
+ let cliDeadline = ceiling - RecordingEngine.commandRewriteReturnMargin
701
+ return CLIRunner.run(args, home: home, timeout: cliDeadline, totalWallClockBudget: cliDeadline)
702
+ }
703
+ /// Resolves and revalidates the frozen rewrite target immediately before a rewrite:
704
+ /// re-finds the recorded target app, activates it, waits for focus to settle, and
705
+ /// re-reads the frozen Accessibility selection. Production performs real NSWorkspace/AX
706
+ /// I/O; tests replace it to drive the rewrite pipeline (Rewriting busy state, CLI
707
+ /// budget, cancellation, staleness) headless.
708
+ lazy var rewriteSelectionResolver: @MainActor (
709
+ _ targetAppBundleIdentifier: String?,
710
+ _ targetAppPid: pid_t?,
711
+ _ selectionToken: AccessibilitySelectionToken?,
712
+ _ pipelineGeneration: UInt64?
713
+ ) async -> RewriteTargetResolution = { [weak self] bundleIdentifier, pid, token, generation in
714
+ guard let self else { return .targetAppMissing }
715
+ return await self.resolveRewriteSelection(
716
+ targetAppBundleIdentifier: bundleIdentifier,
717
+ targetAppPid: pid,
718
+ selectionToken: token,
719
+ pipelineGeneration: generation
720
+ )
721
+ }
722
+ lazy var openAIAPIKeyProvider: () -> String = { [home] in
723
+ OpenAIAPIKeyStore.load(homePath: home)
724
+ }
725
+ /// Test-only delivery tap. When set, a routed paste stops at this boundary — everything
726
+ /// up to it (routing, payload selection, generation guards) is the production path.
727
+ var pasteInterceptorForTesting: (@MainActor (_ text: String, _ deliveryKind: PasteDeliveryKind, _ pipelineGeneration: UInt64?) -> Void)?
728
+
153
729
  // Real-time streaming
154
730
  private var realtimeClient: RealtimeTranscriptionClient?
155
731
  private var streamingTask: Task<Void, Never>?
@@ -157,10 +733,98 @@ public final class RecordingEngine: ObservableObject {
157
733
  private var streamingText = ""
158
734
  private var recordedPCM = Data()
159
735
  private var activeAudioPath: String?
160
- private var lastAccessibilityPromptAt: Date?
736
+ private let accessibilityPromptGate = AccessibilityPromptGate.processShared
737
+ private(set) var recordingGeneration: UInt64 = 0
738
+ private var activeCaptureConfiguration: RecordingCaptureConfiguration?
739
+ var pipelineDeliveryGate = PipelineDeliveryGate()
740
+ /// Generation whose intent delivery (Deciding/Answering/Rewriting) is in flight, or nil.
741
+ /// Scoped to the generation so a stale completion can never clear a newer pending state.
742
+ private var intentDeliveryPendingGeneration: UInt64?
743
+ lazy var intentClassifier = SpeechIntentClassifier(
744
+ apiKeyProvider: { [home] in OpenAIAPIKeyStore.load(homePath: home) }
745
+ )
746
+ private lazy var pasteTransactionCoordinator = makePasteTransactionCoordinator(
747
+ schedule: { delay, operation in
748
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
749
+ MainActor.assumeIsolated { operation() }
750
+ }
751
+ },
752
+ writeAndVerify: { text in
753
+ let pasteboard = NSPasteboard.general
754
+ return RecordingEngine.writeClipboardAttempt(text, to: pasteboard)
755
+ },
756
+ postPaste: {
757
+ let source = CGEventSource(stateID: .hidSystemState)
758
+ guard let down = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true),
759
+ let up = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) else {
760
+ return false
761
+ }
762
+ down.flags = .maskCommand
763
+ up.flags = .maskCommand
764
+ down.post(tap: .cgSessionEventTap)
765
+ up.post(tap: .cgSessionEventTap)
766
+ return true
767
+ }
768
+ )
769
+
770
+ /// Every coordinator the engine owns must publish its idle transitions:
771
+ /// `canStartRecording` derives from coordinator state, and settlement back to idle is
772
+ /// otherwise invisible to observers — the menu bar would stay busy with Start disabled
773
+ /// until some unrelated published change.
774
+ private func makePasteTransactionCoordinator(
775
+ schedule: @escaping PasteTransactionCoordinator.Scheduler,
776
+ writeAndVerify: @escaping PasteTransactionCoordinator.PayloadWriter,
777
+ postPaste: @escaping PasteTransactionCoordinator.PastePoster
778
+ ) -> PasteTransactionCoordinator {
779
+ let coordinator = PasteTransactionCoordinator(
780
+ schedule: schedule,
781
+ writeAndVerify: writeAndVerify,
782
+ postPaste: postPaste
783
+ )
784
+ coordinator.pendingTransactionWillChange = { [weak self] in
785
+ self?.objectWillChange.send()
786
+ }
787
+ return coordinator
788
+ }
789
+
790
+ #if DEBUG
791
+ /// Test-only: swaps in a coordinator with injected I/O while keeping the production
792
+ /// observation wiring, so settlement observability can be driven deterministically.
793
+ @discardableResult
794
+ func installPasteCoordinatorForTesting(
795
+ schedule: @escaping PasteTransactionCoordinator.Scheduler,
796
+ writeAndVerify: @escaping PasteTransactionCoordinator.PayloadWriter,
797
+ postPaste: @escaping PasteTransactionCoordinator.PastePoster
798
+ ) -> PasteTransactionCoordinator {
799
+ let coordinator = makePasteTransactionCoordinator(
800
+ schedule: schedule,
801
+ writeAndVerify: writeAndVerify,
802
+ postPaste: postPaste
803
+ )
804
+ pasteTransactionCoordinator = coordinator
805
+ return coordinator
806
+ }
807
+ #endif
161
808
 
162
- private nonisolated static let realtimePeriodicCommitInterval: TimeInterval = 0.9
809
+ private nonisolated static let realtimePeriodicCommitIntervalMilliseconds: UInt64 = 900
163
810
  private nonisolated static let realtimeFinishTimeoutMilliseconds: UInt64 = 700
811
+ /// Hard wall-clock budget for the rewrite helper (CLI spawn + one model call), covering
812
+ /// execution *and* CLIRunner's termination grace, kill grace, and pipe drain — not just
813
+ /// the child execution deadline. The user is waiting with recording blocked, so this
814
+ /// matches the interactive answer ceiling (`SpeechIntentClassifier.conversationTimeout`)
815
+ /// — never the generic 120 s CLI ceiling; cancellation stays available the whole time.
816
+ /// The `commandCLI` seam hands CLIRunner `commandRewriteTimeout` minus
817
+ /// `commandRewriteReturnMargin` so the runner's own deadline keeping plus the return
818
+ /// path stays inside this ceiling.
819
+ nonisolated static let commandRewriteTimeout: TimeInterval = 10
820
+ /// Wall-clock margin reserved out of `commandRewriteTimeout` before it becomes
821
+ /// CLIRunner's total deadline. CLIRunner clamps every wait to that deadline but still
822
+ /// pays small unclamped costs around them — spawn setup, waitid poll granularity (each
823
+ /// bounded wait can oversleep one 10 ms poll), synchronous capture shutdown, and the
824
+ /// detached-task hop back to the MainActor. Reserving a full second keeps the
825
+ /// *observable* rewrite time under the public ceiling even when the execution window,
826
+ /// termination grace, and pipe drain all run to exhaustion.
827
+ nonisolated static let commandRewriteReturnMargin: TimeInterval = 1
164
828
 
165
829
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
166
830
  private let fnMonitor = FnKeyMonitor()
@@ -169,21 +833,12 @@ public final class RecordingEngine: ObservableObject {
169
833
  let home = FileManager.default.homeDirectoryForCurrentUser.path
170
834
  private var audioDir: String { "\(home)/.hasna/recordings/audio" }
171
835
 
172
- // MARK: - OpenAI API Key
173
-
174
- private var openAIAPIKey: String {
175
- OpenAIAPIKeyStore.load(homePath: home)
176
- }
177
-
178
836
  public init() {
179
837
  try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
180
838
  log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
181
839
 
182
840
  // Load preferences
183
- if let savedMode = UserDefaults.standard.string(forKey: "recordingMode"),
184
- let parsedMode = RecordingMode(rawValue: savedMode) {
185
- mode = parsedMode
186
- }
841
+ intentDetectionEnabled = UserDefaults.standard.object(forKey: "intentDetectionEnabled") as? Bool ?? true
187
842
  transcriptionLanguage = OpenAIAPIKeyStore.loadLanguage(homePath: home)
188
843
  useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
189
844
  if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
@@ -195,7 +850,12 @@ public final class RecordingEngine: ObservableObject {
195
850
  Task { @MainActor [weak self] in
196
851
  guard let self else { return }
197
852
  self.fnKeyIsDown = true
198
- guard self.useFnKey, Self.canBeginRecording(isRecording: self.isRecording, isTranscribing: self.isTranscribing) else { return }
853
+ guard self.useFnKey, Self.canBeginRecording(
854
+ isRecording: self.isRecording,
855
+ isTranscribing: self.isTranscribing,
856
+ isAwaitingMicrophonePermission: self.microphonePermissionStartGate.isAwaitingResponse,
857
+ isDeliveryPending: self.deliveryIsPending
858
+ ) else { return }
199
859
  self.startRecording(trigger: .fnKey)
200
860
  }
201
861
  }
@@ -213,13 +873,18 @@ public final class RecordingEngine: ObservableObject {
213
873
  self.stopAndTranscribe()
214
874
  }
215
875
  }
216
- updateFnMonitor()
876
+ updateFnMonitor(allowAutomaticPrompt: false)
217
877
 
218
878
  KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
219
879
  Task { @MainActor [weak self] in
220
880
  guard let self, !self.keyboardShortcutIsDown else { return }
221
881
  self.keyboardShortcutIsDown = true
222
- guard Self.canBeginRecording(isRecording: self.isRecording, isTranscribing: self.isTranscribing) else { return }
882
+ guard Self.canBeginRecording(
883
+ isRecording: self.isRecording,
884
+ isTranscribing: self.isTranscribing,
885
+ isAwaitingMicrophonePermission: self.microphonePermissionStartGate.isAwaitingResponse,
886
+ isDeliveryPending: self.deliveryIsPending
887
+ ) else { return }
223
888
  self.startRecording(trigger: .keyboardShortcut)
224
889
  }
225
890
  }
@@ -285,9 +950,9 @@ public final class RecordingEngine: ObservableObject {
285
950
  }
286
951
 
287
952
  public func requestAccessibilityPermission() {
288
- let trusted = ensureAccessibilityPermission(prompt: true)
289
- log("requestAccessibilityPermission trusted=\(trusted)")
290
- statusMessage = trusted
953
+ let result = accessibilityPromptGate.requestExplicitly()
954
+ log("requestAccessibilityPermission trusted=\(result.trusted)")
955
+ statusMessage = result.trusted
291
956
  ? "Accessibility allowed"
292
957
  : "Enable Accessibility permission for Recordings to paste"
293
958
  objectWillChange.send()
@@ -307,11 +972,15 @@ public final class RecordingEngine: ObservableObject {
307
972
  }
308
973
  }
309
974
 
310
- private func updateFnMonitor() {
975
+ private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
311
976
  if useFnKey {
312
977
  let ok = fnMonitor.start()
313
978
  log("fn monitor start ok=\(ok)")
314
979
  if !ok {
980
+ if allowAutomaticPrompt {
981
+ let result = accessibilityPromptGate.trustForProtectedOperation()
982
+ log("fn monitor accessibility trusted=\(result.trusted) prompted=\(result.didPrompt)")
983
+ }
315
984
  statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
316
985
  }
317
986
  } else {
@@ -320,8 +989,9 @@ public final class RecordingEngine: ObservableObject {
320
989
  }
321
990
 
322
991
  public func updateStatus() {
323
- if isRecording || isTranscribing { return }
992
+ if isRecording || isTranscribing || deliveryIsPending { return }
324
993
  statusMessage = "Ready"
994
+ flowPhase = .idle
325
995
  }
326
996
 
327
997
  // MARK: - Toggle
@@ -333,40 +1003,141 @@ public final class RecordingEngine: ObservableObject {
333
1003
  // MARK: - Start Recording (Streaming)
334
1004
 
335
1005
  public func startRecording(trigger: RecordingTrigger = .manual) {
336
- guard Self.canBeginRecording(isRecording: isRecording, isTranscribing: isTranscribing) else {
1006
+ guard Self.canBeginRecording(
1007
+ isRecording: isRecording,
1008
+ isTranscribing: isTranscribing,
1009
+ isAwaitingMicrophonePermission: microphonePermissionStartGate.isAwaitingResponse,
1010
+ isDeliveryPending: deliveryIsPending
1011
+ ) else {
337
1012
  if isTranscribing {
338
1013
  statusMessage = "Finish transcribing before recording again"
1014
+ } else if deliveryIsPending {
1015
+ statusMessage = "Still delivering the last recording"
339
1016
  }
340
1017
  return
341
1018
  }
342
- log("startRecording trigger=\(trigger) microphoneStatus=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue) accessibility=\(AXIsProcessTrusted())")
1019
+ log("startRecording trigger=\(trigger) microphoneStatus=\(microphoneAuthorization().rawValue) accessibility=\(accessibilityTrustCheck())")
1020
+ recordingGeneration &+= 1
1021
+ if pasteTargetProcessIdentityByGeneration.count >= 32 {
1022
+ let oldestRetainedGeneration = recordingGeneration > 16 ? recordingGeneration - 16 : 0
1023
+ pasteTargetProcessIdentityByGeneration = pasteTargetProcessIdentityByGeneration.filter {
1024
+ $0.key >= oldestRetainedGeneration
1025
+ }
1026
+ }
343
1027
  activeTrigger = trigger
344
1028
  keyboardShortcutIsDown = trigger == .keyboardShortcut
1029
+ conversationReply = nil
345
1030
 
346
1031
  let myPID = ProcessInfo.processInfo.processIdentifier
347
- let frontmostApp = NSWorkspace.shared.frontmostApplication
348
- let isOwnApp = frontmostApp?.processIdentifier == myPID
1032
+ let frontmostApp = frontmostAppSnapshot()
1033
+ let isOwnApp = frontmostApp?.pid == myPID
349
1034
  targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
350
- targetAppPid = isOwnApp ? nil : frontmostApp?.processIdentifier
1035
+ targetAppPid = isOwnApp ? nil : frontmostApp?.pid
1036
+ if !isOwnApp,
1037
+ let pid = frontmostApp?.pid,
1038
+ let bundleIdentifier = frontmostApp?.bundleIdentifier,
1039
+ let launchDate = frontmostApp?.launchDate {
1040
+ pasteTargetProcessIdentityByGeneration[recordingGeneration] = PasteTargetProcessIdentity(
1041
+ pid: pid,
1042
+ bundleIdentifier: bundleIdentifier,
1043
+ launchDate: launchDate
1044
+ )
1045
+ } else {
1046
+ pasteTargetProcessIdentityByGeneration[recordingGeneration] = nil
1047
+ }
1048
+
1049
+ // The selection is still frozen for every recording (not only an exposed "command
1050
+ // mode"), so a later command decision can only ever act on the exact text and
1051
+ // element that were selected when the user started speaking. The Accessibility IPC
1052
+ // that reads it runs on a detached task, concurrently with recorder start: the
1053
+ // microphone must never wait on a beachballing target app, and the MainActor stays
1054
+ // free to process the key-up that stops the recording. Skipped entirely when intent
1055
+ // detection is off — no command route exists to consume it.
1056
+ let shouldCaptureSelection = Self.shouldCaptureSelection(
1057
+ targetPid: targetAppPid,
1058
+ accessibilityTrusted: accessibilityTrustCheck(),
1059
+ intentDetectionEnabled: intentDetectionEnabled
1060
+ )
1061
+ let capturePid = targetAppPid
1062
+ let captureSelection = selectionCapture
1063
+ let windowTitleLookup = focusedWindowTitleLookup
1064
+ let windowTitlePid = frontmostApp?.pid
1065
+ let axSnapshotTask = Task.detached(priority: .userInitiated) { () -> RecordingStartAXSnapshot in
1066
+ let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
1067
+ let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
1068
+ return RecordingStartAXSnapshot(
1069
+ selectionToken: selectionToken,
1070
+ focusedWindowTitle: focusedWindowTitle
1071
+ )
1072
+ }
351
1073
 
352
- if let store = projectStore {
353
- let windowTitle = Self.focusedWindowTitle(pid: frontmostApp?.processIdentifier)
354
- let projects = store.settings.projects
355
- let detected = ProjectStore.matchProject(windowTitle: windowTitle, bundleId: targetAppBundleIdentifier, projects: projects)
356
- if let detected {
357
- store.setActive(detected.id)
1074
+ // Project auto-selection and the processing configuration resolve with the
1075
+ // snapshot, still once per recording start and frozen for this generation; the
1076
+ // recording pipeline awaits this context only after the recorder has stopped.
1077
+ let generation = recordingGeneration
1078
+ let projectStore = projectStore
1079
+ let targetBundleIdentifierForProjects = targetAppBundleIdentifier
1080
+ let transcriptionLanguageAtStart = transcriptionLanguage
1081
+ let intentDetectionEnabledAtStart = intentDetectionEnabled
1082
+ let homePath = home
1083
+ let startContext = Task { @MainActor [weak self] () -> RecordingStartResolvedContext in
1084
+ let axSnapshot = await axSnapshotTask.value
1085
+ if let self, generation == self.recordingGeneration, let store = projectStore {
1086
+ let projects = store.settings.projects
1087
+ let detected = ProjectStore.matchProject(
1088
+ windowTitle: axSnapshot.focusedWindowTitle,
1089
+ bundleId: targetBundleIdentifierForProjects,
1090
+ projects: projects
1091
+ )
1092
+ if let detected,
1093
+ detected.id != store.settings.activeProjectId,
1094
+ store.canMutateProjects {
1095
+ do {
1096
+ try store.setActive(detected.id)
1097
+ } catch {
1098
+ self.log("project auto-selection failed; continuing capture with the last active project: \(error.localizedDescription)")
1099
+ }
1100
+ }
1101
+ if let warning = store.synchronizationError ?? store.persistenceError {
1102
+ self.log("project synchronization degraded; continuing capture: \(warning)")
1103
+ }
358
1104
  }
1105
+ let modelSelection = OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
1106
+ return RecordingStartResolvedContext(
1107
+ selectionToken: axSnapshot.selectionToken,
1108
+ canonicalProjectId: projectStore?.activeCanonicalProjectIdForRecording,
1109
+ displayProjectId: projectStore?.settings.activeProjectId,
1110
+ activeProjectName: projectStore?.activeProject?.name,
1111
+ processing: RecordingProcessingConfiguration(
1112
+ transcriptionPrompt: modelSelection.transcriptionPrompt,
1113
+ transcriberPrompt: projectStore?.effectiveSystemPrompt ?? "",
1114
+ postProcessingMode: projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue,
1115
+ transcriptionLanguage: transcriptionLanguageAtStart,
1116
+ transcriptionModel: modelSelection.transcriptionModel,
1117
+ transcriberModel: modelSelection.transcriberModel,
1118
+ enhancementModel: modelSelection.enhancementModel,
1119
+ intentModel: modelSelection.intentModel,
1120
+ intentDetectionEnabled: intentDetectionEnabledAtStart,
1121
+ enhanceTriggersJSON: modelSelection.enhanceTriggersJSON,
1122
+ keywordTransformsJSON: modelSelection.keywordTransformsJSON
1123
+ )
1124
+ )
359
1125
  }
360
1126
 
361
- switch AVCaptureDevice.authorizationStatus(for: .audio) {
1127
+ switch microphoneAuthorization() {
362
1128
  case .authorized:
363
- startNativeRecording()
1129
+ startNativeRecording(startContext: startContext)
364
1130
  case .notDetermined:
1131
+ guard let requestID = microphonePermissionStartGate.reserve() else { return }
365
1132
  statusMessage = "Allow microphone access to record"
366
1133
  log("requesting microphone access before recording")
367
1134
  AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
368
1135
  Task { @MainActor [weak self] in
369
1136
  guard let self else { return }
1137
+ guard self.microphonePermissionStartGate.consumeResponse(for: requestID) else {
1138
+ self.log("ignoring stale microphone permission response")
1139
+ return
1140
+ }
370
1141
  self.log("microphone access response granted=\(granted)")
371
1142
  if granted {
372
1143
  guard self.shouldContinueStarting(trigger: trigger) else {
@@ -375,25 +1146,122 @@ public final class RecordingEngine: ObservableObject {
375
1146
  self.updateStatus()
376
1147
  return
377
1148
  }
378
- self.startNativeRecording()
1149
+ self.startNativeRecording(startContext: startContext)
379
1150
  } else {
380
1151
  self.resetRecordingIntent()
381
1152
  self.statusMessage = "Microphone permission denied"
1153
+ self.flowPhase = .failed(self.statusMessage)
382
1154
  }
383
1155
  }
384
1156
  }
385
1157
  case .denied, .restricted:
386
1158
  resetRecordingIntent()
387
- log("microphone permission blocked status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
1159
+ log("microphone permission blocked status=\(microphoneAuthorization().rawValue)")
388
1160
  statusMessage = "Enable Microphone permission for Recordings in System Settings"
1161
+ flowPhase = .failed(statusMessage)
389
1162
  @unknown default:
390
1163
  resetRecordingIntent()
391
1164
  statusMessage = "Microphone permission unavailable"
1165
+ flowPhase = .failed(statusMessage)
1166
+ }
1167
+ }
1168
+
1169
+ nonisolated static func canBeginRecording(
1170
+ isRecording: Bool,
1171
+ isTranscribing: Bool,
1172
+ isAwaitingMicrophonePermission: Bool = false,
1173
+ isDeliveryPending: Bool = false
1174
+ ) -> Bool {
1175
+ !isRecording && !isTranscribing && !isAwaitingMicrophonePermission && !isDeliveryPending
1176
+ }
1177
+
1178
+ nonisolated static func shouldCaptureSelection(
1179
+ targetPid: pid_t?,
1180
+ accessibilityTrusted: Bool,
1181
+ intentDetectionEnabled: Bool
1182
+ ) -> Bool {
1183
+ targetPid != nil && accessibilityTrusted && intentDetectionEnabled
1184
+ }
1185
+
1186
+ nonisolated static func recordingStatus(
1187
+ trigger: RecordingTrigger
1188
+ ) -> String {
1189
+ switch trigger {
1190
+ case .manual: "Recording — click Stop when finished"
1191
+ case .fnKey, .keyboardShortcut: "Recording — release to stop"
1192
+ }
1193
+ }
1194
+
1195
+ private var deliveryIsPending: Bool {
1196
+ intentDeliveryPendingGeneration != nil || pasteTransactionCoordinator.hasPendingTransaction
1197
+ }
1198
+
1199
+ private func beginIntentDelivery(for generation: UInt64) {
1200
+ intentDeliveryPendingGeneration = generation
1201
+ }
1202
+
1203
+ private func endIntentDelivery(for generation: UInt64) {
1204
+ if intentDeliveryPendingGeneration == generation {
1205
+ intentDeliveryPendingGeneration = nil
392
1206
  }
393
1207
  }
394
1208
 
395
- nonisolated static func canBeginRecording(isRecording: Bool, isTranscribing: Bool) -> Bool {
396
- !isRecording && !isTranscribing
1209
+ /// Truthful start availability for UI surfaces. Mirrors exactly the gate
1210
+ /// `startRecording` applies, so a menu bar or button can never present Start while the
1211
+ /// engine would reject it.
1212
+ public var canStartRecording: Bool {
1213
+ Self.canBeginRecording(
1214
+ isRecording: isRecording,
1215
+ isTranscribing: isTranscribing,
1216
+ isAwaitingMicrophonePermission: microphonePermissionStartGate.isAwaitingResponse,
1217
+ isDeliveryPending: deliveryIsPending
1218
+ )
1219
+ }
1220
+
1221
+ /// Whether an in-flight Deciding/Answering/Rewriting delivery can be cancelled. Once a
1222
+ /// paste transaction is submitted the remaining window is sub-second and has its own
1223
+ /// target/clipboard safety rails, so cancellation stops being offered.
1224
+ public var canCancelIntentDelivery: Bool {
1225
+ intentDeliveryPendingGeneration != nil && !pasteTransactionCoordinator.hasPendingTransaction
1226
+ }
1227
+
1228
+ /// Cancels the pending intent delivery. Every phase that can be pending here —
1229
+ /// Deciding, Answering, Rewriting — inserted the transcript into Recent before the
1230
+ /// phase began (and the recording was already persisted to the library), so cancelling
1231
+ /// only abandons the delivery: "transcript saved to Recent" is literally true. Bumping
1232
+ /// the generation makes every in-flight completion stale, and every completion path
1233
+ /// re-checks the generation before touching state, the clipboard, or the target app —
1234
+ /// a cancelled decision, answer, or rewrite can never land later.
1235
+ public func cancelIntentProcessing() {
1236
+ guard canCancelIntentDelivery else { return }
1237
+ log("intent delivery cancelled by user generation=\(recordingGeneration)")
1238
+ recordingGeneration &+= 1
1239
+ intentDeliveryPendingGeneration = nil
1240
+ isTranscribing = false
1241
+ liveTranscriptionText = ""
1242
+ statusMessage = "Cancelled — transcript saved to Recent"
1243
+ flowPhase = .idle
1244
+ }
1245
+
1246
+ #if DEBUG
1247
+ /// Test-only: advances and registers a pipeline generation the way a recording
1248
+ /// start/stop pair would, so delivery tests can drive `finishWithText` repeatedly.
1249
+ func beginPipelineForTesting() -> UInt64 {
1250
+ recordingGeneration &+= 1
1251
+ pipelineDeliveryGate.registerPipeline(recordingGeneration)
1252
+ return recordingGeneration
1253
+ }
1254
+ #endif
1255
+
1256
+ /// Single staleness rule for generation-bound deliveries: anything bound to a
1257
+ /// superseded generation — or arriving mid-recording — is abandoned.
1258
+ nonisolated static func shouldAbandonDelivery(
1259
+ pipelineGeneration: UInt64?,
1260
+ currentGeneration: UInt64,
1261
+ isRecording: Bool
1262
+ ) -> Bool {
1263
+ guard let pipelineGeneration else { return false }
1264
+ return isRecording || pipelineGeneration != currentGeneration
397
1265
  }
398
1266
 
399
1267
  nonisolated static func shouldContinueStartingAfterPermission(
@@ -419,11 +1287,20 @@ public final class RecordingEngine: ObservableObject {
419
1287
  )
420
1288
  }
421
1289
 
422
- private func startNativeRecording() {
423
- let apiKey = openAIAPIKey
1290
+ private func startNativeRecording(startContext: Task<RecordingStartResolvedContext, Never>) {
1291
+ let apiKey = openAIAPIKeyProvider()
1292
+ let captureConfiguration = RecordingCaptureConfiguration(
1293
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
1294
+ targetAppPid: targetAppPid,
1295
+ startContext: startContext
1296
+ )
1297
+ activeCaptureConfiguration = captureConfiguration
424
1298
  log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
425
1299
  if !apiKey.isEmpty {
426
- startRealtimeStreaming(apiKey: apiKey)
1300
+ startRealtimeStreaming(
1301
+ apiKey: apiKey,
1302
+ transcriptionLanguage: transcriptionLanguage
1303
+ )
427
1304
  }
428
1305
 
429
1306
  let client = realtimeClient
@@ -431,7 +1308,7 @@ public final class RecordingEngine: ObservableObject {
431
1308
  pcmStreamPipe = streamPipe
432
1309
  let homePath = home
433
1310
  let firstChunkLogged = LockedFlag()
434
- let recorder = NativePCMRecorder { data in
1311
+ let recorder = recorderFactory { data in
435
1312
  if firstChunkLogged.take() {
436
1313
  NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
437
1314
  }
@@ -449,11 +1326,8 @@ public final class RecordingEngine: ObservableObject {
449
1326
  recordedPCM.removeAll(keepingCapacity: true)
450
1327
  activeAudioPath = "\(audioDir)/recording-\(Self.timestampForFilename()).wav"
451
1328
  let trigger = activeTrigger ?? .manual
452
- statusMessage = switch (mode, trigger) {
453
- case (.command, _): "Speak your instruction..."
454
- case (_, .manual): "Recording — click Stop when finished"
455
- case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
456
- }
1329
+ statusMessage = Self.recordingStatus(trigger: trigger)
1330
+ flowPhase = .listening
457
1331
 
458
1332
  recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
459
1333
  Task { @MainActor [weak self] in
@@ -468,14 +1342,16 @@ public final class RecordingEngine: ObservableObject {
468
1342
  streamingTask = nil
469
1343
  pcmStreamPipe?.cancel()
470
1344
  pcmStreamPipe = nil
1345
+ activeCaptureConfiguration = nil
471
1346
  resetRecordingIntent()
472
1347
  statusMessage = "Failed: \(error.localizedDescription)"
1348
+ flowPhase = .failed(statusMessage)
473
1349
  }
474
1350
  }
475
1351
 
476
1352
  // MARK: - Real-time Streaming
477
1353
 
478
- private func startRealtimeStreaming(apiKey: String) {
1354
+ private func startRealtimeStreaming(apiKey: String, transcriptionLanguage: String) {
479
1355
  let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
480
1356
  realtimeClient = client
481
1357
  let language = OpenAIAPIKeyStore.apiLanguageHint(for: transcriptionLanguage)
@@ -485,15 +1361,19 @@ public final class RecordingEngine: ObservableObject {
485
1361
  await client.startStreaming(language: language)
486
1362
  self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
487
1363
 
488
- var lastPeriodicCommitAt = Date.distantPast
1364
+ var lastPeriodicCommitAt: UInt64?
489
1365
 
490
1366
  // Receive deltas
491
1367
  while client.isStreaming {
492
1368
  try? await Task.sleep(for: .milliseconds(100))
493
- if self.isRecording,
494
- Date().timeIntervalSince(lastPeriodicCommitAt) >= Self.realtimePeriodicCommitInterval {
1369
+ let now = Self.monotonicMilliseconds()
1370
+ let periodicCommitIsDue = Self.realtimePeriodicCommitIsDue(
1371
+ nowMilliseconds: now,
1372
+ lastCommitMilliseconds: lastPeriodicCommitAt
1373
+ )
1374
+ if self.isRecording, periodicCommitIsDue {
495
1375
  if await client.commitInput(reason: "periodic") {
496
- lastPeriodicCommitAt = Date()
1376
+ lastPeriodicCommitAt = now
497
1377
  }
498
1378
  }
499
1379
  let text = client.accumulatedText
@@ -516,11 +1396,43 @@ public final class RecordingEngine: ObservableObject {
516
1396
  }
517
1397
  }
518
1398
 
1399
+ // MARK: - Cancel (discard without transcribing)
1400
+
1401
+ public func cancelRecording() {
1402
+ guard isRecording else { return }
1403
+ log("cancelRecording")
1404
+
1405
+ recordingTimer?.invalidate()
1406
+ recordingTimer = nil
1407
+
1408
+ let recorder = nativeRecorder
1409
+ nativeRecorder = nil
1410
+ recorder?.stop()
1411
+
1412
+ realtimeClient?.stop()
1413
+ realtimeClient = nil
1414
+ streamingTask?.cancel()
1415
+ streamingTask = nil
1416
+ pcmStreamPipe?.cancel()
1417
+ pcmStreamPipe = nil
1418
+
1419
+ isRecording = false
1420
+ isTranscribing = false
1421
+ liveTranscriptionText = ""
1422
+ recordedPCM.removeAll(keepingCapacity: true)
1423
+ activeAudioPath = nil
1424
+ activeCaptureConfiguration = nil
1425
+ resetRecordingIntent()
1426
+ statusMessage = "Ready"
1427
+ flowPhase = .idle
1428
+ }
1429
+
519
1430
  // MARK: - Stop & Transcribe
520
1431
 
521
1432
  public func stopAndTranscribe() {
522
1433
  guard isRecording else { return }
523
- log("stopAndTranscribe")
1434
+ let pipelineTrace = RecordingPipelineTrace()
1435
+ log(pipelineTrace.message(stage: "release"))
524
1436
 
525
1437
  recordingTimer?.invalidate()
526
1438
  recordingTimer = nil
@@ -532,14 +1444,29 @@ public final class RecordingEngine: ObservableObject {
532
1444
  isRecording = false
533
1445
  isTranscribing = true
534
1446
 
535
- let curMode = mode
536
- let targetAppBundleIdentifier = targetAppBundleIdentifier
537
- let targetAppPid = targetAppPid
538
- let activeProjectId = projectStore?.settings.activeProjectId
539
- let activeProjectName = projectStore?.activeProject?.name
1447
+ guard let captureConfiguration = activeCaptureConfiguration else {
1448
+ realtimeClient?.stop()
1449
+ realtimeClient = nil
1450
+ streamingTask?.cancel()
1451
+ streamingTask = nil
1452
+ pcmStreamPipe?.cancel()
1453
+ pcmStreamPipe = nil
1454
+ activeAudioPath = nil
1455
+ recordedPCM.removeAll(keepingCapacity: true)
1456
+ resetRecordingIntent()
1457
+ finish("Recording configuration unavailable")
1458
+ return
1459
+ }
1460
+ activeCaptureConfiguration = nil
1461
+ let targetAppBundleIdentifier = captureConfiguration.targetAppBundleIdentifier
1462
+ let targetAppPid = captureConfiguration.targetAppPid
540
1463
  let audioPath = activeAudioPath
541
1464
  let pcmStreamPipe = pcmStreamPipe
542
1465
  let client = realtimeClient
1466
+ let pipelineGeneration = recordingGeneration
1467
+ pipelineDeliveryGate.registerPipeline(pipelineGeneration)
1468
+ statusMessage = "Transcribing..."
1469
+ flowPhase = .finalizing
543
1470
  resetRecordingIntent()
544
1471
  self.pcmStreamPipe = nil
545
1472
 
@@ -547,44 +1474,225 @@ public final class RecordingEngine: ObservableObject {
547
1474
  if let pcmStreamPipe {
548
1475
  self.recordedPCM = await pcmStreamPipe.finish()
549
1476
  }
550
- self.log("captured pcm bytes=\(self.recordedPCM.count)")
551
-
552
- let streamingResult = await client?.finish(timeoutMilliseconds: Self.realtimeFinishTimeoutMilliseconds) ?? ""
1477
+ self.log(pipelineTrace.message(
1478
+ stage: "pcm_drain_complete",
1479
+ detail: "pcm_bytes=\(self.recordedPCM.count)"
1480
+ ))
1481
+
1482
+ let streamingResult = await client?.finish(
1483
+ timeoutMilliseconds: Self.realtimeFinishTimeoutMilliseconds,
1484
+ pipelineID: pipelineTrace.id,
1485
+ pipelineStartedUptimeMilliseconds: pipelineTrace.startedUptimeMilliseconds
1486
+ )
1487
+ ?? RealtimeFinishResult(text: "", settled: false, error: nil)
1488
+ self.log(pipelineTrace.message(
1489
+ stage: "realtime_finish_complete",
1490
+ detail: "settled=\(streamingResult.settled) chars=\(streamingResult.text.count)"
1491
+ ))
1492
+
1493
+ // The start context was captured concurrently at recording start; by the time
1494
+ // the realtime transcript has settled it is resolved in all but pathological
1495
+ // cases, and its Accessibility reads are bounded either way.
1496
+ let startContext = await captureConfiguration.startContext.value
1497
+ let selectionToken = startContext.selectionToken
1498
+ let activeProjectId = startContext.displayProjectId
1499
+ let canonicalProjectId = startContext.canonicalProjectId
1500
+ let activeProjectName = startContext.activeProjectName
1501
+ let processingConfiguration = startContext.processing
1502
+ let postProcessingMode = processingConfiguration.postProcessingMode
1503
+ let busyLabel = Self.shouldLabelRewriting(
1504
+ postProcessingMode: postProcessingMode
1505
+ ) ? "Rewriting..." : "Transcribing..."
553
1506
 
554
1507
  self.realtimeClient = nil
555
1508
  self.streamingTask?.cancel()
556
1509
  self.streamingTask = nil
1510
+ if self.isTranscribing {
1511
+ self.statusMessage = busyLabel
1512
+ self.flowPhase = .processing(busyLabel)
1513
+ }
1514
+
1515
+ if let error = streamingResult.error {
1516
+ self.log("realtime finish reported error=\(error)")
1517
+ }
557
1518
 
558
- let realtimeText = Self.normalizedRealtimeTranscript(streamingResult)
1519
+ let realtimeText = Self.normalizedRealtimeTranscript(streamingResult.text)
1520
+ let safeRealtimeFallbackText = Self.settledRealtimeFallbackTranscript(
1521
+ finishResult: streamingResult,
1522
+ pcmByteCount: self.recordedPCM.count,
1523
+ language: processingConfiguration.transcriptionLanguage
1524
+ )
1525
+ let realtimeFastPathText = Self.settledRealtimeFastPathTranscript(
1526
+ finishResult: streamingResult,
1527
+ pcmByteCount: self.recordedPCM.count,
1528
+ language: processingConfiguration.transcriptionLanguage
1529
+ )
559
1530
 
560
1531
  self.liveTranscriptionText = ""
561
1532
 
562
- if let audioPath, self.writeCapturedWAV(to: audioPath) {
1533
+ if let realtimeFastPathText {
1534
+ let pcmData = self.recordedPCM
1535
+ let durationMs = Int(self.recordingDuration * 1_000)
1536
+ let language = OpenAIAPIKeyStore.apiLanguageHint(for: processingConfiguration.transcriptionLanguage)
1537
+ let homePath = self.home
1538
+ self.log(pipelineTrace.message(
1539
+ stage: "realtime_fast_path_ready",
1540
+ detail: "chars=\(realtimeFastPathText.count) pcm_bytes=\(pcmData.count)"
1541
+ ))
1542
+ let persist: @Sendable () async -> RealtimeFastPathSaveResult = {
1543
+ await Self.saveRealtimeTranscript(
1544
+ text: realtimeFastPathText,
1545
+ audioPath: audioPath,
1546
+ pcmData: pcmData,
1547
+ durationMs: durationMs,
1548
+ activeProjectId: canonicalProjectId,
1549
+ processingConfiguration: processingConfiguration,
1550
+ language: language,
1551
+ recordingId: pipelineTrace.id,
1552
+ homePath: homePath,
1553
+ pipelineTrace: pipelineTrace
1554
+ )
1555
+ }
1556
+
1557
+ if Self.shouldPasteBeforePersistence(
1558
+ postProcessingMode: postProcessingMode,
1559
+ transcript: realtimeFastPathText,
1560
+ hasSelection: selectionToken != nil,
1561
+ intentDetectionEnabled: processingConfiguration.intentDetectionEnabled
1562
+ ) {
1563
+ self.isTranscribing = false
1564
+ _ = Self.deliverRealtimeBeforePersistence(
1565
+ text: realtimeFastPathText,
1566
+ persist: persist,
1567
+ deliver: { text in
1568
+ await withCheckedContinuation { continuation in
1569
+ self.finishWithText(
1570
+ text,
1571
+ rawTranscript: text,
1572
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
1573
+ targetAppPid: targetAppPid,
1574
+ selectionToken: selectionToken,
1575
+ canonicalProjectId: canonicalProjectId,
1576
+ activeProjectId: activeProjectId,
1577
+ activeProjectName: activeProjectName,
1578
+ processingConfiguration: processingConfiguration,
1579
+ pipelineTrace: pipelineTrace,
1580
+ pipelineGeneration: pipelineGeneration,
1581
+ deliveryCompleted: { continuation.resume() }
1582
+ )
1583
+ }
1584
+ },
1585
+ persistenceCompleted: { result in
1586
+ self.recordPersistenceCompletion(savedText: result.text)
1587
+ if result.text == nil {
1588
+ self.recoverAsyncPersistenceFailure(
1589
+ error: result.error ?? "Realtime save returned no recording",
1590
+ audioPath: audioPath,
1591
+ pcmData: pcmData,
1592
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
1593
+ targetAppPid: targetAppPid,
1594
+ selectionToken: selectionToken,
1595
+ canonicalProjectId: canonicalProjectId,
1596
+ displayProjectId: activeProjectId,
1597
+ activeProjectName: activeProjectName,
1598
+ processingConfiguration: processingConfiguration,
1599
+ pipelineTrace: pipelineTrace,
1600
+ pipelineGeneration: pipelineGeneration
1601
+ )
1602
+ } else {
1603
+ self.log(pipelineTrace.message(stage: "async_persistence_complete"))
1604
+ }
1605
+ }
1606
+ )
1607
+ self.activeAudioPath = nil
1608
+ self.recordedPCM.removeAll(keepingCapacity: true)
1609
+ return
1610
+ }
1611
+
1612
+ let saveResult = await persist()
1613
+ self.recordPersistenceCompletion(savedText: saveResult.text)
1614
+ guard let savedText = saveResult.text else {
1615
+ self.log("realtime fast-path save failed error=\(saveResult.error ?? "unknown")")
1616
+ if let audioPath, FileManager.default.fileExists(atPath: audioPath) || self.writeCapturedWAV(to: audioPath) {
1617
+ self.fallbackTranscribe(
1618
+ audioPath: audioPath,
1619
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
1620
+ targetAppPid: targetAppPid,
1621
+ selectionToken: selectionToken,
1622
+ canonicalProjectId: canonicalProjectId,
1623
+ displayProjectId: activeProjectId,
1624
+ activeProjectName: activeProjectName,
1625
+ processingConfiguration: processingConfiguration,
1626
+ realtimeText: safeRealtimeFallbackText,
1627
+ pipelineTrace: pipelineTrace,
1628
+ pipelineGeneration: pipelineGeneration
1629
+ )
1630
+ } else {
1631
+ self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
1632
+ self.finish(saveResult.error ?? "Failed to save transcription")
1633
+ }
1634
+ self.activeAudioPath = nil
1635
+ self.recordedPCM.removeAll(keepingCapacity: true)
1636
+ return
1637
+ }
1638
+ self.isTranscribing = false
1639
+ self.finishWithText(
1640
+ savedText,
1641
+ rawTranscript: realtimeFastPathText,
1642
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
1643
+ targetAppPid: targetAppPid,
1644
+ selectionToken: selectionToken,
1645
+ canonicalProjectId: canonicalProjectId,
1646
+ activeProjectId: activeProjectId,
1647
+ activeProjectName: activeProjectName,
1648
+ processingConfiguration: processingConfiguration,
1649
+ pipelineTrace: pipelineTrace,
1650
+ pipelineGeneration: pipelineGeneration
1651
+ )
1652
+ } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
1653
+ self.log(pipelineTrace.message(stage: "wav_write_complete", detail: "path=\(audioPath)"))
1654
+ if realtimeText != nil, !streamingResult.settled {
1655
+ self.log("realtime fast path skipped because final transcript did not settle")
1656
+ }
563
1657
  self.log("transcribing captured full audio with quality model audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
564
1658
  self.fallbackTranscribe(
565
1659
  audioPath: audioPath,
566
- curMode: curMode,
567
1660
  targetAppBundleIdentifier: targetAppBundleIdentifier,
568
1661
  targetAppPid: targetAppPid,
569
- activeProjectId: activeProjectId,
1662
+ selectionToken: selectionToken,
1663
+ canonicalProjectId: canonicalProjectId,
1664
+ displayProjectId: activeProjectId,
570
1665
  activeProjectName: activeProjectName,
571
- realtimeText: realtimeText
1666
+ processingConfiguration: processingConfiguration,
1667
+ realtimeText: safeRealtimeFallbackText,
1668
+ pipelineTrace: pipelineTrace,
1669
+ pipelineGeneration: pipelineGeneration
572
1670
  )
573
1671
  } else {
574
- let resolved = Self.resolveFinalTranscript(cliText: nil, cliError: "No audio captured", realtimeText: realtimeText)
1672
+ let resolved = Self.resolveFinalTranscript(
1673
+ cliText: nil,
1674
+ cliError: "No audio captured",
1675
+ realtimeText: safeRealtimeFallbackText
1676
+ )
575
1677
  if let text = resolved.text {
576
1678
  self.log("no audio file written; using realtime transcript chars=\(text.count)")
577
1679
  self.isTranscribing = false
578
1680
  self.finishWithText(
579
1681
  text,
580
- curMode: curMode,
1682
+ rawTranscript: text,
581
1683
  targetAppBundleIdentifier: targetAppBundleIdentifier,
582
1684
  targetAppPid: targetAppPid,
1685
+ selectionToken: selectionToken,
1686
+ canonicalProjectId: canonicalProjectId,
583
1687
  activeProjectId: activeProjectId,
584
- activeProjectName: activeProjectName
1688
+ activeProjectName: activeProjectName,
1689
+ processingConfiguration: processingConfiguration,
1690
+ pipelineTrace: pipelineTrace,
1691
+ pipelineGeneration: pipelineGeneration
585
1692
  )
586
1693
  } else {
587
1694
  self.log("no audio captured")
1695
+ self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
588
1696
  self.finish(resolved.failureStatus ?? "No audio captured")
589
1697
  }
590
1698
  }
@@ -607,6 +1715,75 @@ public final class RecordingEngine: ObservableObject {
607
1715
  return trimmed.isEmpty ? nil : trimmed
608
1716
  }
609
1717
 
1718
+ public nonisolated static func settledRealtimeFastPathTranscript(
1719
+ finishResult: RealtimeFinishResult,
1720
+ pcmByteCount: Int,
1721
+ language: String
1722
+ ) -> String? {
1723
+ guard let text = settledRealtimeFallbackTranscript(
1724
+ finishResult: finishResult,
1725
+ pcmByteCount: pcmByteCount,
1726
+ language: language
1727
+ ) else { return nil }
1728
+ return text
1729
+ }
1730
+
1731
+ public nonisolated static func settledRealtimeFallbackTranscript(
1732
+ finishResult: RealtimeFinishResult,
1733
+ pcmByteCount: Int,
1734
+ language: String
1735
+ ) -> String? {
1736
+ guard finishResult.settled, finishResult.error == nil else { return nil }
1737
+ guard let text = safeRealtimeFallbackTranscript(
1738
+ realtimeText: finishResult.text,
1739
+ language: language
1740
+ ) else { return nil }
1741
+ return shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount) ? nil : text
1742
+ }
1743
+
1744
+ /// Delivery may only run ahead of persistence for transcripts the local screen already
1745
+ /// decided are plain dictation: the paste is near-instant, so persistence is deferred by
1746
+ /// milliseconds. Command/conversation-shaped transcripts persist first — their delivery
1747
+ /// can block on the classifier, the assistant, or the rewrite CLI, and the recording
1748
+ /// must already be durable by then.
1749
+ nonisolated static func shouldPasteBeforePersistence(
1750
+ postProcessingMode: String,
1751
+ transcript: String,
1752
+ hasSelection: Bool,
1753
+ intentDetectionEnabled: Bool
1754
+ ) -> Bool {
1755
+ guard PostProcessingMode(rawValue: postProcessingMode) == .off else { return false }
1756
+ guard intentDetectionEnabled else { return true }
1757
+ return IntentScreen.screen(text: transcript, hasSelection: hasSelection)?.intent == .dictate
1758
+ }
1759
+
1760
+ nonisolated static func shouldLabelRewriting(
1761
+ postProcessingMode: String
1762
+ ) -> Bool {
1763
+ PostProcessingMode(rawValue: postProcessingMode) != .off
1764
+ }
1765
+
1766
+ @MainActor
1767
+ static func deliverRealtimeBeforePersistence(
1768
+ text: String,
1769
+ persist: @escaping @Sendable () async -> RealtimeFastPathSaveResult,
1770
+ deliver: @escaping @MainActor @Sendable (String) async -> Void,
1771
+ persistenceCompleted: @escaping @MainActor @Sendable (RealtimeFastPathSaveResult) -> Void
1772
+ ) -> Task<Void, Never> {
1773
+ return Task {
1774
+ await deliver(text)
1775
+ let result = await persist()
1776
+ persistenceCompleted(result)
1777
+ }
1778
+ }
1779
+
1780
+ /// Publishes a monotonic completion event only for confirmed persistence. A failed
1781
+ /// helper result must not make the Library appear current before recovery succeeds.
1782
+ func recordPersistenceCompletion(savedText: String?) {
1783
+ guard savedText != nil else { return }
1784
+ persistedRecordingRevision &+= 1
1785
+ }
1786
+
610
1787
  public nonisolated static func shouldUseRealtimeFastPath(
611
1788
  realtimeText: String?,
612
1789
  pcmByteCount: Int,
@@ -624,30 +1801,49 @@ public final class RecordingEngine: ObservableObject {
624
1801
  pcmByteCount: Int,
625
1802
  language: String = "en"
626
1803
  ) -> String? {
627
- guard let text = normalizedRealtimeTranscript(realtimeText) else { return nil }
628
- guard isSafeRealtimeFastPathText(rawText: realtimeText ?? "", cleanedText: text, language: language) else {
629
- return nil
630
- }
1804
+ guard let text = safeRealtimeFallbackTranscript(realtimeText: realtimeText, language: language) else { return nil }
631
1805
  return shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount) ? nil : text
632
1806
  }
633
1807
 
1808
+ public nonisolated static func safeRealtimeFallbackTranscript(
1809
+ realtimeText: String?,
1810
+ language: String = "en"
1811
+ ) -> String? {
1812
+ guard let text = normalizedRealtimeTranscript(realtimeText) else { return nil }
1813
+ guard isSafeRealtimeFastPathText(
1814
+ rawText: realtimeText ?? "",
1815
+ cleanedText: text,
1816
+ language: language
1817
+ ) else { return nil }
1818
+ return text
1819
+ }
1820
+
634
1821
  public nonisolated static func isSafeRealtimeFastPathText(rawText: String, cleanedText: String, language: String) -> Bool {
635
1822
  guard !cleanedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
636
1823
  let languageHint = OpenAIAPIKeyStore.apiLanguageHint(for: language)
637
- guard languageHint == "en" else { return true }
638
1824
 
639
1825
  let rawTrimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
640
1826
  let rawNormalized = normalizedTranscriptText(rawTrimmed)
641
1827
  let cleanedNormalized = normalizedTranscriptText(cleanedText)
642
1828
  guard !cleanedNormalized.isEmpty else { return false }
1829
+ if languageHint == "en" {
1830
+ guard cjkLetterCount(in: cleanedNormalized) == 0 else { return false }
1831
+ }
643
1832
  guard rawNormalized != cleanedNormalized else { return true }
644
1833
  guard cjkLetterCount(in: cleanedNormalized) == 0 else { return false }
645
1834
 
646
- let cleanedWords = canonicalTranscriptWords(cleanedNormalized, droppingArtifacts: false)
1835
+ let cleanedWords = canonicalTranscriptWords(cleanedNormalized)
647
1836
  guard !cleanedWords.isEmpty else { return false }
648
1837
 
649
- let rawWords = canonicalTranscriptWords(rawNormalized, droppingArtifacts: true)
650
- guard isSubsequence(cleanedWords, of: rawWords) else { return false }
1838
+ // CJK fragments are known realtime transport artifacts, but repeated words and
1839
+ // fillers may be intentional speech. The fast path is only safe when cleanup
1840
+ // preserves every lexical token; otherwise the whole WAV is transcribed.
1841
+ let rawWords = languageHint == "en"
1842
+ ? canonicalTranscriptWordsPreservingSpeechTokens(rawNormalized)
1843
+ : canonicalTranscriptWords(rawNormalized)
1844
+ guard cleanedWords == rawWords else { return false }
1845
+
1846
+ guard languageHint == "en" else { return true }
651
1847
 
652
1848
  let rawCJKCount = cjkLetterCount(in: rawNormalized)
653
1849
  if rawCJKCount > 0 {
@@ -656,8 +1852,7 @@ public final class RecordingEngine: ObservableObject {
656
1852
  guard rawCJKCount <= max(6, cleanedLatinCount / 3) else { return false }
657
1853
  }
658
1854
 
659
- let removedWordCount = max(0, rawWords.count - cleanedWords.count)
660
- return removedWordCount <= max(8, cleanedWords.count + 3)
1855
+ return true
661
1856
  }
662
1857
 
663
1858
  public nonisolated static func wasRealtimeTranscriptRepaired(rawText: String, cleanedText: String) -> Bool {
@@ -691,17 +1886,11 @@ public final class RecordingEngine: ObservableObject {
691
1886
 
692
1887
  private nonisolated static func removeStandaloneRealtimeArtifacts(from text: String) -> String {
693
1888
  let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
694
- let englishDominant = latinLetterCount(in: text) >= max(12, cjkLetterCount(in: text) * 3)
695
1889
  let words = text.split(separator: " ").compactMap { rawWord -> String? in
696
1890
  let normalized = rawWord
697
1891
  .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
698
1892
  .lowercased()
699
1893
  return artifactTokens.contains(normalized) ? nil : String(rawWord)
700
- }.compactMap { word -> String? in
701
- guard englishDominant else { return word }
702
- let normalized = word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
703
- guard !normalized.isEmpty else { return word }
704
- return isMostlyCJKArtifact(normalized) ? nil : word
705
1894
  }
706
1895
  return words.joined(separator: " ")
707
1896
  }
@@ -718,41 +1907,32 @@ public final class RecordingEngine: ObservableObject {
718
1907
  .trimmingCharacters(in: .whitespacesAndNewlines)
719
1908
  }
720
1909
 
721
- private nonisolated static func canonicalTranscriptWords(_ text: String, droppingArtifacts: Bool) -> [String] {
1910
+ private nonisolated static func canonicalTranscriptWords(_ text: String) -> [String] {
722
1911
  text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).compactMap { rawWord in
723
- var normalized = String(rawWord)
1912
+ let normalized = String(rawWord)
724
1913
  .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
725
1914
  .lowercased()
726
- if droppingArtifacts {
727
- normalized = normalized.unicodeScalars.reduce(into: "") { output, scalar in
728
- if !isCJKScalar(scalar) {
729
- output.unicodeScalars.append(scalar)
730
- }
731
- }
732
- }
733
1915
  guard !normalized.isEmpty else { return nil }
734
- if droppingArtifacts, isStandaloneRealtimeArtifact(normalized) {
735
- return nil
736
- }
737
1916
  return normalized
738
1917
  }
739
1918
  }
740
1919
 
741
- private nonisolated static func isSubsequence(_ needle: [String], of haystack: [String]) -> Bool {
742
- guard !needle.isEmpty else { return true }
743
- var index = 0
744
- for word in haystack where word == needle[index] {
745
- index += 1
746
- if index == needle.count {
747
- return true
1920
+ private nonisolated static func canonicalTranscriptWordsPreservingSpeechTokens(_ text: String) -> [String] {
1921
+ text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).compactMap { rawWord in
1922
+ var normalized = String(rawWord)
1923
+ .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
1924
+ .lowercased()
1925
+ if normalized == "어" || normalized == "음" {
1926
+ return nil
1927
+ }
1928
+ if normalized.hasSuffix("어") {
1929
+ let withoutSuffix = String(normalized.dropLast())
1930
+ if latinLetterCount(in: withoutSuffix) > 0 {
1931
+ normalized = withoutSuffix
1932
+ }
748
1933
  }
1934
+ return normalized.isEmpty ? nil : normalized
749
1935
  }
750
- return false
751
- }
752
-
753
- private nonisolated static func isStandaloneRealtimeArtifact(_ word: String) -> Bool {
754
- let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
755
- return artifactTokens.contains(word) || isMostlyCJKArtifact(word)
756
1936
  }
757
1937
 
758
1938
  private nonisolated static func collapseAdjacentDuplicateWords(in text: String) -> String {
@@ -800,13 +1980,6 @@ public final class RecordingEngine: ObservableObject {
800
1980
  word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).lowercased()
801
1981
  }
802
1982
 
803
- private nonisolated static func isMostlyCJKArtifact(_ word: String) -> Bool {
804
- let cjkCount = word.unicodeScalars.filter(isCJKScalar).count
805
- guard cjkCount > 0 else { return false }
806
- let letterCount = word.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
807
- return cjkCount >= max(1, letterCount - cjkCount)
808
- }
809
-
810
1983
  private nonisolated static func latinLetterCount(in text: String) -> Int {
811
1984
  text.unicodeScalars.filter { scalar in
812
1985
  (65...90).contains(Int(scalar.value)) || (97...122).contains(Int(scalar.value))
@@ -830,29 +2003,288 @@ public final class RecordingEngine: ObservableObject {
830
2003
  }
831
2004
  }
832
2005
 
833
- private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
834
- log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
835
- if curMode == .command {
836
- runCommandMode(instruction: text, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
2006
+ func finishWithText(
2007
+ _ text: String,
2008
+ rawTranscript: String,
2009
+ targetAppBundleIdentifier: String?,
2010
+ targetAppPid: pid_t?,
2011
+ selectionToken: AccessibilitySelectionToken?,
2012
+ canonicalProjectId: String?,
2013
+ activeProjectId: String?,
2014
+ activeProjectName: String?,
2015
+ processingConfiguration: RecordingProcessingConfiguration,
2016
+ pipelineTrace: RecordingPipelineTrace? = nil,
2017
+ pipelineGeneration: UInt64? = nil,
2018
+ deliveryCompleted: (@MainActor @Sendable () -> Void)? = nil
2019
+ ) {
2020
+ log("finishWithText chars=\(text.count) rawChars=\(rawTranscript.count)")
2021
+ if let pipelineGeneration,
2022
+ !pipelineDeliveryGate.claimDelivery(for: pipelineGeneration) {
2023
+ log("duplicate delivery suppressed pipeline_generation=\(pipelineGeneration)")
2024
+ deliveryCompleted?()
2025
+ return
2026
+ }
2027
+ // A delivery whose recording has been superseded (or that would land mid-recording)
2028
+ // is abandoned outright: the transcript is persisted to the library, and nothing may
2029
+ // paste into whatever the user is doing now.
2030
+ if isRecording || Self.shouldAbandonDelivery(
2031
+ pipelineGeneration: pipelineGeneration,
2032
+ currentGeneration: recordingGeneration,
2033
+ isRecording: isRecording
2034
+ ) {
2035
+ log("delivery abandoned for superseded recording pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
2036
+ deliveryCompleted?()
837
2037
  return
838
2038
  }
839
2039
 
840
- let shortcutText = voiceShortcuts?.match(text)
841
- let output = shortcutText ?? text
842
- pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid, restoreClipboard: true)
843
- recentTranscriptions.insert(
844
- TranscriptionResult(
845
- rawText: text,
2040
+ let execute: @MainActor @Sendable (RoutedSpeechAction, IntentDecisionOrigin, Bool) -> Void = { [weak self] action, origin, transcriptRetainedInRecent in
2041
+ guard let self else {
2042
+ deliveryCompleted?()
2043
+ return
2044
+ }
2045
+ self.executeRoutedAction(
2046
+ action,
2047
+ origin: origin,
2048
+ transcriptRetainedInRecent: transcriptRetainedInRecent,
2049
+ text: text,
2050
+ rawTranscript: rawTranscript,
2051
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
2052
+ targetAppPid: targetAppPid,
2053
+ selectionToken: selectionToken,
2054
+ canonicalProjectId: canonicalProjectId,
2055
+ activeProjectId: activeProjectId,
2056
+ activeProjectName: activeProjectName,
2057
+ processingConfiguration: processingConfiguration,
2058
+ pipelineTrace: pipelineTrace,
2059
+ pipelineGeneration: pipelineGeneration,
2060
+ deliveryCompleted: deliveryCompleted
2061
+ )
2062
+ }
2063
+
2064
+ // Voice shortcuts are explicit user-configured expansions and take precedence over
2065
+ // intent inference, exactly as they preceded routing before this flow existed.
2066
+ if let shortcutText = voiceShortcuts?.match(rawTranscript) {
2067
+ log("voice shortcut matched — pasting shortcut content")
2068
+ pasteIntoFrontApp(
2069
+ shortcutText,
2070
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
2071
+ targetAppPid: targetAppPid,
2072
+ restoreClipboard: true,
2073
+ deliveryKind: .ordinaryDictation,
2074
+ pipelineTrace: pipelineTrace,
2075
+ pipelineGeneration: pipelineGeneration,
2076
+ deliveryCompleted: deliveryCompleted
2077
+ )
2078
+ insertRecentTranscription(
2079
+ rawText: rawTranscript,
846
2080
  processedText: shortcutText,
847
- timestamp: Date(),
848
2081
  projectId: activeProjectId,
849
2082
  projectName: activeProjectName
2083
+ )
2084
+ return
2085
+ }
2086
+
2087
+ // Intent is always decided on the raw transcript — never on post-processed output,
2088
+ // which the enhancement pipeline may have rewritten.
2089
+ let routingContext = IntentRoutingContext(
2090
+ detectionEnabled: processingConfiguration.intentDetectionEnabled,
2091
+ hasSelection: selectionToken != nil,
2092
+ accessibilityTrusted: accessibilityTrustCheck()
2093
+ )
2094
+ if !routingContext.detectionEnabled {
2095
+ execute(IntentRouter.route(decision: nil, context: routingContext), .localScreen, false)
2096
+ return
2097
+ }
2098
+ if let localDecision = IntentScreen.screen(text: rawTranscript, hasSelection: routingContext.hasSelection) {
2099
+ log("intent decided locally intent=\(localDecision.intent.rawValue) reason=\(localDecision.reason)")
2100
+ execute(
2101
+ IntentRouter.route(decision: localDecision, context: routingContext),
2102
+ .localScreen,
2103
+ false
2104
+ )
2105
+ return
2106
+ }
2107
+
2108
+ // Consult the classifier. New recordings are blocked while the decision is pending,
2109
+ // and the generation is re-checked afterwards so a stale (or user-cancelled)
2110
+ // decision can never act on a later recording.
2111
+ // The transcript enters Recent before the pending phase begins: cancelling while
2112
+ // Deciding (or any later phase) promises "transcript saved to Recent", so it must
2113
+ // already be there.
2114
+ insertRecentTranscription(
2115
+ rawText: rawTranscript,
2116
+ processedText: nil,
2117
+ projectId: activeProjectId,
2118
+ projectName: activeProjectName
2119
+ )
2120
+ let deliveryGeneration = pipelineGeneration ?? recordingGeneration
2121
+ beginIntentDelivery(for: deliveryGeneration)
2122
+ updateDeliveryStatus("Deciding...", kind: .progress, pipelineGeneration: pipelineGeneration)
2123
+ if let pipelineTrace { log(pipelineTrace.message(stage: "intent_classification_started")) }
2124
+ let classifier = intentClassifier
2125
+ let intentModel = processingConfiguration.intentModel
2126
+ let hasSelection = routingContext.hasSelection
2127
+ let trustCheck = accessibilityTrustCheck
2128
+ Task { [weak self] in
2129
+ let outcome = await classifier.classify(
2130
+ transcript: rawTranscript,
2131
+ hasSelection: hasSelection,
2132
+ model: intentModel
2133
+ )
2134
+ guard let self else {
2135
+ deliveryCompleted?()
2136
+ return
2137
+ }
2138
+ self.endIntentDelivery(for: deliveryGeneration)
2139
+ guard deliveryGeneration == self.recordingGeneration, !self.isRecording else {
2140
+ self.log("stale intent decision abandoned pipeline_generation=\(deliveryGeneration)")
2141
+ deliveryCompleted?()
2142
+ return
2143
+ }
2144
+ let decision: IntentDecision?
2145
+ switch outcome {
2146
+ case .decision(let classified):
2147
+ decision = classified
2148
+ self.log("intent classified intent=\(classified.intent.rawValue) confidence=\(classified.confidence) reason=\(classified.reason)")
2149
+ case .unavailable(let message):
2150
+ decision = nil
2151
+ self.log("intent classifier unavailable — failing closed to dictation: \(message)")
2152
+ }
2153
+ if let pipelineTrace { self.log(pipelineTrace.message(stage: "intent_classification_complete")) }
2154
+ let action = IntentRouter.route(
2155
+ decision: decision,
2156
+ context: IntentRoutingContext(
2157
+ detectionEnabled: true,
2158
+ hasSelection: hasSelection,
2159
+ accessibilityTrusted: trustCheck()
2160
+ )
2161
+ )
2162
+ execute(action, .classifier, true)
2163
+ }
2164
+ }
2165
+
2166
+ private func executeRoutedAction(
2167
+ _ action: RoutedSpeechAction,
2168
+ origin: IntentDecisionOrigin,
2169
+ transcriptRetainedInRecent: Bool,
2170
+ text: String,
2171
+ rawTranscript: String,
2172
+ targetAppBundleIdentifier: String?,
2173
+ targetAppPid: pid_t?,
2174
+ selectionToken: AccessibilitySelectionToken?,
2175
+ canonicalProjectId: String?,
2176
+ activeProjectId: String?,
2177
+ activeProjectName: String?,
2178
+ processingConfiguration: RecordingProcessingConfiguration,
2179
+ pipelineTrace: RecordingPipelineTrace?,
2180
+ pipelineGeneration: UInt64?,
2181
+ deliveryCompleted: (@MainActor @Sendable () -> Void)?
2182
+ ) {
2183
+ switch action {
2184
+ case .paste(let reason, let literalRawTranscript):
2185
+ log("intent route=paste origin=\(origin.rawValue) literal=\(literalRawTranscript) reason=\(reason)")
2186
+ let output = literalRawTranscript ? rawTranscript : text
2187
+ pasteIntoFrontApp(
2188
+ output,
2189
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
2190
+ targetAppPid: targetAppPid,
2191
+ restoreClipboard: true,
2192
+ deliveryKind: .ordinaryDictation,
2193
+ pipelineTrace: pipelineTrace,
2194
+ pipelineGeneration: pipelineGeneration,
2195
+ deliveryCompleted: deliveryCompleted
2196
+ )
2197
+ if transcriptRetainedInRecent {
2198
+ attachProcessedTextToRecentTranscription(
2199
+ rawText: rawTranscript,
2200
+ processedText: output == rawTranscript ? nil : output
2201
+ )
2202
+ } else {
2203
+ insertRecentTranscription(
2204
+ rawText: rawTranscript,
2205
+ processedText: output == rawTranscript ? nil : output,
2206
+ projectId: activeProjectId,
2207
+ projectName: activeProjectName
2208
+ )
2209
+ }
2210
+ case .rewriteSelection(let reason):
2211
+ log("intent route=rewriteSelection origin=\(origin.rawValue) reason=\(reason)")
2212
+ // Retention before processing: the Rewriting phase can be cancelled (or fail),
2213
+ // and the Cancel affordance promises the transcript stays in Recent.
2214
+ if !transcriptRetainedInRecent {
2215
+ insertRecentTranscription(
2216
+ rawText: rawTranscript,
2217
+ processedText: nil,
2218
+ projectId: activeProjectId,
2219
+ projectName: activeProjectName
2220
+ )
2221
+ }
2222
+ runCommandMode(
2223
+ instruction: rawTranscript,
2224
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
2225
+ targetAppPid: targetAppPid,
2226
+ selectionToken: selectionToken,
2227
+ canonicalProjectId: canonicalProjectId,
2228
+ processingConfiguration: processingConfiguration,
2229
+ pipelineTrace: pipelineTrace,
2230
+ pipelineGeneration: pipelineGeneration,
2231
+ deliveryCompleted: deliveryCompleted
2232
+ )
2233
+ case .answerConversation(let reason):
2234
+ log("intent route=answerConversation origin=\(origin.rawValue) reason=\(reason)")
2235
+ if !transcriptRetainedInRecent {
2236
+ insertRecentTranscription(
2237
+ rawText: rawTranscript,
2238
+ processedText: nil,
2239
+ projectId: activeProjectId,
2240
+ projectName: activeProjectName
2241
+ )
2242
+ }
2243
+ runConversationMode(
2244
+ question: rawTranscript,
2245
+ processingConfiguration: processingConfiguration,
2246
+ pipelineTrace: pipelineTrace,
2247
+ pipelineGeneration: pipelineGeneration,
2248
+ deliveryCompleted: deliveryCompleted
2249
+ )
2250
+ }
2251
+ }
2252
+
2253
+ private func insertRecentTranscription(
2254
+ rawText: String,
2255
+ processedText: String?,
2256
+ projectId: String?,
2257
+ projectName: String?
2258
+ ) {
2259
+ recentTranscriptions.insert(
2260
+ TranscriptionResult(
2261
+ rawText: rawText,
2262
+ processedText: processedText,
2263
+ timestamp: Date(),
2264
+ projectId: projectId,
2265
+ projectName: projectName
850
2266
  ),
851
2267
  at: 0
852
2268
  )
853
2269
  if recentTranscriptions.count > 20 { recentTranscriptions.removeLast() }
854
2270
  }
855
2271
 
2272
+ /// Backfills the processed text onto a transcript that entered Recent when its pending
2273
+ /// phase began, so the entry shows exactly what was pasted. The original timestamp is
2274
+ /// preserved.
2275
+ private func attachProcessedTextToRecentTranscription(rawText: String, processedText: String?) {
2276
+ guard let processedText,
2277
+ let index = recentTranscriptions.firstIndex(where: { $0.rawText == rawText }) else { return }
2278
+ let existing = recentTranscriptions[index]
2279
+ recentTranscriptions[index] = TranscriptionResult(
2280
+ rawText: existing.rawText,
2281
+ processedText: processedText,
2282
+ timestamp: existing.timestamp,
2283
+ projectId: existing.projectId,
2284
+ projectName: existing.projectName
2285
+ )
2286
+ }
2287
+
856
2288
  private func writeCapturedWAV(to path: String) -> Bool {
857
2289
  guard !recordedPCM.isEmpty else { return false }
858
2290
  do {
@@ -872,21 +2304,97 @@ public final class RecordingEngine: ObservableObject {
872
2304
  }
873
2305
  }
874
2306
 
875
- private nonisolated static func saveCapturedWAVInBackground(pcmData: Data, audioPath: String, homePath: String) {
876
- Task.detached(priority: .utility) {
2307
+ private nonisolated static func saveRealtimeTranscript(
2308
+ text: String,
2309
+ audioPath: String?,
2310
+ pcmData: Data,
2311
+ durationMs: Int,
2312
+ activeProjectId: String?,
2313
+ processingConfiguration: RecordingProcessingConfiguration,
2314
+ language: String,
2315
+ recordingId: String,
2316
+ homePath: String,
2317
+ pipelineTrace: RecordingPipelineTrace
2318
+ ) async -> RealtimeFastPathSaveResult {
2319
+ await Task.detached(priority: .utility) {
877
2320
  do {
878
- try Self.writeWAV(
879
- pcmData: pcmData,
880
- sampleRate: 24_000,
881
- channelCount: 1,
882
- bitsPerSample: 16,
883
- to: URL(fileURLWithPath: audioPath)
2321
+ var savedAudioPath: String?
2322
+ if let audioPath, !pcmData.isEmpty {
2323
+ NativeAppLog.write(
2324
+ pipelineTrace.message(stage: "wav_write_started", detail: "pcm_bytes=\(pcmData.count)"),
2325
+ homePath: homePath
2326
+ )
2327
+ try Self.writeWAV(
2328
+ pcmData: pcmData,
2329
+ sampleRate: 24_000,
2330
+ channelCount: 1,
2331
+ bitsPerSample: 16,
2332
+ to: URL(fileURLWithPath: audioPath)
2333
+ )
2334
+ savedAudioPath = audioPath
2335
+ NativeAppLog.write(
2336
+ pipelineTrace.message(stage: "wav_write_complete", detail: "path=\(audioPath) pcm_bytes=\(pcmData.count)"),
2337
+ homePath: homePath
2338
+ )
2339
+ }
2340
+
2341
+ let textFile = try Self.writeTemporaryTranscript(text: text, homePath: homePath)
2342
+ defer { try? FileManager.default.removeItem(atPath: textFile) }
2343
+
2344
+ let args = saveTextCLIArgs(
2345
+ textFile: textFile,
2346
+ audioPath: savedAudioPath,
2347
+ activeProjectId: activeProjectId,
2348
+ transcriberPrompt: processingConfiguration.transcriberPrompt,
2349
+ postProcessingMode: processingConfiguration.postProcessingMode,
2350
+ language: language,
2351
+ transcriptionModel: processingConfiguration.transcriptionModel,
2352
+ transcriberModel: processingConfiguration.transcriberModel,
2353
+ enhancementModel: processingConfiguration.enhancementModel,
2354
+ enhanceTriggersJSON: processingConfiguration.enhanceTriggersJSON,
2355
+ keywordTransformsJSON: processingConfiguration.keywordTransformsJSON,
2356
+ recordingId: recordingId,
2357
+ durationMs: durationMs,
2358
+ source: "realtime_fast_path",
2359
+ modelUsed: RealtimeTranscriptionClient.transcriptionModelID
2360
+ )
2361
+ NativeAppLog.write(
2362
+ pipelineTrace.message(stage: "helper_started", detail: "operation=save_text"),
2363
+ homePath: homePath
2364
+ )
2365
+ let output = CLIRunner.run(args, home: homePath)
2366
+ if let error = CLIRunner.parseError(output) {
2367
+ NativeAppLog.write(
2368
+ pipelineTrace.message(stage: "helper_processing_store_failed", detail: "error=\(NativeErrorSanitizer.sanitize(error))"),
2369
+ homePath: homePath
2370
+ )
2371
+ return RealtimeFastPathSaveResult(text: nil, error: error)
2372
+ }
2373
+
2374
+ NativeAppLog.write(
2375
+ pipelineTrace.message(stage: "helper_processing_store_complete"),
2376
+ homePath: homePath
884
2377
  )
885
- NativeAppLog.write("wrote wav path=\(audioPath) pcmBytes=\(pcmData.count)", homePath: homePath)
2378
+ return RealtimeFastPathSaveResult(text: CLIRunner.parseJSON(output) ?? text, error: nil)
886
2379
  } catch {
887
- NativeAppLog.write("failed to save wav after realtime fast path error=\(error.localizedDescription)", homePath: homePath)
2380
+ NativeAppLog.write(
2381
+ pipelineTrace.message(stage: "persistence_failed", detail: "error=\(NativeErrorSanitizer.sanitize(error.localizedDescription))"),
2382
+ homePath: homePath
2383
+ )
2384
+ return RealtimeFastPathSaveResult(text: nil, error: error.localizedDescription)
888
2385
  }
889
- }
2386
+ }.value
2387
+ }
2388
+
2389
+ private nonisolated static func writeTemporaryTranscript(text: String, homePath: String) throws -> String {
2390
+ let dir = URL(fileURLWithPath: homePath)
2391
+ .appendingPathComponent(".hasna", isDirectory: true)
2392
+ .appendingPathComponent("recordings", isDirectory: true)
2393
+ .appendingPathComponent("tmp", isDirectory: true)
2394
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
2395
+ let url = dir.appendingPathComponent("realtime-\(UUID().uuidString).txt")
2396
+ try text.write(to: url, atomically: true, encoding: .utf8)
2397
+ return url.path
890
2398
  }
891
2399
 
892
2400
  private nonisolated static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
@@ -938,226 +2446,1154 @@ public final class RecordingEngine: ObservableObject {
938
2446
 
939
2447
  // MARK: - Fallback Transcription
940
2448
 
941
- private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?, realtimeText: String? = nil) {
942
- let homePath = home
943
-
944
- isTranscribing = true
945
- statusMessage = "Transcribing..."
2449
+ private func recoverAsyncPersistenceFailure(
2450
+ error: String,
2451
+ audioPath: String?,
2452
+ pcmData: Data,
2453
+ targetAppBundleIdentifier: String?,
2454
+ targetAppPid: pid_t?,
2455
+ selectionToken: AccessibilitySelectionToken?,
2456
+ canonicalProjectId: String?,
2457
+ displayProjectId: String?,
2458
+ activeProjectName: String?,
2459
+ processingConfiguration: RecordingProcessingConfiguration,
2460
+ pipelineTrace: RecordingPipelineTrace,
2461
+ pipelineGeneration: UInt64
2462
+ ) {
2463
+ let sanitizedError = NativeErrorSanitizer.sanitize(error)
2464
+ log(pipelineTrace.message(
2465
+ stage: "async_persistence_failed",
2466
+ detail: "error=\(sanitizedError)"
2467
+ ))
2468
+ updateBackgroundRecoveryStatus(
2469
+ "Pasted; recovering recording...",
2470
+ kind: .success,
2471
+ pipelineGeneration: pipelineGeneration
2472
+ )
946
2473
 
947
2474
  Task.detached {
948
- let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
949
- let cliError = CLIRunner.parseError(output)
950
- let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
2475
+ let recoveryAudioPath = Self.ensureBackgroundRecoveryAudio(
2476
+ audioPath: audioPath,
2477
+ pcmData: pcmData
2478
+ )
951
2479
 
952
2480
  await MainActor.run {
953
- if let cliError {
954
- self.log("cli transcription failed error=\(cliError)")
955
- } else if cliText == nil {
956
- self.log("cli transcription empty output=\(output.prefix(160))")
957
- }
958
-
959
- let resolved = Self.resolveFinalTranscript(cliText: cliText, cliError: cliError, realtimeText: realtimeText)
960
- guard let text = resolved.text else {
961
- self.finish(resolved.failureStatus ?? "Transcription failed")
2481
+ guard let recoveryAudioPath else {
2482
+ self.updateBackgroundRecoveryStatus(
2483
+ "Pasted, but recording could not be saved: \(sanitizedError)",
2484
+ kind: .failure,
2485
+ pipelineGeneration: pipelineGeneration
2486
+ )
962
2487
  return
963
2488
  }
964
-
965
- if cliText == nil {
966
- self.log("using realtime transcript fallback chars=\(text.count)")
967
- } else {
968
- self.log("cli transcription succeeded chars=\(text.count)")
969
- }
970
- self.isTranscribing = false
971
- self.finishWithText(
972
- text,
973
- curMode: curMode,
2489
+ self.fallbackTranscribe(
2490
+ audioPath: recoveryAudioPath,
974
2491
  targetAppBundleIdentifier: targetAppBundleIdentifier,
975
2492
  targetAppPid: targetAppPid,
976
- activeProjectId: activeProjectId,
977
- activeProjectName: activeProjectName
2493
+ selectionToken: selectionToken,
2494
+ canonicalProjectId: canonicalProjectId,
2495
+ displayProjectId: displayProjectId,
2496
+ activeProjectName: activeProjectName,
2497
+ processingConfiguration: processingConfiguration,
2498
+ realtimeText: nil,
2499
+ pipelineTrace: pipelineTrace,
2500
+ deliverResult: false,
2501
+ backgroundRecoveryGeneration: pipelineGeneration
978
2502
  )
979
2503
  }
980
2504
  }
981
2505
  }
982
2506
 
983
- private func mostRecentAudioFile() -> String? {
984
- let files = (try? FileManager.default.contentsOfDirectory(atPath: audioDir)) ?? []
985
- let wavFiles = files.filter { $0.hasSuffix(".wav") }.sorted().reversed()
986
- return wavFiles.first.map { "\(audioDir)/\($0)" }
987
- }
988
-
989
- private func finish(_ msg: String) {
990
- log("finish status=\(msg)")
991
- isTranscribing = false
992
- liveTranscriptionText = ""
993
- statusMessage = msg
2507
+ nonisolated static func ensureBackgroundRecoveryAudio(
2508
+ audioPath: String?,
2509
+ pcmData: Data
2510
+ ) -> String? {
2511
+ guard let audioPath else { return nil }
2512
+ if FileManager.default.fileExists(atPath: audioPath) {
2513
+ return audioPath
2514
+ }
2515
+ guard !pcmData.isEmpty else { return nil }
2516
+ do {
2517
+ try writeWAV(
2518
+ pcmData: pcmData,
2519
+ sampleRate: 24_000,
2520
+ channelCount: 1,
2521
+ bitsPerSample: 16,
2522
+ to: URL(fileURLWithPath: audioPath)
2523
+ )
2524
+ return audioPath
2525
+ } catch {
2526
+ return nil
2527
+ }
994
2528
  }
995
2529
 
996
- private func resetRecordingIntent() {
997
- activeTrigger = nil
998
- keyboardShortcutIsDown = false
999
- fnKeyIsDown = false
1000
- targetAppBundleIdentifier = nil
1001
- targetAppPid = nil
2530
+ nonisolated static func shouldApplyBackgroundRecoveryStatus(
2531
+ recoveryGeneration: UInt64,
2532
+ currentGeneration: UInt64,
2533
+ isRecording: Bool,
2534
+ isTranscribing: Bool
2535
+ ) -> Bool {
2536
+ recoveryGeneration == currentGeneration && !isRecording && !isTranscribing
1002
2537
  }
1003
2538
 
1004
- // MARK: - Command Mode
1005
-
1006
- private func runCommandMode(instruction: String, targetAppBundleIdentifier: String?, targetAppPid: pid_t?) {
1007
- guard ensureAccessibilityPermission(prompt: shouldPromptAccessibility()) else {
1008
- log("command mode blocked by accessibility permission")
1009
- statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
1010
- return
1011
- }
1012
- let targetApp = selectedRunningPasteTarget(
1013
- targetAppBundleIdentifier: targetAppBundleIdentifier,
1014
- targetAppPid: targetAppPid,
1015
- frontmostPid: NSWorkspace.shared.frontmostApplication?.processIdentifier
1016
- )
1017
- guard let targetApp else {
1018
- log("command mode target app not found")
1019
- statusMessage = "No target app found"
2539
+ private func updateBackgroundRecoveryStatus(
2540
+ _ message: String,
2541
+ kind: DeliveryStatusKind,
2542
+ pipelineGeneration: UInt64
2543
+ ) {
2544
+ guard Self.shouldApplyBackgroundRecoveryStatus(
2545
+ recoveryGeneration: pipelineGeneration,
2546
+ currentGeneration: recordingGeneration,
2547
+ isRecording: isRecording,
2548
+ isTranscribing: isTranscribing
2549
+ ) else {
2550
+ log("background recovery status suppressed for superseded pipeline generation=\(pipelineGeneration)")
1020
2551
  return
1021
2552
  }
1022
- let alreadyFrontmost = targetApp.processIdentifier == NSWorkspace.shared.frontmostApplication?.processIdentifier
1023
- if !alreadyFrontmost {
1024
- targetApp.activate(options: [.activateIgnoringOtherApps])
1025
- }
2553
+ statusMessage = message
2554
+ flowPhase = Self.flowPhase(forDeliveryStatus: message, kind: kind)
2555
+ }
1026
2556
 
1027
- let copyDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
1028
- DispatchQueue.main.asyncAfter(deadline: .now() + copyDelay) {
1029
- self.postKey(0x08, flags: .maskCommand) // Cmd+C
2557
+ nonisolated static func fallbackCompletionAction(
2558
+ cliText: String?,
2559
+ cliError: String?,
2560
+ realtimeText: String?,
2561
+ deliverResult: Bool
2562
+ ) -> FallbackCompletionAction {
2563
+ let resolved = resolveFinalTranscript(
2564
+ cliText: cliText,
2565
+ cliError: cliError,
2566
+ realtimeText: realtimeText
2567
+ )
2568
+ guard let text = resolved.text else {
2569
+ let failure = resolved.failureStatus ?? "Transcription failed"
2570
+ return deliverResult ? .fail(failure) : .backgroundFailed(failure)
1030
2571
  }
2572
+ return deliverResult ? .deliver(text) : .backgroundRecovered
2573
+ }
1031
2574
 
2575
+ private func fallbackTranscribe(
2576
+ audioPath: String,
2577
+ targetAppBundleIdentifier: String?,
2578
+ targetAppPid: pid_t?,
2579
+ selectionToken: AccessibilitySelectionToken?,
2580
+ canonicalProjectId: String?,
2581
+ displayProjectId: String?,
2582
+ activeProjectName: String?,
2583
+ processingConfiguration: RecordingProcessingConfiguration,
2584
+ realtimeText: String? = nil,
2585
+ pipelineTrace: RecordingPipelineTrace? = nil,
2586
+ pipelineGeneration: UInt64? = nil,
2587
+ deliverResult: Bool = true,
2588
+ backgroundRecoveryGeneration: UInt64? = nil
2589
+ ) {
1032
2590
  let homePath = home
1033
- Task {
1034
- try? await Task.sleep(for: .milliseconds(Int((copyDelay + 0.25) * 1_000)))
1035
- let selected = NSPasteboard.general.string(forType: .string) ?? ""
1036
- guard !selected.isEmpty else {
1037
- statusMessage = "No text selected"
1038
- return
1039
- }
1040
- statusMessage = "Rewriting..."
1041
- isTranscribing = true
1042
2591
 
1043
- Task.detached {
1044
- let result = CLIRunner.run(["rewrite", selected, "--instruction", instruction], home: homePath)
1045
- await MainActor.run {
1046
- self.isTranscribing = false
1047
- self.liveTranscriptionText = ""
1048
- if CLIRunner.parseError(result) == nil, !result.isEmpty {
1049
- self.pasteIntoFrontApp(result)
1050
- self.statusMessage = "Rewritten"
1051
- } else {
1052
- self.statusMessage = CLIRunner.parseError(result) ?? "Rewrite failed"
1053
- }
1054
- }
2592
+ if deliverResult {
2593
+ isTranscribing = true
2594
+ statusMessage = Self.shouldLabelRewriting(
2595
+ postProcessingMode: processingConfiguration.postProcessingMode
2596
+ ) ? "Rewriting..." : "Transcribing..."
2597
+ flowPhase = .processing(statusMessage)
2598
+ } else {
2599
+ if let backgroundRecoveryGeneration {
2600
+ updateBackgroundRecoveryStatus(
2601
+ "Pasted; recovering recording...",
2602
+ kind: .success,
2603
+ pipelineGeneration: backgroundRecoveryGeneration
2604
+ )
1055
2605
  }
1056
2606
  }
1057
- }
1058
2607
 
1059
- private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
1060
- let src = CGEventSource(stateID: .hidSystemState)
1061
- guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
1062
- let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return }
1063
- down.flags = flags
1064
- up.flags = flags
1065
- down.post(tap: .cgSessionEventTap)
1066
- up.post(tap: .cgSessionEventTap)
1067
- }
2608
+ // Only a proven canonical Store id may be persisted. The local display id remains
2609
+ // available to recent-transcript UI even when synchronization is degraded.
2610
+ let transcribeArgs = Self.transcribeCLIArgs(
2611
+ audioPath: audioPath,
2612
+ activeProjectId: canonicalProjectId,
2613
+ transcriberPrompt: processingConfiguration.transcriberPrompt,
2614
+ postProcessingMode: processingConfiguration.postProcessingMode,
2615
+ language: processingConfiguration.transcriptionLanguage,
2616
+ transcriptionPrompt: processingConfiguration.transcriptionPrompt,
2617
+ transcriptionModel: processingConfiguration.transcriptionModel,
2618
+ transcriberModel: processingConfiguration.transcriberModel,
2619
+ enhancementModel: processingConfiguration.enhancementModel,
2620
+ enhanceTriggersJSON: processingConfiguration.enhanceTriggersJSON,
2621
+ keywordTransformsJSON: processingConfiguration.keywordTransformsJSON,
2622
+ recordingId: pipelineTrace?.id
2623
+ )
1068
2624
 
1069
- // MARK: - Window Title (Accessibility API)
2625
+ Task.detached {
2626
+ if let pipelineTrace {
2627
+ NativeAppLog.write(
2628
+ pipelineTrace.message(stage: "helper_started", detail: "operation=batch_transcribe"),
2629
+ homePath: homePath
2630
+ )
2631
+ }
2632
+ let output = CLIRunner.run(transcribeArgs, home: homePath)
2633
+ let cliError = CLIRunner.parseError(output)
2634
+ let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
2635
+ let cliRawText = cliError == nil ? CLIRunner.parseRawTranscript(output) : nil
1070
2636
 
1071
- private static func focusedWindowTitle(pid: pid_t?) -> String? {
1072
- guard let pid else { return nil }
1073
- let app = AXUIElementCreateApplication(pid)
1074
- var windowRef: CFTypeRef?
1075
- guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
1076
- let window = windowRef else { return nil }
1077
- var titleRef: CFTypeRef?
1078
- guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleRef) == .success,
1079
- let title = titleRef as? String else { return nil }
2637
+ await MainActor.run {
2638
+ if let pipelineTrace {
2639
+ self.log(pipelineTrace.message(
2640
+ stage: cliError == nil ? "helper_processing_store_complete" : "helper_processing_store_failed"
2641
+ ))
2642
+ }
2643
+ if let cliError {
2644
+ self.log("cli transcription failed error=\(cliError)")
2645
+ } else if cliText == nil {
2646
+ self.log("cli transcription empty output=\(output.prefix(160))")
2647
+ }
2648
+
2649
+ if cliText == nil {
2650
+ self.log("using realtime transcript fallback chars=\(realtimeText?.count ?? 0)")
2651
+ } else {
2652
+ self.log("cli transcription succeeded chars=\(cliText?.count ?? 0)")
2653
+ }
2654
+ self.recordPersistenceCompletion(savedText: cliError == nil ? cliText : nil)
2655
+ switch Self.fallbackCompletionAction(
2656
+ cliText: cliText,
2657
+ cliError: cliError,
2658
+ realtimeText: realtimeText,
2659
+ deliverResult: deliverResult
2660
+ ) {
2661
+ case .deliver(let text):
2662
+ self.isTranscribing = false
2663
+ self.finishWithText(
2664
+ text,
2665
+ rawTranscript: cliRawText ?? realtimeText ?? text,
2666
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
2667
+ targetAppPid: targetAppPid,
2668
+ selectionToken: selectionToken,
2669
+ canonicalProjectId: canonicalProjectId,
2670
+ activeProjectId: displayProjectId,
2671
+ activeProjectName: activeProjectName,
2672
+ processingConfiguration: processingConfiguration,
2673
+ pipelineTrace: pipelineTrace,
2674
+ pipelineGeneration: pipelineGeneration
2675
+ )
2676
+ case .fail(let failure):
2677
+ if let pipelineGeneration {
2678
+ self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
2679
+ }
2680
+ self.finish(failure)
2681
+ case .backgroundRecovered:
2682
+ if let pipelineTrace {
2683
+ self.log(pipelineTrace.message(stage: "async_persistence_recovered"))
2684
+ }
2685
+ if let backgroundRecoveryGeneration {
2686
+ self.updateBackgroundRecoveryStatus(
2687
+ "Pasted and saved",
2688
+ kind: .success,
2689
+ pipelineGeneration: backgroundRecoveryGeneration
2690
+ )
2691
+ }
2692
+ case .backgroundFailed(let failure):
2693
+ if let backgroundRecoveryGeneration {
2694
+ self.updateBackgroundRecoveryStatus(
2695
+ "Pasted, but recording could not be saved: \(failure)",
2696
+ kind: .failure,
2697
+ pipelineGeneration: backgroundRecoveryGeneration
2698
+ )
2699
+ }
2700
+ }
2701
+ }
2702
+ }
2703
+ }
2704
+
2705
+ private func mostRecentAudioFile() -> String? {
2706
+ let files = (try? FileManager.default.contentsOfDirectory(atPath: audioDir)) ?? []
2707
+ let wavFiles = files.filter { $0.hasSuffix(".wav") }.sorted().reversed()
2708
+ return wavFiles.first.map { "\(audioDir)/\($0)" }
2709
+ }
2710
+
2711
+ nonisolated static func transcribeCLIArgs(
2712
+ audioPath: String,
2713
+ activeProjectId: String?,
2714
+ transcriberPrompt: String,
2715
+ postProcessingMode: String,
2716
+ language: String = "auto",
2717
+ transcriptionPrompt: String? = nil,
2718
+ transcriptionModel: String? = nil,
2719
+ transcriberModel: String? = nil,
2720
+ enhancementModel: String? = nil,
2721
+ enhanceTriggersJSON: String? = nil,
2722
+ keywordTransformsJSON: String? = nil,
2723
+ recordingId: String? = nil
2724
+ ) -> [String] {
2725
+ var args = ["--json"]
2726
+ if let activeProjectId, !activeProjectId.isEmpty {
2727
+ args += ["--project", activeProjectId]
2728
+ }
2729
+ args += ["transcribe", audioPath]
2730
+
2731
+ let languageHint = OpenAIAPIKeyStore.apiLanguageHint(for: language)
2732
+ if !languageHint.isEmpty {
2733
+ args += ["--language", languageHint]
2734
+ }
2735
+ if let recordingId, !recordingId.isEmpty {
2736
+ args += ["--recording-id", recordingId]
2737
+ }
2738
+ if let transcriptionPrompt, !transcriptionPrompt.isEmpty {
2739
+ args += ["--prompt", transcriptionPrompt]
2740
+ }
2741
+ if let transcriptionModel, !transcriptionModel.isEmpty {
2742
+ args += ["--transcription-model", transcriptionModel]
2743
+ }
2744
+ if let transcriberModel, !transcriberModel.isEmpty {
2745
+ args += ["--transcriber-model", transcriberModel]
2746
+ }
2747
+ if let enhancementModel, !enhancementModel.isEmpty {
2748
+ args += ["--enhancement-model", enhancementModel]
2749
+ }
2750
+ if let enhanceTriggersJSON, !enhanceTriggersJSON.isEmpty {
2751
+ args += ["--enhance-triggers-json", enhanceTriggersJSON]
2752
+ }
2753
+ if let keywordTransformsJSON, !keywordTransformsJSON.isEmpty {
2754
+ args += ["--keyword-transforms-json", keywordTransformsJSON]
2755
+ }
2756
+
2757
+ let mode = PostProcessingMode(rawValue: postProcessingMode)?.rawValue ?? PostProcessingMode.auto.rawValue
2758
+ args += ["--post-processing", mode]
2759
+
2760
+ let prompt = transcriberPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
2761
+ if !prompt.isEmpty {
2762
+ args += ["--transcriber-prompt", prompt]
2763
+ }
2764
+
2765
+ return args
2766
+ }
2767
+
2768
+ nonisolated static func saveTextCLIArgs(
2769
+ textFile: String,
2770
+ audioPath: String?,
2771
+ activeProjectId: String?,
2772
+ transcriberPrompt: String,
2773
+ postProcessingMode: String,
2774
+ language: String,
2775
+ transcriptionModel: String? = nil,
2776
+ transcriberModel: String? = nil,
2777
+ enhancementModel: String? = nil,
2778
+ enhanceTriggersJSON: String? = nil,
2779
+ keywordTransformsJSON: String? = nil,
2780
+ recordingId: String? = nil,
2781
+ durationMs: Int,
2782
+ source: String,
2783
+ modelUsed: String
2784
+ ) -> [String] {
2785
+ var args = ["--json"]
2786
+ if let activeProjectId, !activeProjectId.isEmpty {
2787
+ args += ["--project", activeProjectId]
2788
+ }
2789
+ args += [
2790
+ "save-text",
2791
+ "--text-file", textFile,
2792
+ "--source", source,
2793
+ "--model-used", modelUsed,
2794
+ "--post-processing", PostProcessingMode(rawValue: postProcessingMode)?.rawValue ?? PostProcessingMode.auto.rawValue,
2795
+ ]
2796
+ if let audioPath, !audioPath.isEmpty {
2797
+ args += ["--audio-path", audioPath]
2798
+ }
2799
+ if durationMs > 0 {
2800
+ args += ["--duration-ms", String(durationMs)]
2801
+ }
2802
+ if !language.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
2803
+ args += ["--language", language]
2804
+ }
2805
+ if let recordingId, !recordingId.isEmpty {
2806
+ args += ["--recording-id", recordingId]
2807
+ }
2808
+ if let transcriptionModel, !transcriptionModel.isEmpty {
2809
+ args += ["--transcription-model", transcriptionModel]
2810
+ }
2811
+ if let transcriberModel, !transcriberModel.isEmpty {
2812
+ args += ["--transcriber-model", transcriberModel]
2813
+ }
2814
+ if let enhancementModel, !enhancementModel.isEmpty {
2815
+ args += ["--enhancement-model", enhancementModel]
2816
+ }
2817
+ if let enhanceTriggersJSON, !enhanceTriggersJSON.isEmpty {
2818
+ args += ["--enhance-triggers-json", enhanceTriggersJSON]
2819
+ }
2820
+ if let keywordTransformsJSON, !keywordTransformsJSON.isEmpty {
2821
+ args += ["--keyword-transforms-json", keywordTransformsJSON]
2822
+ }
2823
+ let prompt = transcriberPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
2824
+ if !prompt.isEmpty {
2825
+ args += ["--transcriber-prompt", prompt]
2826
+ }
2827
+ return args
2828
+ }
2829
+
2830
+ nonisolated static func rewriteCLIArgs(
2831
+ selectedText: String,
2832
+ instruction: String,
2833
+ activeProjectId: String?,
2834
+ processingConfiguration: RecordingProcessingConfiguration
2835
+ ) -> [String] {
2836
+ var args: [String] = []
2837
+ if let activeProjectId, !activeProjectId.isEmpty {
2838
+ args += ["--project", activeProjectId]
2839
+ }
2840
+ args += [
2841
+ "rewrite",
2842
+ "--instruction", instruction,
2843
+ "--post-processing", processingConfiguration.postProcessingMode,
2844
+ "--language", processingConfiguration.transcriptionLanguage,
2845
+ "--prompt", processingConfiguration.transcriptionPrompt,
2846
+ "--transcriber-prompt", processingConfiguration.transcriberPrompt,
2847
+ "--transcription-model", processingConfiguration.transcriptionModel,
2848
+ "--transcriber-model", processingConfiguration.transcriberModel,
2849
+ "--enhancement-model", processingConfiguration.enhancementModel,
2850
+ "--enhance-triggers-json", processingConfiguration.enhanceTriggersJSON,
2851
+ "--keyword-transforms-json", processingConfiguration.keywordTransformsJSON,
2852
+ "--", selectedText,
2853
+ ]
2854
+ return args
2855
+ }
2856
+
2857
+ private func finish(_ msg: String) {
2858
+ log("finish status=\(msg)")
2859
+ isTranscribing = false
2860
+ liveTranscriptionText = ""
2861
+ statusMessage = msg
2862
+ flowPhase = .failed(msg)
2863
+ }
2864
+
2865
+ private func resetRecordingIntent() {
2866
+ activeTrigger = nil
2867
+ microphonePermissionStartGate.cancel()
2868
+ keyboardShortcutIsDown = false
2869
+ fnKeyIsDown = false
2870
+ targetAppBundleIdentifier = nil
2871
+ targetAppPid = nil
2872
+ }
2873
+
2874
+ // MARK: - Conversation
2875
+
2876
+ private func runConversationMode(
2877
+ question: String,
2878
+ processingConfiguration: RecordingProcessingConfiguration,
2879
+ pipelineTrace: RecordingPipelineTrace?,
2880
+ pipelineGeneration: UInt64?,
2881
+ deliveryCompleted: (@MainActor @Sendable () -> Void)?
2882
+ ) {
2883
+ let deliveryGeneration = pipelineGeneration ?? recordingGeneration
2884
+ beginIntentDelivery(for: deliveryGeneration)
2885
+ updateDeliveryStatus("Answering...", kind: .progress, pipelineGeneration: pipelineGeneration)
2886
+ if let pipelineTrace { log(pipelineTrace.message(stage: "conversation_started")) }
2887
+ let classifier = intentClassifier
2888
+ let model = processingConfiguration.intentModel
2889
+ Task { [weak self] in
2890
+ let outcome = await classifier.answer(question: question, model: model)
2891
+ guard let self else {
2892
+ deliveryCompleted?()
2893
+ return
2894
+ }
2895
+ self.endIntentDelivery(for: deliveryGeneration)
2896
+ if let pipelineTrace { self.log(pipelineTrace.message(stage: "conversation_complete")) }
2897
+ // The conversation route never touches the clipboard: the reply card has an
2898
+ // explicit Copy affordance, and clobbering whatever the user had copied would be
2899
+ // an irreversible side effect of a possibly-misclassified recording.
2900
+ switch outcome {
2901
+ case .answer(let answer):
2902
+ if Self.shouldApplyConversationReply(
2903
+ replyGeneration: pipelineGeneration,
2904
+ currentGeneration: self.recordingGeneration,
2905
+ isRecording: self.isRecording
2906
+ ) {
2907
+ self.conversationReply = ConversationReply(question: question, answer: answer)
2908
+ self.updateDeliveryStatus("Answered", kind: .success, pipelineGeneration: pipelineGeneration)
2909
+ } else {
2910
+ self.log("stale conversation reply dropped pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
2911
+ }
2912
+ case .unavailable(let message):
2913
+ // The delayed answer failed; never auto-paste this late. Fail closed to the
2914
+ // preview path: the transcript is persisted and stays in Recent.
2915
+ self.log("conversation unavailable: \(message)")
2916
+ self.updateDeliveryStatus(
2917
+ "Couldn't answer — transcript saved to Recent",
2918
+ kind: .failure,
2919
+ pipelineGeneration: pipelineGeneration
2920
+ )
2921
+ }
2922
+ deliveryCompleted?()
2923
+ }
2924
+ }
2925
+
2926
+ nonisolated static func shouldApplyConversationReply(
2927
+ replyGeneration: UInt64?,
2928
+ currentGeneration: UInt64,
2929
+ isRecording: Bool
2930
+ ) -> Bool {
2931
+ guard !isRecording else { return false }
2932
+ // No generation means the reply cannot be proven current — fail closed.
2933
+ guard let replyGeneration else { return false }
2934
+ return replyGeneration == currentGeneration
2935
+ }
2936
+
2937
+ // MARK: - Command Mode
2938
+
2939
+ private func runCommandMode(
2940
+ instruction: String,
2941
+ targetAppBundleIdentifier: String?,
2942
+ targetAppPid: pid_t?,
2943
+ selectionToken: AccessibilitySelectionToken?,
2944
+ canonicalProjectId: String?,
2945
+ processingConfiguration: RecordingProcessingConfiguration,
2946
+ pipelineTrace: RecordingPipelineTrace?,
2947
+ pipelineGeneration: UInt64?,
2948
+ deliveryCompleted: (@MainActor @Sendable () -> Void)?
2949
+ ) {
2950
+ let deliveryGeneration = pipelineGeneration ?? recordingGeneration
2951
+ beginIntentDelivery(for: deliveryGeneration)
2952
+ let finishCommandDelivery: @MainActor @Sendable () -> Void = { [weak self] in
2953
+ self?.endIntentDelivery(for: deliveryGeneration)
2954
+ deliveryCompleted?()
2955
+ }
2956
+ guard protectedOperationTrust().trusted else {
2957
+ log("command mode blocked by accessibility permission")
2958
+ updateDeliveryStatus(
2959
+ "Enable Accessibility permission for Recordings to rewrite selected text",
2960
+ kind: .failure,
2961
+ pipelineGeneration: pipelineGeneration
2962
+ )
2963
+ finishCommandDelivery()
2964
+ return
2965
+ }
2966
+
2967
+ let homePath = home
2968
+ let resolveTarget = rewriteSelectionResolver
2969
+ Task { @MainActor in
2970
+ let resolution = await resolveTarget(
2971
+ targetAppBundleIdentifier,
2972
+ targetAppPid,
2973
+ selectionToken,
2974
+ pipelineGeneration
2975
+ )
2976
+ let selected: String
2977
+ switch resolution {
2978
+ case .targetAppMissing:
2979
+ self.log("command mode target app not found")
2980
+ self.updateDeliveryStatus("No target app found", kind: .failure, pipelineGeneration: pipelineGeneration)
2981
+ finishCommandDelivery()
2982
+ return
2983
+ case .selectionUnavailable:
2984
+ self.updateDeliveryStatus("No text selected", kind: .failure, pipelineGeneration: pipelineGeneration)
2985
+ finishCommandDelivery()
2986
+ return
2987
+ case .selection(let validated):
2988
+ selected = validated
2989
+ }
2990
+ // A cancellation (or newer recording) during target resolution makes the whole
2991
+ // rewrite stale — never spawn the CLI for a delivery that can only be abandoned.
2992
+ guard !Self.shouldAbandonDelivery(
2993
+ pipelineGeneration: pipelineGeneration,
2994
+ currentGeneration: self.recordingGeneration,
2995
+ isRecording: self.isRecording
2996
+ ) else {
2997
+ self.log("stale rewrite abandoned before CLI pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
2998
+ finishCommandDelivery()
2999
+ return
3000
+ }
3001
+ if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
3002
+ self.statusMessage = "Rewriting..."
3003
+ self.flowPhase = .processing("Rewriting...")
3004
+ self.isTranscribing = true
3005
+ }
3006
+
3007
+ let rewriteArguments = Self.rewriteCLIArgs(
3008
+ selectedText: selected,
3009
+ instruction: instruction,
3010
+ activeProjectId: canonicalProjectId,
3011
+ processingConfiguration: processingConfiguration
3012
+ )
3013
+ let runCLI = self.commandCLI
3014
+ let result = await Task.detached {
3015
+ runCLI(rewriteArguments, homePath, Self.commandRewriteTimeout)
3016
+ }.value
3017
+ if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
3018
+ self.isTranscribing = false
3019
+ self.liveTranscriptionText = ""
3020
+ }
3021
+ // A rewrite finishing after the user cancelled (or after a newer recording
3022
+ // superseded it) must never paste, even if the frozen selection still matches.
3023
+ guard !Self.shouldAbandonDelivery(
3024
+ pipelineGeneration: pipelineGeneration,
3025
+ currentGeneration: self.recordingGeneration,
3026
+ isRecording: self.isRecording
3027
+ ) else {
3028
+ self.log("stale rewrite abandoned pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
3029
+ finishCommandDelivery()
3030
+ return
3031
+ }
3032
+ if CLIRunner.parseError(result) == nil, !result.isEmpty {
3033
+ self.pasteIntoFrontApp(
3034
+ result,
3035
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
3036
+ targetAppPid: targetAppPid,
3037
+ restoreClipboard: true,
3038
+ deliveryKind: .commandRewrite,
3039
+ selectionToken: selectionToken,
3040
+ pipelineTrace: pipelineTrace,
3041
+ pipelineGeneration: pipelineGeneration,
3042
+ deliveryCompleted: finishCommandDelivery
3043
+ )
3044
+ } else {
3045
+ self.updateDeliveryStatus(
3046
+ CLIRunner.parseError(result) ?? "Rewrite failed",
3047
+ kind: .failure,
3048
+ pipelineGeneration: pipelineGeneration
3049
+ )
3050
+ finishCommandDelivery()
3051
+ }
3052
+ }
3053
+ }
3054
+
3055
+ /// Production body of `rewriteSelectionResolver`: real NSWorkspace/AX I/O. The frozen
3056
+ /// target app is re-found and activated, focus settles, and the frozen selection is
3057
+ /// revalidated element-for-element before any rewrite may run.
3058
+ private func resolveRewriteSelection(
3059
+ targetAppBundleIdentifier: String?,
3060
+ targetAppPid: pid_t?,
3061
+ selectionToken: AccessibilitySelectionToken?,
3062
+ pipelineGeneration: UInt64?
3063
+ ) async -> RewriteTargetResolution {
3064
+ let requiredProcessIdentity = pipelineGeneration.flatMap {
3065
+ pasteTargetProcessIdentityByGeneration[$0]
3066
+ }
3067
+ let targetApp = selectedRunningPasteTarget(
3068
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
3069
+ targetAppPid: targetAppPid,
3070
+ frontmostPid: NSWorkspace.shared.frontmostApplication?.processIdentifier,
3071
+ pipelineGeneration: pipelineGeneration
3072
+ )
3073
+ guard let targetApp else {
3074
+ return .targetAppMissing
3075
+ }
3076
+ let alreadyFrontmost = targetApp.processIdentifier == NSWorkspace.shared.frontmostApplication?.processIdentifier
3077
+ if !alreadyFrontmost {
3078
+ targetApp.activate()
3079
+ }
3080
+
3081
+ let focusDelay: TimeInterval = alreadyFrontmost ? 0.05 : 0.35
3082
+ try? await Task.sleep(for: .milliseconds(Int(focusDelay * 1_000)))
3083
+ let frontmostBeforeRead = NSWorkspace.shared.frontmostApplication
3084
+ guard Self.pasteTargetIsReady(
3085
+ expectedPid: targetApp.processIdentifier,
3086
+ expectedBundleIdentifier: targetApp.bundleIdentifier,
3087
+ frontmostPid: frontmostBeforeRead?.processIdentifier,
3088
+ frontmostBundleIdentifier: frontmostBeforeRead?.bundleIdentifier,
3089
+ accessibilityTrusted: AXIsProcessTrusted(),
3090
+ expectedLaunchDate: requiredProcessIdentity?.launchDate,
3091
+ frontmostLaunchDate: frontmostBeforeRead?.launchDate,
3092
+ requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
3093
+ ) else {
3094
+ return .selectionUnavailable
3095
+ }
3096
+ let frontmostAfterRead = NSWorkspace.shared.frontmostApplication
3097
+ let selected = Self.validAccessibilitySelection(
3098
+ selectionToken?.selectedText,
3099
+ targetStillFrontmost: Self.pasteTargetIsReady(
3100
+ expectedPid: targetApp.processIdentifier,
3101
+ expectedBundleIdentifier: targetApp.bundleIdentifier,
3102
+ frontmostPid: frontmostAfterRead?.processIdentifier,
3103
+ frontmostBundleIdentifier: frontmostAfterRead?.bundleIdentifier,
3104
+ accessibilityTrusted: AXIsProcessTrusted(),
3105
+ expectedLaunchDate: requiredProcessIdentity?.launchDate,
3106
+ frontmostLaunchDate: frontmostAfterRead?.launchDate,
3107
+ requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
3108
+ )
3109
+ )
3110
+ guard let selected,
3111
+ selectionToken?.matchesCurrentSelection(
3112
+ for: targetApp.processIdentifier
3113
+ ) == true else {
3114
+ return .selectionUnavailable
3115
+ }
3116
+ return .selection(selected)
3117
+ }
3118
+
3119
+ nonisolated static func validAccessibilitySelection(
3120
+ _ candidate: String?,
3121
+ targetStillFrontmost: Bool
3122
+ ) -> String? {
3123
+ let text = candidate ?? ""
3124
+ guard targetStillFrontmost,
3125
+ !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
3126
+ return text
3127
+ }
3128
+
3129
+ private func canOwnBusyState(pipelineGeneration: UInt64?) -> Bool {
3130
+ guard let pipelineGeneration else { return true }
3131
+ return pipelineGeneration == recordingGeneration && !isRecording
3132
+ }
3133
+
3134
+ // MARK: - Window Title (Accessibility API)
3135
+
3136
+ /// Runs off the MainActor in the recording-start snapshot; every IPC round trip is
3137
+ /// bounded so an unresponsive app delays project detection, never recording.
3138
+ private nonisolated static func focusedWindowTitle(pid: pid_t?) -> String? {
3139
+ guard let pid else { return nil }
3140
+ let app = AXUIElementCreateApplication(pid)
3141
+ AXUIElementSetMessagingTimeout(app, AccessibilitySelectionToken.captureMessagingTimeout)
3142
+ var windowRef: CFTypeRef?
3143
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
3144
+ let window = windowRef,
3145
+ CFGetTypeID(window) == AXUIElementGetTypeID() else { return nil }
3146
+ let windowElement = window as! AXUIElement
3147
+ AXUIElementSetMessagingTimeout(windowElement, AccessibilitySelectionToken.captureMessagingTimeout)
3148
+ var titleRef: CFTypeRef?
3149
+ guard AXUIElementCopyAttributeValue(windowElement, kAXTitleAttribute as CFString, &titleRef) == .success,
3150
+ let title = titleRef as? String else { return nil }
1080
3151
  return title
1081
3152
  }
1082
3153
 
1083
3154
  // MARK: - Paste
1084
3155
 
1085
- func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
1086
- log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
3156
+ /// Copies text without pasting, preserving the previous clipboard if the write fails.
3157
+ /// Used by explicit "Copy" affordances in the UI.
3158
+ @discardableResult
3159
+ public func copyToClipboard(_ text: String) -> Bool {
3160
+ Self.writeClipboardPreservingOnFailure(text, to: .general)
3161
+ }
3162
+
3163
+ public func pasteIntoFrontApp(
3164
+ _ text: String,
3165
+ targetAppBundleIdentifier: String? = nil,
3166
+ targetAppPid: pid_t? = nil,
3167
+ restoreClipboard: Bool = false
3168
+ ) {
3169
+ pasteIntoFrontApp(
3170
+ text,
3171
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
3172
+ targetAppPid: targetAppPid,
3173
+ restoreClipboard: restoreClipboard,
3174
+ deliveryKind: .manualPaste,
3175
+ pipelineTrace: nil,
3176
+ pipelineGeneration: nil,
3177
+ deliveryCompleted: nil
3178
+ )
3179
+ }
3180
+
3181
+ private func pasteIntoFrontApp(
3182
+ _ text: String,
3183
+ targetAppBundleIdentifier: String? = nil,
3184
+ targetAppPid: pid_t? = nil,
3185
+ restoreClipboard: Bool = false,
3186
+ deliveryKind: PasteDeliveryKind,
3187
+ selectionToken: AccessibilitySelectionToken? = nil,
3188
+ pipelineTrace: RecordingPipelineTrace?,
3189
+ pipelineGeneration: UInt64?,
3190
+ deliveryCompleted: (@MainActor @Sendable () -> Void)?
3191
+ ) {
3192
+ if let pipelineTrace { log(pipelineTrace.message(stage: "paste_requested", detail: "chars=\(text.count)")) }
3193
+ log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(accessibilityTrustCheck())")
3194
+ // A paste bound to a superseded generation (a cancelled or replaced recording) is
3195
+ // abandoned before it can touch the clipboard or the target app.
3196
+ if Self.shouldAbandonDelivery(
3197
+ pipelineGeneration: pipelineGeneration,
3198
+ currentGeneration: recordingGeneration,
3199
+ isRecording: isRecording
3200
+ ) {
3201
+ log("paste abandoned for superseded recording pipeline_generation=\(pipelineGeneration.map(String.init) ?? "nil")")
3202
+ deliveryCompleted?()
3203
+ return
3204
+ }
3205
+ if let pasteInterceptorForTesting {
3206
+ pasteInterceptorForTesting(text, deliveryKind, pipelineGeneration)
3207
+ deliveryCompleted?()
3208
+ return
3209
+ }
1087
3210
  let pb = NSPasteboard.general
1088
- let previousClipboard = restoreClipboard ? ClipboardSnapshot(pasteboard: pb) : nil
1089
- pb.clearContents()
1090
- pb.setString(text, forType: .string)
1091
-
1092
- let prompted = shouldPromptAccessibility()
1093
- guard ensureAccessibilityPermission(prompt: prompted) else {
1094
- log("paste blocked by accessibility permission; copied to clipboard")
1095
- self.statusMessage = prompted
1096
- ? "Copied approve Accessibility for this Recordings app"
1097
- : "Copied waiting for Accessibility approval"
3211
+ var previousClipboard: ClipboardSnapshot?
3212
+
3213
+ let accessibility = protectedOperationTrust()
3214
+ guard accessibility.trusted else {
3215
+ let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
3216
+ let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
3217
+ log("paste blocked by accessibility permission")
3218
+ let message = if deliveryKind == .commandRewrite {
3219
+ "Paste cancelled because Accessibility permission changed"
3220
+ } else if !copied {
3221
+ "Transcription ready, but the clipboard could not be updated"
3222
+ } else if accessibility.didPrompt {
3223
+ "Copied — approve Accessibility for this Recordings app"
3224
+ } else {
3225
+ "Copied — waiting for Accessibility approval"
3226
+ }
3227
+ updateDeliveryStatus(message, kind: .failure, pipelineGeneration: pipelineGeneration)
3228
+ deliveryCompleted?()
1098
3229
  return
1099
3230
  }
1100
3231
 
1101
3232
  let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
3233
+ let requiredProcessIdentity = pipelineGeneration.flatMap {
3234
+ pasteTargetProcessIdentityByGeneration[$0]
3235
+ }
1102
3236
  let targetApp = selectedRunningPasteTarget(
1103
3237
  targetAppBundleIdentifier: targetAppBundleIdentifier,
1104
3238
  targetAppPid: targetAppPid,
1105
- frontmostPid: frontmostPid
3239
+ frontmostPid: frontmostPid,
3240
+ pipelineGeneration: pipelineGeneration
1106
3241
  )
1107
3242
 
1108
3243
  guard let app = targetApp else {
3244
+ let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
3245
+ let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
1109
3246
  log("paste target app not found")
1110
- self.statusMessage = "Copied — no target app found"
3247
+ updateDeliveryStatus(
3248
+ deliveryKind == .commandRewrite
3249
+ ? "Paste cancelled because the target app is unavailable"
3250
+ : copied
3251
+ ? "Copied — no target app found"
3252
+ : "Transcription ready, but the clipboard could not be updated",
3253
+ kind: .failure,
3254
+ pipelineGeneration: pipelineGeneration
3255
+ )
3256
+ deliveryCompleted?()
1111
3257
  return
1112
3258
  }
1113
3259
 
1114
3260
  // Activate the exact app that owned focus when recording started, then paste after focus settles.
1115
3261
  let alreadyFrontmost = app.processIdentifier == frontmostPid
1116
3262
  if !alreadyFrontmost {
1117
- app.activate(options: [.activateIgnoringOtherApps])
3263
+ app.activate()
1118
3264
  }
1119
3265
 
1120
3266
  let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
1121
- DispatchQueue.main.asyncAfter(deadline: .now() + pasteDelay) {
1122
- let src = CGEventSource(stateID: .hidSystemState)
1123
- if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
1124
- let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
1125
- down.flags = .maskCommand
1126
- up.flags = .maskCommand
1127
- down.post(tap: .cgSessionEventTap)
1128
- up.post(tap: .cgSessionEventTap)
1129
- }
1130
- self.log("paste event posted target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost)")
1131
- self.statusMessage = "Pasted (\(text.count) chars)"
1132
-
1133
- if let previousClipboard {
1134
- // Give the target app time to consume the paste, then hand the clipboard back.
1135
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
1136
- let pb = NSPasteboard.general
1137
- if pb.string(forType: .string) == text {
1138
- previousClipboard.restore(to: pb)
1139
- }
3267
+ var ownedPasteboardChangeCount: Int?
3268
+ var clipboardOwnershipWasLost = false
3269
+ updateDeliveryStatus("Pasting...", kind: .progress, pipelineGeneration: pipelineGeneration)
3270
+ let accepted = pasteTransactionCoordinator.submit(
3271
+ text: text,
3272
+ generation: pipelineGeneration,
3273
+ delay: pasteDelay,
3274
+ settlementDelay: restoreClipboard ? 0.6 : 0,
3275
+ targetIsReady: {
3276
+ let frontmost = NSWorkspace.shared.frontmostApplication
3277
+ let appIsReady = Self.pasteTargetIsReady(
3278
+ expectedPid: app.processIdentifier,
3279
+ expectedBundleIdentifier: app.bundleIdentifier,
3280
+ frontmostPid: frontmost?.processIdentifier,
3281
+ frontmostBundleIdentifier: frontmost?.bundleIdentifier,
3282
+ accessibilityTrusted: AXIsProcessTrusted(),
3283
+ expectedLaunchDate: requiredProcessIdentity?.launchDate,
3284
+ frontmostLaunchDate: frontmost?.launchDate,
3285
+ requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
3286
+ )
3287
+ guard appIsReady else { return false }
3288
+ return selectionToken?.matchesCurrentSelection(for: app.processIdentifier) ?? true
3289
+ },
3290
+ payloadIsReady: {
3291
+ guard let ownedPasteboardChangeCount else { return false }
3292
+ return Self.clipboardStillOwned(
3293
+ NSPasteboard.general,
3294
+ text: text,
3295
+ changeCount: ownedPasteboardChangeCount
3296
+ )
3297
+ },
3298
+ prepare: {
3299
+ if restoreClipboard {
3300
+ previousClipboard = ClipboardSnapshot(pasteboard: .general)
1140
3301
  }
3302
+ },
3303
+ writeAttempted: { result in
3304
+ ownedPasteboardChangeCount = result.ownershipChangeCount
3305
+ }
3306
+ ) { transaction, outcome in
3307
+ let posted = outcome == .pasted
3308
+ let accessibilityTrusted = AXIsProcessTrusted()
3309
+ let completedTranscriptAlreadyOnClipboard = outcome == .targetUnavailable
3310
+ && !restoreClipboard
3311
+ && (ownedPasteboardChangeCount.map {
3312
+ Self.clipboardStillOwned(.general, text: transaction.text, changeCount: $0)
3313
+ } ?? false)
3314
+ let shouldCopyAfterFailure = Self.shouldCopyAfterPasteFailure(
3315
+ outcome: outcome,
3316
+ deliveryKind: deliveryKind,
3317
+ accessibilityTrusted: accessibilityTrusted,
3318
+ clipboardOwnershipWasLost: clipboardOwnershipWasLost,
3319
+ completedTranscriptAlreadyOnClipboard: completedTranscriptAlreadyOnClipboard
3320
+ )
3321
+ let copiedAfterFailure = shouldCopyAfterFailure
3322
+ && Self.writeClipboardPreservingOnFailure(transaction.text, to: .general)
3323
+ self.log("paste outcome=\(outcome) target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost) transaction=\(transaction.id)")
3324
+ if let pipelineTrace {
3325
+ self.log(pipelineTrace.message(
3326
+ stage: posted ? "paste_posted" : "paste_failed",
3327
+ detail: "chars=\(transaction.text.count)"
3328
+ ))
3329
+ }
3330
+ deliveryCompleted?()
3331
+ let message = switch outcome {
3332
+ case .pasted: "Pasted (\(transaction.text.count) chars)"
3333
+ case .targetUnavailable: Self.targetUnavailableDeliveryStatus(
3334
+ deliveryKind: deliveryKind,
3335
+ accessibilityTrusted: accessibilityTrusted,
3336
+ clipboardOwnershipWasLost: clipboardOwnershipWasLost,
3337
+ completedTranscriptAlreadyOnClipboard: completedTranscriptAlreadyOnClipboard,
3338
+ fallbackWriteRequested: shouldCopyAfterFailure,
3339
+ fallbackWriteSucceeded: copiedAfterFailure
3340
+ )
3341
+ case .clipboardOwnershipLost: "Paste cancelled because the clipboard changed"
3342
+ case .clipboardWriteFailed: "Paste failed because the clipboard could not be updated"
3343
+ case .eventPostFailed: restoreClipboard
3344
+ ? "Paste failed because the paste event could not be posted"
3345
+ : "Copied, but paste event could not be posted"
3346
+ }
3347
+ self.updateDeliveryStatus(
3348
+ message,
3349
+ kind: posted ? .success : .failure,
3350
+ pipelineGeneration: transaction.generation
3351
+ )
3352
+ } settlement: { transaction, outcome in
3353
+ let pasteboard = NSPasteboard.general
3354
+ let stillOwnsChangeCount = ownedPasteboardChangeCount.map {
3355
+ pasteboard.changeCount == $0
3356
+ } ?? false
3357
+ let stillOwnsPayload = ownedPasteboardChangeCount.map {
3358
+ Self.clipboardStillOwned(pasteboard, text: transaction.text, changeCount: $0)
3359
+ } ?? false
3360
+ if Self.clipboardOwnershipWasLostAfterPasteFailure(
3361
+ outcome: outcome,
3362
+ hasOwnershipToken: ownedPasteboardChangeCount != nil,
3363
+ stillOwnsPayload: stillOwnsPayload
3364
+ ) {
3365
+ clipboardOwnershipWasLost = true
3366
+ }
3367
+ guard let previousClipboard else { return }
3368
+ let shouldRestore = switch outcome {
3369
+ case .clipboardWriteFailed:
3370
+ stillOwnsChangeCount
3371
+ case .targetUnavailable, .clipboardOwnershipLost, .eventPostFailed, .pasted:
3372
+ stillOwnsPayload
1141
3373
  }
3374
+ if shouldRestore {
3375
+ previousClipboard.restore(to: pasteboard)
3376
+ }
3377
+ }
3378
+ guard accepted else {
3379
+ log("paste transaction rejected because another delivery is pending")
3380
+ updateDeliveryStatus(
3381
+ "Finish the previous paste before trying again",
3382
+ kind: .failure,
3383
+ pipelineGeneration: pipelineGeneration
3384
+ )
3385
+ deliveryCompleted?()
3386
+ return
3387
+ }
3388
+ }
3389
+
3390
+ nonisolated static func clipboardOwnershipWasLostAfterPasteFailure(
3391
+ outcome: PasteDeliveryOutcome,
3392
+ hasOwnershipToken: Bool,
3393
+ stillOwnsPayload: Bool
3394
+ ) -> Bool {
3395
+ outcome == .targetUnavailable && hasOwnershipToken && !stillOwnsPayload
3396
+ }
3397
+
3398
+ @discardableResult
3399
+ private nonisolated static func writeClipboard(_ text: String, to pasteboard: NSPasteboard) -> Bool {
3400
+ writeClipboardAttempt(text, to: pasteboard).verified
3401
+ }
3402
+
3403
+ @discardableResult
3404
+ nonisolated static func writeClipboardPreservingOnFailure(
3405
+ _ text: String,
3406
+ to pasteboard: NSPasteboard
3407
+ ) -> Bool {
3408
+ let previousClipboard = ClipboardSnapshot(pasteboard: pasteboard)
3409
+ let result = writeClipboardAttempt(text, to: pasteboard)
3410
+ guard !result.verified else { return true }
3411
+ if pasteboard.changeCount == result.ownershipChangeCount {
3412
+ previousClipboard.restore(to: pasteboard)
3413
+ }
3414
+ return false
3415
+ }
3416
+
3417
+ nonisolated static func writeClipboardAttempt(
3418
+ _ text: String,
3419
+ to pasteboard: NSPasteboard
3420
+ ) -> PasteboardWriteResult {
3421
+ let clearedChangeCount = pasteboard.clearContents()
3422
+ guard pasteboard.setString(text, forType: .string) else {
3423
+ return PasteboardWriteResult(
3424
+ verified: false,
3425
+ ownershipChangeCount: clearedChangeCount
3426
+ )
1142
3427
  }
3428
+ let writtenChangeCount = pasteboard.changeCount
3429
+ let storedText = pasteboard.string(forType: .string)
3430
+ return PasteboardWriteResult(
3431
+ verified: pasteboard.changeCount == writtenChangeCount && storedText == text,
3432
+ ownershipChangeCount: writtenChangeCount
3433
+ )
1143
3434
  }
1144
3435
 
1145
- private func selectedRunningPasteTarget(targetAppBundleIdentifier: String?, targetAppPid: pid_t?, frontmostPid: pid_t?) -> NSRunningApplication? {
3436
+ nonisolated static func clipboardStillOwned(
3437
+ _ pasteboard: NSPasteboard,
3438
+ text: String,
3439
+ changeCount: Int
3440
+ ) -> Bool {
3441
+ pasteboard.changeCount == changeCount && pasteboard.string(forType: .string) == text
3442
+ }
3443
+
3444
+ nonisolated static func pasteTargetIsReady(
3445
+ expectedPid: pid_t,
3446
+ expectedBundleIdentifier: String?,
3447
+ frontmostPid: pid_t?,
3448
+ frontmostBundleIdentifier: String?,
3449
+ accessibilityTrusted: Bool,
3450
+ expectedLaunchDate: Date? = nil,
3451
+ frontmostLaunchDate: Date? = nil,
3452
+ requiresProcessIdentity: Bool = false
3453
+ ) -> Bool {
3454
+ let processIdentityMatches = if requiresProcessIdentity {
3455
+ expectedLaunchDate != nil && frontmostLaunchDate == expectedLaunchDate
3456
+ } else {
3457
+ expectedLaunchDate == nil || frontmostLaunchDate == expectedLaunchDate
3458
+ }
3459
+ return accessibilityTrusted
3460
+ && frontmostPid == expectedPid
3461
+ && frontmostBundleIdentifier == expectedBundleIdentifier
3462
+ && processIdentityMatches
3463
+ }
3464
+
3465
+ nonisolated static func shouldCopyPasteFallback(deliveryKind: PasteDeliveryKind) -> Bool {
3466
+ deliveryKind != .commandRewrite
3467
+ }
3468
+
3469
+ nonisolated static func shouldCopyAfterPasteFailure(
3470
+ outcome: PasteDeliveryOutcome,
3471
+ deliveryKind: PasteDeliveryKind,
3472
+ accessibilityTrusted: Bool,
3473
+ clipboardOwnershipWasLost: Bool = false,
3474
+ completedTranscriptAlreadyOnClipboard: Bool = false
3475
+ ) -> Bool {
3476
+ outcome == .targetUnavailable
3477
+ && !accessibilityTrusted
3478
+ && !clipboardOwnershipWasLost
3479
+ && !completedTranscriptAlreadyOnClipboard
3480
+ && shouldCopyPasteFallback(deliveryKind: deliveryKind)
3481
+ }
3482
+
3483
+ nonisolated static func targetUnavailableDeliveryStatus(
3484
+ deliveryKind: PasteDeliveryKind,
3485
+ accessibilityTrusted: Bool,
3486
+ clipboardOwnershipWasLost: Bool,
3487
+ completedTranscriptAlreadyOnClipboard: Bool,
3488
+ fallbackWriteRequested: Bool,
3489
+ fallbackWriteSucceeded: Bool
3490
+ ) -> String {
3491
+ if fallbackWriteSucceeded {
3492
+ return "Copied — Accessibility permission changed"
3493
+ }
3494
+ if completedTranscriptAlreadyOnClipboard {
3495
+ return accessibilityTrusted
3496
+ ? "Copied — target app lost focus"
3497
+ : "Copied — Accessibility permission changed"
3498
+ }
3499
+ if clipboardOwnershipWasLost {
3500
+ return "Paste cancelled because the clipboard changed"
3501
+ }
3502
+ if !accessibilityTrusted && deliveryKind == .commandRewrite {
3503
+ return "Paste cancelled because Accessibility permission changed"
3504
+ }
3505
+ if fallbackWriteRequested {
3506
+ return "Transcription ready, but the clipboard could not be updated"
3507
+ }
3508
+ return "Paste cancelled because the target app lost focus"
3509
+ }
3510
+
3511
+ nonisolated static func stableAccessibilityDocumentIdentifier(_ candidate: String?) -> String? {
3512
+ guard let candidate,
3513
+ !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3514
+ return nil
3515
+ }
3516
+ return candidate
3517
+ }
3518
+
3519
+ nonisolated static func stableAccessibilityContextIdentifier(
3520
+ documentIdentifier: String?,
3521
+ elementIdentifier: String?
3522
+ ) -> String? {
3523
+ if let documentIdentifier = stableAccessibilityDocumentIdentifier(documentIdentifier) {
3524
+ return "document:\(documentIdentifier)"
3525
+ }
3526
+ // AXIdentifier identifies a control, not the document shown in it. Editors
3527
+ // commonly reuse one control and window across tabs, so fail closed without
3528
+ // an independently document-specific AX identity.
3529
+ _ = elementIdentifier
3530
+ return nil
3531
+ }
3532
+
3533
+ enum DeliveryStatusKind: Equatable, Sendable {
3534
+ case progress
3535
+ case success
3536
+ case failure
3537
+ }
3538
+
3539
+ nonisolated static func flowPhase(
3540
+ forDeliveryStatus message: String,
3541
+ kind: DeliveryStatusKind
3542
+ ) -> RecordingFlowPhase {
3543
+ switch kind {
3544
+ case .progress: .processing(message)
3545
+ case .success: .ready(message)
3546
+ case .failure: .failed(message)
3547
+ }
3548
+ }
3549
+
3550
+ private func updateDeliveryStatus(
3551
+ _ message: String,
3552
+ kind: DeliveryStatusKind,
3553
+ pipelineGeneration: UInt64?
3554
+ ) {
3555
+ if let pipelineGeneration {
3556
+ guard pipelineDeliveryGate.shouldApplyStatus(
3557
+ deliveryGeneration: pipelineGeneration,
3558
+ currentGeneration: recordingGeneration,
3559
+ isRecording: isRecording,
3560
+ isTranscribing: isTranscribing
3561
+ ) else {
3562
+ log("delivery status suppressed for superseded pipeline generation=\(pipelineGeneration)")
3563
+ return
3564
+ }
3565
+ }
3566
+ statusMessage = message
3567
+ flowPhase = Self.flowPhase(forDeliveryStatus: message, kind: kind)
3568
+ }
3569
+
3570
+ private func selectedRunningPasteTarget(
3571
+ targetAppBundleIdentifier: String?,
3572
+ targetAppPid: pid_t?,
3573
+ frontmostPid: pid_t?,
3574
+ pipelineGeneration: UInt64?
3575
+ ) -> NSRunningApplication? {
1146
3576
  let myPID = ProcessInfo.processInfo.processIdentifier
1147
3577
  let runningApps = NSWorkspace.shared.runningApplications
1148
3578
  let candidates = runningApps.map {
1149
3579
  PasteTargetCandidate(
1150
3580
  pid: $0.processIdentifier,
1151
3581
  bundleIdentifier: $0.bundleIdentifier,
1152
- isRegularApp: $0.activationPolicy == .regular
3582
+ isRegularApp: $0.activationPolicy == .regular,
3583
+ launchDate: $0.launchDate
1153
3584
  )
1154
3585
  }
3586
+ let requiredProcessIdentity = pipelineGeneration.flatMap {
3587
+ pasteTargetProcessIdentityByGeneration[$0]
3588
+ }
1155
3589
  let selectedTarget = Self.selectPasteTarget(
1156
3590
  candidates: candidates,
1157
3591
  currentPid: myPID,
1158
3592
  targetBundleIdentifier: targetAppBundleIdentifier,
1159
3593
  targetPid: targetAppPid,
1160
- frontmostPid: frontmostPid
3594
+ frontmostPid: frontmostPid,
3595
+ requiredProcessIdentity: requiredProcessIdentity,
3596
+ requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
1161
3597
  )
1162
3598
  return selectedTarget.flatMap { selected in
1163
3599
  runningApps.first { $0.processIdentifier == selected.pid }
@@ -1169,21 +3605,55 @@ public final class RecordingEngine: ObservableObject {
1169
3605
  currentPid: pid_t,
1170
3606
  targetBundleIdentifier: String?,
1171
3607
  targetPid: pid_t?,
1172
- frontmostPid: pid_t? = nil
3608
+ frontmostPid: pid_t? = nil,
3609
+ requiredProcessIdentity: PasteTargetProcessIdentity? = nil,
3610
+ requiresProcessIdentity: Bool = false
1173
3611
  ) -> PasteTargetCandidate? {
1174
- candidates.first {
1175
- guard let targetPid else { return false }
1176
- return $0.pid == targetPid && $0.pid != currentPid
1177
- } ?? candidates.first {
1178
- guard $0.pid != currentPid, $0.isRegularApp else { return false }
1179
- guard let targetBundleIdentifier else { return false }
1180
- return $0.bundleIdentifier == targetBundleIdentifier
1181
- } ?? candidates.first {
3612
+ if let targetPid {
3613
+ guard let targetBundleIdentifier else { return nil }
3614
+ let selected = candidates.first {
3615
+ $0.pid == targetPid
3616
+ && $0.pid != currentPid
3617
+ && $0.bundleIdentifier == targetBundleIdentifier
3618
+ }
3619
+ guard let selected else { return nil }
3620
+ if requiresProcessIdentity {
3621
+ guard let requiredProcessIdentity,
3622
+ requiredProcessIdentity.pid == targetPid,
3623
+ requiredProcessIdentity.bundleIdentifier == targetBundleIdentifier,
3624
+ requiredProcessIdentity.matches(selected) else { return nil }
3625
+ } else if let requiredProcessIdentity,
3626
+ !requiredProcessIdentity.matches(selected) {
3627
+ return nil
3628
+ }
3629
+ return selected
3630
+ }
3631
+ if let targetBundleIdentifier {
3632
+ return candidates.first {
3633
+ $0.pid != currentPid
3634
+ && $0.isRegularApp
3635
+ && $0.bundleIdentifier == targetBundleIdentifier
3636
+ }
3637
+ }
3638
+ return candidates.first {
1182
3639
  guard let frontmostPid else { return false }
1183
3640
  return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
1184
3641
  }
1185
3642
  }
1186
3643
 
3644
+ private nonisolated static func monotonicMilliseconds() -> UInt64 {
3645
+ UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
3646
+ }
3647
+
3648
+ nonisolated static func realtimePeriodicCommitIsDue(
3649
+ nowMilliseconds: UInt64,
3650
+ lastCommitMilliseconds: UInt64?
3651
+ ) -> Bool {
3652
+ guard let lastCommitMilliseconds else { return true }
3653
+ guard nowMilliseconds >= lastCommitMilliseconds else { return false }
3654
+ return nowMilliseconds - lastCommitMilliseconds >= realtimePeriodicCommitIntervalMilliseconds
3655
+ }
3656
+
1187
3657
  nonisolated static func resolveFinalTranscript(
1188
3658
  cliText: String?,
1189
3659
  cliError: String?,
@@ -1198,28 +3668,6 @@ public final class RecordingEngine: ObservableObject {
1198
3668
  return (nil, cliError ?? "Empty transcription")
1199
3669
  }
1200
3670
 
1201
- private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
1202
- if AXIsProcessTrusted() {
1203
- return true
1204
- }
1205
- if !prompt {
1206
- return false
1207
- }
1208
- return AXIsProcessTrustedWithOptions(
1209
- ["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
1210
- )
1211
- }
1212
-
1213
- private func shouldPromptAccessibility() -> Bool {
1214
- let now = Date()
1215
- if let lastAccessibilityPromptAt,
1216
- now.timeIntervalSince(lastAccessibilityPromptAt) < 20 {
1217
- return false
1218
- }
1219
- lastAccessibilityPromptAt = now
1220
- return true
1221
- }
1222
-
1223
3671
  private func log(_ message: String) {
1224
3672
  NativeAppLog.write(message, homePath: home)
1225
3673
  }
@@ -1241,7 +3689,7 @@ private final class LockedFlag: @unchecked Sendable {
1241
3689
  private struct ClipboardSnapshot {
1242
3690
  private let items: [[NSPasteboard.PasteboardType: Data]]
1243
3691
 
1244
- init?(pasteboard: NSPasteboard) {
3692
+ init(pasteboard: NSPasteboard) {
1245
3693
  let capturedItems = pasteboard.pasteboardItems?.compactMap { item -> [NSPasteboard.PasteboardType: Data]? in
1246
3694
  let dataByType = item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { result, type in
1247
3695
  if let data = item.data(forType: type) {
@@ -1250,7 +3698,6 @@ private struct ClipboardSnapshot {
1250
3698
  }
1251
3699
  return dataByType.isEmpty ? nil : dataByType
1252
3700
  } ?? []
1253
- guard !capturedItems.isEmpty else { return nil }
1254
3701
  items = capturedItems
1255
3702
  }
1256
3703
 
@@ -1269,43 +3716,911 @@ private struct ClipboardSnapshot {
1269
3716
 
1270
3717
  // MARK: - CLI Runner
1271
3718
 
3719
+ private final class ProcessDataCapture: @unchecked Sendable {
3720
+ private let lock = NSLock()
3721
+ private var storage = Data()
3722
+ private var finished = false
3723
+
3724
+ func append(_ data: Data) {
3725
+ lock.lock()
3726
+ defer { lock.unlock() }
3727
+ guard !finished else { return }
3728
+ storage.append(data)
3729
+ }
3730
+
3731
+ func finish() -> Bool {
3732
+ lock.lock()
3733
+ defer { lock.unlock() }
3734
+ guard !finished else { return false }
3735
+ finished = true
3736
+ return true
3737
+ }
3738
+
3739
+ var data: Data {
3740
+ lock.lock()
3741
+ defer { lock.unlock() }
3742
+ return storage
3743
+ }
3744
+ }
3745
+
1272
3746
  enum CLIRunner: Sendable {
1273
- static func run(_ args: [String], home: String) -> String {
1274
- let bin = "\(home)/.bun/bin/recordings"
1275
- let proc = Process()
1276
- let outPipe = Pipe()
1277
- let errPipe = Pipe()
1278
- if FileManager.default.fileExists(atPath: bin) {
1279
- proc.executableURL = URL(fileURLWithPath: bin)
1280
- proc.arguments = args
1281
- } else {
1282
- proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
1283
- proc.arguments = ["recordings"] + args
3747
+ enum CaptureOperation: Sendable {
3748
+ case read
3749
+ case poll
3750
+
3751
+ fileprivate var description: String {
3752
+ switch self {
3753
+ case .read: "reading"
3754
+ case .poll: "waiting for"
3755
+ }
3756
+ }
3757
+ }
3758
+
3759
+ enum ExecutionError: Error, LocalizedError, Equatable {
3760
+ case timedOut(executable: String, seconds: TimeInterval)
3761
+ case captureFailed(operation: CaptureOperation, code: Int32)
3762
+
3763
+ var errorDescription: String? {
3764
+ switch self {
3765
+ case let .timedOut(executable, seconds):
3766
+ return "Command timed out after \(seconds.formatted()) seconds: \(executable)"
3767
+ case let .captureFailed(operation, code):
3768
+ return "Failed to capture command output while \(operation.description): \(String(cString: strerror(code)))"
3769
+ }
1284
3770
  }
1285
- proc.environment = ProcessInfo.processInfo.environment.merging([
3771
+ }
3772
+
3773
+ struct Command: Sendable {
3774
+ let executable: String
3775
+ let argumentsPrefix: [String]
3776
+ }
3777
+
3778
+ struct ProcessOutput: Sendable {
3779
+ let stdout: String
3780
+ let stderr: String
3781
+ let terminationStatus: Int32
3782
+ }
3783
+
3784
+ enum ProcessLifecycleEvent: Equatable, Sendable {
3785
+ case leaderExitObserved
3786
+ case processGroupSignaled(Int32)
3787
+ case leaderReaped(Int32)
3788
+ }
3789
+
3790
+ typealias LeaderReaper = (
3791
+ _ processIdentifier: pid_t,
3792
+ _ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
3793
+ ) throws -> Int32
3794
+
3795
+ static func run(
3796
+ _ args: [String],
3797
+ home: String,
3798
+ timeout: TimeInterval = 120,
3799
+ totalWallClockBudget: TimeInterval? = nil
3800
+ ) -> String {
3801
+ let command = resolveCommand(home: home)
3802
+ let arguments = command.argumentsPrefix + args
3803
+ let environment = ProcessInfo.processInfo.environment.merging([
1286
3804
  "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
1287
3805
  ]) { _, new in new }
1288
- proc.standardOutput = outPipe
1289
- proc.standardError = errPipe
1290
3806
  do {
1291
- try proc.run()
1292
- proc.waitUntilExit()
1293
- let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
1294
- let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
1295
- if proc.terminationStatus != 0 {
1296
- let details = stderr.isEmpty ? stdout : stderr
1297
- return "ERROR: \(details.trimmingCharacters(in: .whitespacesAndNewlines))"
1298
- }
1299
- return stdout.isEmpty ? stderr : stdout
3807
+ let output = try runExecutable(
3808
+ command.executable,
3809
+ arguments: arguments,
3810
+ environment: environment,
3811
+ executionTimeout: timeout,
3812
+ totalWallClockBudget: totalWallClockBudget
3813
+ )
3814
+ if output.terminationStatus != 0 {
3815
+ let details = output.stderr.isEmpty ? output.stdout : output.stderr
3816
+ return "ERROR: \(NativeErrorSanitizer.sanitize(details.trimmingCharacters(in: .whitespacesAndNewlines)))"
3817
+ }
3818
+ return output.stdout.isEmpty
3819
+ ? NativeErrorSanitizer.sanitize(output.stderr)
3820
+ : output.stdout
1300
3821
  } catch {
1301
- return "ERROR: \(error.localizedDescription)"
3822
+ return "ERROR: \(NativeErrorSanitizer.sanitize(error.localizedDescription))"
3823
+ }
3824
+ }
3825
+
3826
+ static func resolveCommand(
3827
+ home: String,
3828
+ bundleURL: URL = Bundle.main.bundleURL,
3829
+ fileManager: FileManager = .default
3830
+ ) -> Command {
3831
+ let bundled = bundleURL.appendingPathComponent("Contents/Helpers/recordings")
3832
+ let isPackagedApp = bundleURL.pathExtension.caseInsensitiveCompare("app") == .orderedSame
3833
+ if isPackagedApp || fileManager.fileExists(atPath: bundled.path) {
3834
+ return Command(executable: bundled.path, argumentsPrefix: [])
3835
+ }
3836
+
3837
+ // SwiftPM development and test runs do not have an app bundle. Retain an
3838
+ // explicit local fallback there; packaged apps exclusively use their helper.
3839
+ let userCLI = "\(home)/.bun/bin/recordings"
3840
+ if fileManager.fileExists(atPath: userCLI) {
3841
+ return Command(executable: userCLI, argumentsPrefix: [])
3842
+ }
3843
+ return Command(executable: "/usr/bin/env", argumentsPrefix: ["recordings"])
3844
+ }
3845
+
3846
+ /// Wall-clock time reserved out of the execution window when `totalWallClockBudget` is
3847
+ /// set, so termination grace, kill grace, and pipe drain land inside the budget with
3848
+ /// scheduling margin to spare.
3849
+ static let wallClockCleanupReserve: TimeInterval = 1
3850
+
3851
+ static func runExecutable(
3852
+ _ executable: String,
3853
+ arguments: [String],
3854
+ environment: [String: String]? = nil,
3855
+ executionTimeout: TimeInterval = 120,
3856
+ terminationGracePeriod: TimeInterval = 0.5,
3857
+ forceKillGracePeriod: TimeInterval = 1,
3858
+ pipeDrainTimeout: TimeInterval = 2,
3859
+ totalWallClockBudget: TimeInterval? = nil,
3860
+ beforeExecutionDeadline: (() -> Void)? = nil,
3861
+ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)? = nil,
3862
+ leaderReaper: LeaderReaper? = nil,
3863
+ captureSystemCalls: PipeCaptureReader.SystemCalls = .live
3864
+ ) throws -> ProcessOutput {
3865
+ precondition(executionTimeout.isFinite && executionTimeout > 0)
3866
+ precondition(terminationGracePeriod.isFinite && terminationGracePeriod >= 0)
3867
+ precondition(forceKillGracePeriod.isFinite && forceKillGracePeriod >= 0)
3868
+ precondition(pipeDrainTimeout.isFinite && pipeDrainTimeout >= 0)
3869
+ if let totalWallClockBudget {
3870
+ precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
3871
+ }
3872
+
3873
+ // The budget clock starts before the spawn so setup latency cannot extend the
3874
+ // observable wall time. Every wait below is clamped to what is left of it.
3875
+ let wallClockDeadline = totalWallClockBudget.map { monotonicUptimeDeadline(after: $0) }
3876
+ func clampedToWallClockBudget(
3877
+ _ phaseTimeout: TimeInterval,
3878
+ reserving reserve: TimeInterval = 0
3879
+ ) -> TimeInterval {
3880
+ guard let wallClockDeadline else { return phaseTimeout }
3881
+ let now = DispatchTime.now().uptimeNanoseconds
3882
+ let remaining = wallClockDeadline > now
3883
+ ? Double(wallClockDeadline - now) / 1_000_000_000 - reserve
3884
+ : 0
3885
+ return max(0, min(phaseTimeout, remaining))
3886
+ }
3887
+ let contractualExecutionTimeout = totalWallClockBudget
3888
+ .map { min(executionTimeout, $0 - wallClockCleanupReserve) } ?? executionTimeout
3889
+
3890
+ let stdoutReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
3891
+ let stderrReader: PipeCaptureReader
3892
+ do {
3893
+ stderrReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
3894
+ } catch {
3895
+ stdoutReader.closeWriteDescriptor()
3896
+ _ = finishCaptures([stdoutReader], pipeDrainTimeout: 0)
3897
+ throw error
3898
+ }
3899
+ let captureReaders = [stdoutReader, stderrReader]
3900
+
3901
+ let processIdentifier: pid_t
3902
+ do {
3903
+ processIdentifier = try spawnProcessGroup(
3904
+ executable,
3905
+ arguments: arguments,
3906
+ environment: environment,
3907
+ stdoutDescriptor: stdoutReader.writeDescriptor,
3908
+ stderrDescriptor: stderrReader.writeDescriptor,
3909
+ stdoutReadDescriptor: stdoutReader.readDescriptor,
3910
+ stderrReadDescriptor: stderrReader.readDescriptor
3911
+ )
3912
+ } catch {
3913
+ stdoutReader.closeWriteDescriptor()
3914
+ stderrReader.closeWriteDescriptor()
3915
+ _ = finishCaptures(captureReaders, pipeDrainTimeout: 0)
3916
+ throw error
3917
+ }
3918
+ stdoutReader.closeWriteDescriptor()
3919
+ stderrReader.closeWriteDescriptor()
3920
+
3921
+ beforeExecutionDeadline?()
3922
+ let leaderExitWasObserved: Bool
3923
+ let didTimeOut: Bool
3924
+ do {
3925
+ leaderExitWasObserved = try waitForUnreapedLeaderExit(
3926
+ processIdentifier,
3927
+ timeout: clampedToWallClockBudget(executionTimeout, reserving: wallClockCleanupReserve),
3928
+ lifecycleObserver: lifecycleObserver
3929
+ )
3930
+ didTimeOut = !leaderExitWasObserved
3931
+ } catch {
3932
+ signalProcessGroup(processIdentifier, signal: SIGKILL, lifecycleObserver: lifecycleObserver)
3933
+ reapLeaderInBackground(processIdentifier)
3934
+ _ = finishCaptures(
3935
+ captureReaders,
3936
+ pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
3937
+ )
3938
+ throw error
3939
+ }
3940
+
3941
+ // Keep the direct child unreaped until every group-directed signal has
3942
+ // been sent. Its zombie reserves the process-group identifier, so a PID
3943
+ // reuse cannot redirect cleanup to an unrelated process group.
3944
+ signalProcessGroup(processIdentifier, signal: SIGTERM, lifecycleObserver: lifecycleObserver)
3945
+ var confirmedExit = leaderExitWasObserved
3946
+ if didTimeOut {
3947
+ do {
3948
+ confirmedExit = try waitForUnreapedLeaderExit(
3949
+ processIdentifier,
3950
+ timeout: clampedToWallClockBudget(terminationGracePeriod),
3951
+ lifecycleObserver: lifecycleObserver
3952
+ )
3953
+ } catch {
3954
+ signalProcessGroup(processIdentifier, signal: SIGKILL, lifecycleObserver: lifecycleObserver)
3955
+ reapLeaderInBackground(processIdentifier)
3956
+ _ = finishCaptures(
3957
+ captureReaders,
3958
+ pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
3959
+ )
3960
+ throw error
3961
+ }
3962
+ } else {
3963
+ let drainDeadline = monotonicDispatchDeadline(
3964
+ after: clampedToWallClockBudget(terminationGracePeriod)
3965
+ )
3966
+ for reader in captureReaders {
3967
+ reader.waitUntilExited(deadline: drainDeadline)
3968
+ }
3969
+ }
3970
+ signalProcessGroup(processIdentifier, signal: SIGKILL, lifecycleObserver: lifecycleObserver)
3971
+ if !confirmedExit {
3972
+ do {
3973
+ confirmedExit = try waitForUnreapedLeaderExit(
3974
+ processIdentifier,
3975
+ timeout: clampedToWallClockBudget(forceKillGracePeriod),
3976
+ lifecycleObserver: lifecycleObserver
3977
+ )
3978
+ } catch {
3979
+ reapLeaderInBackground(processIdentifier)
3980
+ _ = finishCaptures(
3981
+ captureReaders,
3982
+ pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
3983
+ )
3984
+ throw error
3985
+ }
3986
+ }
3987
+ let terminationStatus: Int32?
3988
+ if confirmedExit {
3989
+ do {
3990
+ terminationStatus = try leaderReaper?(
3991
+ processIdentifier,
3992
+ lifecycleObserver
3993
+ ) ?? reapLeader(
3994
+ processIdentifier,
3995
+ lifecycleObserver: lifecycleObserver
3996
+ )
3997
+ } catch {
3998
+ reapLeaderInBackground(processIdentifier)
3999
+ _ = finishCaptures(
4000
+ captureReaders,
4001
+ pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
4002
+ )
4003
+ throw error
4004
+ }
4005
+ } else {
4006
+ reapLeaderInBackground(processIdentifier)
4007
+ terminationStatus = nil
4008
+ }
4009
+
4010
+ let captureError = finishCaptures(
4011
+ captureReaders,
4012
+ pipeDrainTimeout: clampedToWallClockBudget(pipeDrainTimeout)
4013
+ )
4014
+
4015
+ if didTimeOut {
4016
+ throw ExecutionError.timedOut(executable: executable, seconds: contractualExecutionTimeout)
4017
+ }
4018
+ if let captureError {
4019
+ throw captureError
4020
+ }
4021
+
4022
+ // Both readers are joined by now, so these snapshots can never observe a
4023
+ // truncated mid-append state.
4024
+ return ProcessOutput(
4025
+ stdout: String(decoding: stdoutReader.data, as: UTF8.self),
4026
+ stderr: String(decoding: stderrReader.data, as: UTF8.self),
4027
+ terminationStatus: terminationStatus ?? 1
4028
+ )
4029
+ }
4030
+
4031
+ private static func spawnProcessGroup(
4032
+ _ executable: String,
4033
+ arguments: [String],
4034
+ environment: [String: String]?,
4035
+ stdoutDescriptor: Int32,
4036
+ stderrDescriptor: Int32,
4037
+ stdoutReadDescriptor: Int32,
4038
+ stderrReadDescriptor: Int32
4039
+ ) throws -> pid_t {
4040
+ var duplicatedDescriptors: [Int32] = []
4041
+ defer {
4042
+ for descriptor in duplicatedDescriptors {
4043
+ Darwin.close(descriptor)
4044
+ }
4045
+ }
4046
+ let childStdoutDescriptor = try nonStandardDescriptor(
4047
+ stdoutDescriptor,
4048
+ duplicates: &duplicatedDescriptors
4049
+ )
4050
+ let childStderrDescriptor = try nonStandardDescriptor(
4051
+ stderrDescriptor,
4052
+ duplicates: &duplicatedDescriptors
4053
+ )
4054
+
4055
+ var fileActions: posix_spawn_file_actions_t?
4056
+ try checkPOSIX(posix_spawn_file_actions_init(&fileActions), operation: "initialize spawn file actions")
4057
+ defer { posix_spawn_file_actions_destroy(&fileActions) }
4058
+
4059
+ try checkPOSIX(
4060
+ posix_spawn_file_actions_adddup2(&fileActions, childStdoutDescriptor, STDOUT_FILENO),
4061
+ operation: "configure command stdout"
4062
+ )
4063
+ try checkPOSIX(
4064
+ posix_spawn_file_actions_adddup2(&fileActions, childStderrDescriptor, STDERR_FILENO),
4065
+ operation: "configure command stderr"
4066
+ )
4067
+ let inheritedDescriptors = Set([
4068
+ stdoutReadDescriptor,
4069
+ stderrReadDescriptor,
4070
+ stdoutDescriptor,
4071
+ stderrDescriptor,
4072
+ childStdoutDescriptor,
4073
+ childStderrDescriptor,
4074
+ ]).filter { $0 != STDOUT_FILENO && $0 != STDERR_FILENO }
4075
+ for descriptor in inheritedDescriptors {
4076
+ try checkPOSIX(
4077
+ posix_spawn_file_actions_addclose(&fileActions, descriptor),
4078
+ operation: "close inherited command descriptor"
4079
+ )
4080
+ }
4081
+
4082
+ var attributes: posix_spawnattr_t?
4083
+ try checkPOSIX(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes")
4084
+ defer { posix_spawnattr_destroy(&attributes) }
4085
+ var defaultSignals = sigset_t()
4086
+ Darwin.sigemptyset(&defaultSignals)
4087
+ Darwin.sigaddset(&defaultSignals, SIGTERM)
4088
+ try checkPOSIX(
4089
+ posix_spawnattr_setsigdefault(&attributes, &defaultSignals),
4090
+ operation: "reset command termination signal"
4091
+ )
4092
+ var unblockedSignals = sigset_t()
4093
+ Darwin.sigemptyset(&unblockedSignals)
4094
+ try checkPOSIX(
4095
+ posix_spawnattr_setsigmask(&attributes, &unblockedSignals),
4096
+ operation: "unblock command signals"
4097
+ )
4098
+ let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK
4099
+ try checkPOSIX(
4100
+ posix_spawnattr_setflags(&attributes, Int16(spawnFlags)),
4101
+ operation: "configure command process group"
4102
+ )
4103
+ try checkPOSIX(
4104
+ posix_spawnattr_setpgroup(&attributes, 0),
4105
+ operation: "configure command process group leader"
4106
+ )
4107
+
4108
+ let environmentValues = (environment ?? ProcessInfo.processInfo.environment)
4109
+ .map { "\($0.key)=\($0.value)" }
4110
+ var processIdentifier: pid_t = 0
4111
+ let spawnResult = withMutableCStringArray([executable] + arguments) { argumentVector in
4112
+ withMutableCStringArray(environmentValues) { environmentVector in
4113
+ posix_spawn(
4114
+ &processIdentifier,
4115
+ executable,
4116
+ &fileActions,
4117
+ &attributes,
4118
+ argumentVector,
4119
+ environmentVector
4120
+ )
4121
+ }
4122
+ }
4123
+ try checkPOSIX(spawnResult, operation: "launch command")
4124
+ return processIdentifier
4125
+ }
4126
+
4127
+ private static func nonStandardDescriptor(
4128
+ _ descriptor: Int32,
4129
+ duplicates: inout [Int32]
4130
+ ) throws -> Int32 {
4131
+ guard descriptor == STDOUT_FILENO || descriptor == STDERR_FILENO else {
4132
+ return descriptor
4133
+ }
4134
+ let duplicate = Darwin.fcntl(descriptor, F_DUPFD_CLOEXEC, 3)
4135
+ guard duplicate != -1 else {
4136
+ throw NSError(
4137
+ domain: NSPOSIXErrorDomain,
4138
+ code: Int(errno),
4139
+ userInfo: [NSLocalizedDescriptionKey: "Failed to duplicate command descriptor: \(String(cString: strerror(errno)))"]
4140
+ )
4141
+ }
4142
+ duplicates.append(duplicate)
4143
+ return duplicate
4144
+ }
4145
+
4146
+ private static func withMutableCStringArray<Result>(
4147
+ _ strings: [String],
4148
+ body: (UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) throws -> Result
4149
+ ) rethrows -> Result {
4150
+ var pointers = strings.map { strdup($0) }
4151
+ pointers.append(nil)
4152
+ defer {
4153
+ for pointer in pointers where pointer != nil {
4154
+ free(pointer)
4155
+ }
4156
+ }
4157
+ return try pointers.withUnsafeMutableBufferPointer { buffer in
4158
+ try body(buffer.baseAddress!)
4159
+ }
4160
+ }
4161
+
4162
+ private static func checkPOSIX(_ result: Int32, operation: String) throws {
4163
+ guard result == 0 else {
4164
+ throw NSError(
4165
+ domain: NSPOSIXErrorDomain,
4166
+ code: Int(result),
4167
+ userInfo: [NSLocalizedDescriptionKey: "Failed to \(operation): \(String(cString: strerror(result)))"]
4168
+ )
4169
+ }
4170
+ }
4171
+
4172
+ private static func waitForUnreapedLeaderExit(
4173
+ _ processIdentifier: pid_t,
4174
+ timeout: TimeInterval,
4175
+ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
4176
+ ) throws -> Bool {
4177
+ let deadline = monotonicUptimeDeadline(after: timeout)
4178
+ repeat {
4179
+ var information = siginfo_t()
4180
+ var result: Int32
4181
+ repeat {
4182
+ result = Darwin.waitid(
4183
+ P_PID,
4184
+ id_t(processIdentifier),
4185
+ &information,
4186
+ WEXITED | WNOHANG | WNOWAIT
4187
+ )
4188
+ } while result == -1 && errno == EINTR
4189
+ guard result == 0 else {
4190
+ throw NSError(
4191
+ domain: NSPOSIXErrorDomain,
4192
+ code: Int(errno),
4193
+ userInfo: [NSLocalizedDescriptionKey: "Failed to observe command exit: \(String(cString: strerror(errno)))"]
4194
+ )
4195
+ }
4196
+ if information.si_pid == processIdentifier {
4197
+ lifecycleObserver?(.leaderExitObserved)
4198
+ return true
4199
+ }
4200
+ if DispatchTime.now().uptimeNanoseconds >= deadline { return false }
4201
+ usleep(10_000)
4202
+ } while true
4203
+ }
4204
+
4205
+ private static func signalProcessGroup(
4206
+ _ processGroup: pid_t,
4207
+ signal: Int32,
4208
+ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
4209
+ ) {
4210
+ lifecycleObserver?(.processGroupSignaled(signal))
4211
+ _ = Darwin.kill(-processGroup, signal)
4212
+ }
4213
+
4214
+ private static func reapLeader(
4215
+ _ processIdentifier: pid_t,
4216
+ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
4217
+ ) throws -> Int32 {
4218
+ var waitStatus: Int32 = 0
4219
+ var waitResult: pid_t
4220
+ repeat {
4221
+ waitResult = Darwin.waitpid(processIdentifier, &waitStatus, 0)
4222
+ } while waitResult == -1 && errno == EINTR
4223
+ guard waitResult == processIdentifier else {
4224
+ throw NSError(
4225
+ domain: NSPOSIXErrorDomain,
4226
+ code: Int(errno),
4227
+ userInfo: [NSLocalizedDescriptionKey: "Failed to reap command: \(String(cString: strerror(errno)))"]
4228
+ )
4229
+ }
4230
+ let terminationStatus = decode(waitStatus: waitStatus)
4231
+ lifecycleObserver?(.leaderReaped(terminationStatus))
4232
+ return terminationStatus
4233
+ }
4234
+
4235
+ private static func reapLeaderInBackground(_ processIdentifier: pid_t) {
4236
+ DispatchQueue.global(qos: .utility).async {
4237
+ var waitStatus: Int32 = 0
4238
+ var waitResult: pid_t
4239
+ repeat {
4240
+ waitResult = Darwin.waitpid(processIdentifier, &waitStatus, 0)
4241
+ } while waitResult == -1 && errno == EINTR
4242
+ }
4243
+ }
4244
+
4245
+ private static func decode(waitStatus: Int32) -> Int32 {
4246
+ let signal = waitStatus & 0x7f
4247
+ if signal == 0 {
4248
+ return (waitStatus >> 8) & 0xff
4249
+ }
4250
+ return 128 + signal
4251
+ }
4252
+
4253
+ private static func monotonicDispatchDeadline(after timeout: TimeInterval) -> DispatchTime {
4254
+ DispatchTime(uptimeNanoseconds: monotonicUptimeDeadline(after: timeout))
4255
+ }
4256
+
4257
+ private static func monotonicUptimeDeadline(after timeout: TimeInterval) -> UInt64 {
4258
+ let now = DispatchTime.now().uptimeNanoseconds
4259
+ let maximumDelay = min(UInt64(Int64.max), UInt64.max - now)
4260
+ let requestedNanoseconds = timeout * 1_000_000_000
4261
+ let nanoseconds = requestedNanoseconds >= Double(maximumDelay)
4262
+ ? maximumDelay
4263
+ : UInt64(requestedNanoseconds.rounded(.up))
4264
+ return now + nanoseconds
4265
+ }
4266
+
4267
+ /// Shuts capture down deterministically. Waits up to `pipeDrainTimeout` for the
4268
+ /// readers to observe end-of-file on their own — the complete-output path — then
4269
+ /// cancels and joins whatever is still running. Both reader threads have provably
4270
+ /// exited and both pipe read ends are closed before this returns: a silent escaped
4271
+ /// descendant still holding a write end observes a widowed pipe at that point, and no
4272
+ /// snapshot of captured output can race a reader mid-append.
4273
+ private static func finishCaptures(
4274
+ _ readers: [PipeCaptureReader],
4275
+ pipeDrainTimeout: TimeInterval
4276
+ ) -> ExecutionError? {
4277
+ let deadline = monotonicDispatchDeadline(after: pipeDrainTimeout)
4278
+ for reader in readers {
4279
+ reader.waitUntilExited(deadline: deadline)
4280
+ }
4281
+ for reader in readers {
4282
+ reader.cancel()
4283
+ }
4284
+ for reader in readers {
4285
+ reader.join()
4286
+ }
4287
+ return readers.lazy.compactMap(\.terminalError).first
4288
+ }
4289
+
4290
+ /// Owns one capture pipe end to end: it creates the pipe, lends the write end to the
4291
+ /// spawn, and consumes the read end on a dedicated thread that multiplexes the
4292
+ /// nonblocking pipe with a private wakeup pipe through poll(2). `cancel()` writes one
4293
+ /// wakeup byte, so the thread deterministically leaves poll even when a silent
4294
+ /// escaped descendant keeps the write end open forever without writing a byte — POSIX
4295
+ /// does not promise that replacing or closing a descriptor interrupts a read(2)
4296
+ /// already blocked on it, so the blocking-read + dup2 revocation this replaces could
4297
+ /// leave the reader thread and the process's pipe reference alive indefinitely.
4298
+ ///
4299
+ /// The runner must keep owning every reference to the read descriptor itself:
4300
+ /// `FileHandle.readabilityHandler` keeps a private duplicate of the descriptor that
4301
+ /// can survive `close()` while data is flowing, which would leave the pipe readable
4302
+ /// forever — a descendant that inherited the write end would never observe EPIPE, and
4303
+ /// the descriptor would leak in this process. Never reintroduce it here.
4304
+ final class PipeCaptureReader: @unchecked Sendable {
4305
+ struct SystemCalls: @unchecked Sendable {
4306
+ let makePipe: (_ operation: String) throws -> (read: Int32, write: Int32)
4307
+ let fcntl: (_ descriptor: Int32, _ command: Int32, _ value: Int32?) -> Int32
4308
+ let close: (_ descriptor: Int32) -> Int32
4309
+ let read: (
4310
+ _ descriptor: Int32,
4311
+ _ buffer: UnsafeMutableRawPointer?,
4312
+ _ count: Int
4313
+ ) -> Int
4314
+ let poll: (
4315
+ _ descriptors: UnsafeMutablePointer<pollfd>?,
4316
+ _ count: nfds_t,
4317
+ _ timeout: Int32
4318
+ ) -> Int32
4319
+
4320
+ init(
4321
+ makePipe: @escaping (_ operation: String) throws -> (read: Int32, write: Int32) = {
4322
+ try PipeCaptureReader.makePipe(operation: $0)
4323
+ },
4324
+ fcntl: @escaping (
4325
+ _ descriptor: Int32,
4326
+ _ command: Int32,
4327
+ _ value: Int32?
4328
+ ) -> Int32 = { descriptor, command, value in
4329
+ if let value {
4330
+ return Darwin.fcntl(descriptor, command, value)
4331
+ }
4332
+ return Darwin.fcntl(descriptor, command)
4333
+ },
4334
+ close: @escaping (_ descriptor: Int32) -> Int32 = { Darwin.close($0) },
4335
+ read: @escaping (
4336
+ _ descriptor: Int32,
4337
+ _ buffer: UnsafeMutableRawPointer?,
4338
+ _ count: Int
4339
+ ) -> Int = { Darwin.read($0, $1, $2) },
4340
+ poll: @escaping (
4341
+ _ descriptors: UnsafeMutablePointer<pollfd>?,
4342
+ _ count: nfds_t,
4343
+ _ timeout: Int32
4344
+ ) -> Int32 = { Darwin.poll($0, $1, $2) }
4345
+ ) {
4346
+ self.makePipe = makePipe
4347
+ self.fcntl = fcntl
4348
+ self.close = close
4349
+ self.read = read
4350
+ self.poll = poll
4351
+ }
4352
+
4353
+ static let live = SystemCalls()
4354
+ }
4355
+
4356
+ /// Write end lent to the spawned child; the runner closes it through
4357
+ /// `closeWriteDescriptor()` once the child holds its own copies.
4358
+ let writeDescriptor: Int32
4359
+ /// Read end consumed — and eventually closed — exclusively by the reader thread.
4360
+ let readDescriptor: Int32
4361
+
4362
+ private let wakeupReadDescriptor: Int32
4363
+ private let wakeupWriteDescriptor: Int32
4364
+ private let systemCalls: SystemCalls
4365
+ private let capture = ProcessDataCapture()
4366
+ private let exited = DispatchSemaphore(value: 0)
4367
+ private let lock = NSLock()
4368
+ private var cancelRequested = false
4369
+ private var writeDescriptorClosed = false
4370
+ private var wakeupDescriptorsClosed = false
4371
+ private var storedTerminalError: ExecutionError?
4372
+
4373
+ /// Reads per drain burst between polls, so a flooding writer cannot starve the
4374
+ /// wakeup descriptor check.
4375
+ private static let drainReadLimit = 64
4376
+ /// Reads allowed after cancellation — comfortably above the kernel's largest pipe
4377
+ /// buffer, so an already-buffered tail is never dropped, yet `join()` stays
4378
+ /// prompt against a descendant that keeps writing.
4379
+ private static let cancelledDrainReadLimit = 16
4380
+
4381
+ init(systemCalls: SystemCalls = .live) throws {
4382
+ let dataPipe = try systemCalls.makePipe("create capture pipe")
4383
+ let wakeupPipe: (read: Int32, write: Int32)
4384
+ do {
4385
+ wakeupPipe = try systemCalls.makePipe("create capture wakeup pipe")
4386
+ } catch {
4387
+ _ = systemCalls.close(dataPipe.read)
4388
+ _ = systemCalls.close(dataPipe.write)
4389
+ throw error
4390
+ }
4391
+
4392
+ let descriptors = [dataPipe.read, dataPipe.write, wakeupPipe.read, wakeupPipe.write]
4393
+ var setupSucceeded = false
4394
+ defer {
4395
+ if !setupSucceeded {
4396
+ for descriptor in descriptors {
4397
+ _ = systemCalls.close(descriptor)
4398
+ }
4399
+ }
4400
+ }
4401
+
4402
+ for descriptor in descriptors {
4403
+ let descriptorFlags = try Self.checkedFcntl(
4404
+ descriptor,
4405
+ command: F_GETFD,
4406
+ operation: "read capture descriptor flags",
4407
+ systemCalls: systemCalls
4408
+ )
4409
+ _ = try Self.checkedFcntl(
4410
+ descriptor,
4411
+ command: F_SETFD,
4412
+ value: descriptorFlags | FD_CLOEXEC,
4413
+ operation: "protect capture descriptor from inheritance",
4414
+ systemCalls: systemCalls
4415
+ )
4416
+ }
4417
+ let readFlags = try Self.checkedFcntl(
4418
+ dataPipe.read,
4419
+ command: F_GETFL,
4420
+ operation: "read capture pipe status flags",
4421
+ systemCalls: systemCalls
4422
+ )
4423
+ _ = try Self.checkedFcntl(
4424
+ dataPipe.read,
4425
+ command: F_SETFL,
4426
+ value: readFlags | O_NONBLOCK,
4427
+ operation: "make capture pipe nonblocking",
4428
+ systemCalls: systemCalls
4429
+ )
4430
+
4431
+ readDescriptor = dataPipe.read
4432
+ writeDescriptor = dataPipe.write
4433
+ wakeupReadDescriptor = wakeupPipe.read
4434
+ wakeupWriteDescriptor = wakeupPipe.write
4435
+ self.systemCalls = systemCalls
4436
+ setupSucceeded = true
4437
+ Thread.detachNewThread { [self] in consumePipe() }
4438
+ }
4439
+
4440
+ deinit {
4441
+ // The reader thread retains this object until it has closed the read end.
4442
+ // Callers must still close/cancel/join; this only releases any remaining
4443
+ // owner-side descriptors after the reader has already exited.
4444
+ if !writeDescriptorClosed { _ = systemCalls.close(writeDescriptor) }
4445
+ if !wakeupDescriptorsClosed {
4446
+ _ = systemCalls.close(wakeupReadDescriptor)
4447
+ _ = systemCalls.close(wakeupWriteDescriptor)
4448
+ }
4449
+ }
4450
+
4451
+ /// Everything captured so far; stable and complete once `join()` has returned.
4452
+ var data: Data { capture.data }
4453
+
4454
+ /// A terminal capture failure, stable once `join()` has returned. Cancellation
4455
+ /// and ordinary end-of-file are not failures.
4456
+ var terminalError: ExecutionError? {
4457
+ lock.lock()
4458
+ defer { lock.unlock() }
4459
+ return storedTerminalError
4460
+ }
4461
+
4462
+ func closeWriteDescriptor() {
4463
+ lock.lock()
4464
+ defer { lock.unlock() }
4465
+ guard !writeDescriptorClosed else { return }
4466
+ writeDescriptorClosed = true
4467
+ _ = systemCalls.close(writeDescriptor)
4468
+ }
4469
+
4470
+ /// Waits until `deadline` for the reader thread to exit on its own — that is,
4471
+ /// for end-of-file once every write end is closed. Returns whether it has.
4472
+ @discardableResult
4473
+ func waitUntilExited(deadline: DispatchTime) -> Bool {
4474
+ guard exited.wait(timeout: deadline) == .success else { return false }
4475
+ exited.signal()
4476
+ return true
4477
+ }
4478
+
4479
+ /// Wakes the reader thread out of poll(2) even when the capture pipe never
4480
+ /// becomes readable again. Idempotent; never blocks.
4481
+ func cancel() {
4482
+ lock.lock()
4483
+ defer { lock.unlock() }
4484
+ guard !cancelRequested, !wakeupDescriptorsClosed else { return }
4485
+ cancelRequested = true
4486
+ var wakeupByte: UInt8 = 1
4487
+ var result: Int
4488
+ repeat {
4489
+ result = Darwin.write(wakeupWriteDescriptor, &wakeupByte, 1)
4490
+ } while result == -1 && errno == EINTR
4491
+ }
4492
+
4493
+ /// Blocks until the reader thread has provably exited and closed the pipe read
4494
+ /// end, then releases the wakeup pipe. Callers must `cancel()` first whenever the
4495
+ /// pipe may never reach end-of-file; the wakeup then bounds this wait to thread
4496
+ /// scheduling plus one final drain burst. Idempotent.
4497
+ func join() {
4498
+ exited.wait()
4499
+ exited.signal()
4500
+ lock.lock()
4501
+ defer { lock.unlock() }
4502
+ guard !wakeupDescriptorsClosed else { return }
4503
+ wakeupDescriptorsClosed = true
4504
+ _ = systemCalls.close(wakeupReadDescriptor)
4505
+ _ = systemCalls.close(wakeupWriteDescriptor)
4506
+ }
4507
+
4508
+ private func consumePipe() {
4509
+ Thread.current.name = "CLIRunner.PipeCaptureReader"
4510
+ var buffer = [UInt8](repeating: 0, count: 65_536)
4511
+ var cancelled = false
4512
+ readLoop: while true {
4513
+ var reads = 0
4514
+ var sawEndOfFile = false
4515
+ var sawFailure = false
4516
+ let readLimit = cancelled ? Self.cancelledDrainReadLimit : Self.drainReadLimit
4517
+ while reads < readLimit {
4518
+ let count = buffer.withUnsafeMutableBytes {
4519
+ systemCalls.read(readDescriptor, $0.baseAddress, $0.count)
4520
+ }
4521
+ if count > 0 {
4522
+ capture.append(Data(bytes: buffer, count: count))
4523
+ reads += 1
4524
+ continue
4525
+ }
4526
+ if count == 0 {
4527
+ sawEndOfFile = true
4528
+ } else if errno == EINTR {
4529
+ continue
4530
+ } else if errno != EAGAIN {
4531
+ storeTerminalError(operation: .read, code: errno)
4532
+ sawFailure = true
4533
+ }
4534
+ break
4535
+ }
4536
+ if sawEndOfFile || sawFailure || cancelled { break readLoop }
4537
+ var descriptors = [
4538
+ pollfd(fd: readDescriptor, events: Int16(POLLIN), revents: 0),
4539
+ pollfd(fd: wakeupReadDescriptor, events: Int16(POLLIN), revents: 0),
4540
+ ]
4541
+ let events = systemCalls.poll(&descriptors, 2, -1)
4542
+ if events == -1 {
4543
+ if errno == EINTR { continue readLoop }
4544
+ storeTerminalError(operation: .poll, code: errno)
4545
+ break readLoop
4546
+ }
4547
+ if descriptors[0].revents & Int16(POLLNVAL) != 0 {
4548
+ storeTerminalError(operation: .poll, code: EBADF)
4549
+ break readLoop
4550
+ }
4551
+ if descriptors[0].revents & Int16(POLLERR) != 0 {
4552
+ storeTerminalError(operation: .poll, code: EIO)
4553
+ break readLoop
4554
+ }
4555
+ if descriptors[1].revents & Int16(POLLNVAL) != 0 {
4556
+ storeTerminalError(operation: .poll, code: EBADF)
4557
+ break readLoop
4558
+ }
4559
+ if descriptors[1].revents & Int16(POLLERR) != 0 {
4560
+ storeTerminalError(operation: .poll, code: EIO)
4561
+ break readLoop
4562
+ }
4563
+ if descriptors[1].revents != 0 {
4564
+ // One final bounded drain of already-buffered data, then exit.
4565
+ cancelled = true
4566
+ }
4567
+ }
4568
+ _ = capture.finish()
4569
+ _ = systemCalls.close(readDescriptor)
4570
+ exited.signal()
4571
+ }
4572
+
4573
+ private func storeTerminalError(operation: CaptureOperation, code: Int32) {
4574
+ lock.lock()
4575
+ defer { lock.unlock() }
4576
+ guard storedTerminalError == nil else { return }
4577
+ storedTerminalError = .captureFailed(operation: operation, code: code)
4578
+ }
4579
+
4580
+ private static func makePipe(operation: String) throws -> (read: Int32, write: Int32) {
4581
+ var ends: [Int32] = [0, 0]
4582
+ guard Darwin.pipe(&ends) == 0 else {
4583
+ throw NSError(
4584
+ domain: NSPOSIXErrorDomain,
4585
+ code: Int(errno),
4586
+ userInfo: [NSLocalizedDescriptionKey: "Failed to \(operation): \(String(cString: strerror(errno)))"]
4587
+ )
4588
+ }
4589
+ return (ends[0], ends[1])
4590
+ }
4591
+
4592
+ private static func checkedFcntl(
4593
+ _ descriptor: Int32,
4594
+ command: Int32,
4595
+ value: Int32? = nil,
4596
+ operation: String,
4597
+ systemCalls: SystemCalls
4598
+ ) throws -> Int32 {
4599
+ var result: Int32
4600
+ repeat {
4601
+ result = systemCalls.fcntl(descriptor, command, value)
4602
+ } while result == -1 && errno == EINTR
4603
+ guard result != -1 else {
4604
+ let errorNumber = errno
4605
+ throw NSError(
4606
+ domain: NSPOSIXErrorDomain,
4607
+ code: Int(errorNumber),
4608
+ userInfo: [
4609
+ NSLocalizedDescriptionKey:
4610
+ "Failed to \(operation): \(String(cString: strerror(errorNumber)))"
4611
+ ]
4612
+ )
4613
+ }
4614
+ return result
1302
4615
  }
1303
4616
  }
1304
4617
 
1305
4618
  static func parseError(_ output: String) -> String? {
1306
4619
  let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
1307
4620
  guard trimmed.hasPrefix("ERROR:") else { return nil }
1308
- let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
4621
+ let message = NativeErrorSanitizer.sanitize(
4622
+ trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
4623
+ )
1309
4624
  let lowercased = message.lowercased()
1310
4625
  if lowercased.contains("401") || lowercased.contains("incorrect api key")
1311
4626
  || lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
@@ -1324,6 +4639,19 @@ enum CLIRunner: Sendable {
1324
4639
  return String(message.prefix(120))
1325
4640
  }
1326
4641
 
4642
+ /// The raw (pre-enhancement) transcript from a CLI JSON envelope. Intent decisions must
4643
+ /// run on this, never on `processed_text`.
4644
+ static func parseRawTranscript(_ output: String) -> String? {
4645
+ guard let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
4646
+ s.lowerBound < e.upperBound else { return nil }
4647
+ let json = String(output[s.lowerBound..<e.upperBound])
4648
+ guard let data = json.data(using: .utf8),
4649
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
4650
+ let raw = obj["raw_text"] as? String,
4651
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
4652
+ return raw
4653
+ }
4654
+
1327
4655
  static func parseJSON(_ output: String) -> String? {
1328
4656
  if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
1329
4657
  s.lowerBound < e.upperBound {