@hasna/recordings 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +56 -6
  2. package/contracts/v1/fixtures.json +1206 -0
  3. package/dist/cli/index.js +28 -7
  4. package/dist/contracts/hosted-v1.d.ts +105 -0
  5. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  6. package/dist/contracts/hosted-v1.js +41 -0
  7. package/dist/contracts/stream-v1.d.ts +117 -0
  8. package/dist/contracts/stream-v1.d.ts.map +1 -0
  9. package/dist/contracts/stream-v1.js +35 -0
  10. package/dist/hosted/index.d.ts +58 -0
  11. package/dist/hosted/index.d.ts.map +1 -0
  12. package/dist/hosted/index.js +266 -0
  13. package/dist/hosted/transport.d.ts +36 -0
  14. package/dist/hosted/transport.d.ts.map +1 -0
  15. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  16. package/dist/hosted-v1-gdr9extc.js +84 -0
  17. package/dist/index.js +27 -6
  18. package/dist/mcp/index.js +27 -6
  19. package/dist/server/index.js +27 -6
  20. package/dist/storage.js +27 -6
  21. package/docs/hosted-sdk.md +71 -0
  22. package/docs/wire-contracts.md +24 -0
  23. package/package.json +27 -6
  24. package/scripts/ci-linux-suite.ts +23 -13
  25. package/scripts/macos_artifact.ts +40 -33
  26. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  27. package/scripts/native/recordings_fs_guard.c +36 -4
  28. package/scripts/native-core-receipt.py +171 -0
  29. package/scripts/native_fs_guard.ts +2 -0
  30. package/scripts/release-suite-gate.ts +227 -150
  31. package/scripts/resolve_tailscale_cli.sh +24 -3
  32. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  33. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  34. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  35. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
  36. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  37. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
  38. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  39. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
  40. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  41. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  42. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  43. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  44. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  45. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +115 -2
  46. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
  47. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  48. package/src/native/Recordings/build.sh +2 -1
  49. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  50. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  51. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  52. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  53. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  54. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  55. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  56. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  57. package/dist/__tests__/preload.d.ts +0 -2
  58. package/dist/__tests__/preload.d.ts.map +0 -1
@@ -689,18 +689,19 @@ private final class PCMStreamPipe: @unchecked Sendable {
689
689
  guard !Task.isCancelled else { break }
690
690
  guard !data.isEmpty else { continue }
691
691
  recordedPCM.append(data)
692
+ // Providers need every admitted packet before a pause can settle.
693
+ // Only the legacy realtime client waits for network-sized chunks.
694
+ providerSession?.appendPCM(data)
692
695
  pendingChunk.append(data)
693
696
 
694
697
  while pendingChunk.count >= chunkSize {
695
698
  let chunk = pendingChunk.prefixData(count: chunkSize)
696
- providerSession?.appendPCM(chunk)
697
699
  await client?.sendAudio(chunk)
698
700
  pendingChunk.removeFirst(chunkSize)
699
701
  }
700
702
  }
701
703
 
702
704
  if !Task.isCancelled && !pendingChunk.isEmpty {
703
- providerSession?.appendPCM(pendingChunk)
704
705
  await client?.sendAudio(pendingChunk)
705
706
  }
706
707
  return recordedPCM
@@ -939,6 +940,10 @@ public final class RecordingEngine: ObservableObject {
939
940
  private var providerSession: (any RecordingTranscriptionSession)?
940
941
  private var providerConfiguration: RecordingProviderSessionConfiguration?
941
942
  private var providerCompletionTask: Task<Void, Never>?
943
+ /// Injection keeps persistence ordering testable without changing the public file contract.
944
+ var providerAudioWriter: @Sendable (Data, URL) throws -> Void = { pcm, url in
945
+ try RecordingEngine.writeWAV(pcmData: pcm, sampleRate: 24_000, channelCount: 1, bitsPerSample: 16, to: url)
946
+ }
942
947
 
943
948
  private var nativeRecorder: PCMRecordingSource?
944
949
  private var recordingTimer: Timer?
@@ -948,6 +953,7 @@ public final class RecordingEngine: ObservableObject {
948
953
  private var fnKeyIsDown = false
949
954
  private var targetAppBundleIdentifier: String?
950
955
  private var targetAppPid: pid_t?
956
+ private var frozenPasteTargetsByGeneration: [UInt64: RecordingPasteTargetSelection] = [:]
951
957
  private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
952
958
  public var projectStore: ProjectStore?
953
959
  /// The minimal app uses only global cleanup preferences from the legacy settings
@@ -980,6 +986,12 @@ public final class RecordingEngine: ObservableObject {
980
986
  launchDate: app.launchDate
981
987
  )
982
988
  }
989
+ var pasteTargetApplicationLookup: (pid_t) -> PasteApplicationObservation? = {
990
+ NSRunningApplication(processIdentifier: $0).map(PasteApplicationObservation.init)
991
+ }
992
+ var pasteFallbackWriter: (String) -> Bool = { text in
993
+ RecordingEngine.writeClipboardPreservingOnFailure(text, to: .general)
994
+ }
983
995
  var recorderFactory: (@escaping @Sendable (Data) -> Void) -> PCMRecordingSource = {
984
996
  NativePCMRecorder(onPCM: $0)
985
997
  }
@@ -990,12 +1002,15 @@ public final class RecordingEngine: ObservableObject {
990
1002
  RecordingEngine.focusedWindowTitle(pid: $0)
991
1003
  }
992
1004
  var commandCLI: @Sendable (_ args: [String], _ home: String, _ timeout: TimeInterval) -> String = { args, home, ceiling in
993
- // The caller's timeout is the public ceiling on *observable* wall time. CLIRunner's
1005
+ // The caller supplies the remaining public budget after queue admission. CLIRunner's
994
1006
  // total deadline (execution, termination grace, kill grace, pipe drain) sits a full
995
1007
  // return margin below it: spawn setup, waitid poll granularity, capture shutdown,
996
1008
  // and the hop back to the caller all run outside CLIRunner's clamped waits and must
997
1009
  // fit inside the reserved margin.
998
1010
  let cliDeadline = ceiling - RecordingEngine.commandRewriteReturnMargin
1011
+ guard cliDeadline > CLIRunner.wallClockCleanupReserve else {
1012
+ return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
1013
+ }
999
1014
  return CLIRunner.run(args, home: home, timeout: cliDeadline, totalWallClockBudget: cliDeadline)
1000
1015
  }
1001
1016
  /// Resolves and revalidates the frozen rewrite target immediately before a rewrite:
@@ -1227,6 +1242,23 @@ public final class RecordingEngine: ObservableObject {
1227
1242
  /// *observable* rewrite time under the public ceiling even when the execution window,
1228
1243
  /// termination grace, and pipe drain all run to exhaustion.
1229
1244
  nonisolated static let commandRewriteReturnMargin: TimeInterval = 1
1245
+
1246
+ /// Create this operation synchronously before awaiting the blocking queue: creating
1247
+ /// its deadline inside the submitted closure would give delayed work a fresh budget.
1248
+ nonisolated static func makeCommandRewriteOperation(
1249
+ args: [String],
1250
+ home: String,
1251
+ runCLI: @escaping @Sendable ([String], String, TimeInterval) -> String,
1252
+ deadline: CLIRunner.WallClockDeadline = .init(after: commandRewriteTimeout)
1253
+ ) -> @Sendable () -> String {
1254
+ return {
1255
+ let remaining = min(commandRewriteTimeout, deadline.remaining())
1256
+ guard remaining > commandRewriteReturnMargin + CLIRunner.wallClockCleanupReserve else {
1257
+ return "ERROR: \(CLIRunner.ExecutionError.deadlineExhausted.localizedDescription)"
1258
+ }
1259
+ return runCLI(args, home, remaining)
1260
+ }
1261
+ }
1230
1262
  /// Wait before each read-back of the target app's focused field. The window server
1231
1263
  /// delivers the posted keystroke asynchronously and the app then does its own work, so a
1232
1264
  /// read taken on the posting turn would report "unchanged" for a paste that is simply
@@ -1738,7 +1770,8 @@ public final class RecordingEngine: ObservableObject {
1738
1770
 
1739
1771
  // MARK: - Start Recording (Streaming)
1740
1772
 
1741
- public func startRecording(trigger: RecordingTrigger = .manual) {
1773
+ public func startRecording(trigger: RecordingTrigger = .manual,
1774
+ pasteTarget: RecordingPasteTargetSelection = .frontmostApplication) {
1742
1775
  guard Self.canBeginRecording(
1743
1776
  isRecording: isRecording,
1744
1777
  isTranscribing: isTranscribing,
@@ -1755,11 +1788,12 @@ public final class RecordingEngine: ObservableObject {
1755
1788
  }
1756
1789
  log("startRecording trigger=\(trigger) microphoneStatus=\(microphoneAuthorization().rawValue) accessibility=\(accessibilityTrustCheck())")
1757
1790
  recordingGeneration &+= 1
1758
- if pasteTargetProcessIdentityByGeneration.count >= 32 {
1791
+ if pasteTargetProcessIdentityByGeneration.count >= 32 || frozenPasteTargetsByGeneration.count >= 32 {
1759
1792
  let oldestRetainedGeneration = recordingGeneration > 16 ? recordingGeneration - 16 : 0
1760
1793
  pasteTargetProcessIdentityByGeneration = pasteTargetProcessIdentityByGeneration.filter {
1761
1794
  $0.key >= oldestRetainedGeneration
1762
1795
  }
1796
+ frozenPasteTargetsByGeneration = frozenPasteTargetsByGeneration.filter { $0.key >= oldestRetainedGeneration }
1763
1797
  }
1764
1798
  activeTrigger = trigger
1765
1799
  keyboardShortcutIsDown = trigger == .keyboardShortcut
@@ -1778,7 +1812,19 @@ public final class RecordingEngine: ObservableObject {
1778
1812
  setBlockedReason(nil, for: .delivery)
1779
1813
 
1780
1814
  let myPID = ProcessInfo.processInfo.processIdentifier
1781
- let frontmostApp = frontmostAppSnapshot()
1815
+ let frontmostApp: FrontmostAppSnapshot?
1816
+ switch pasteTarget {
1817
+ case .frontmostApplication:
1818
+ frontmostApp = frontmostAppSnapshot()
1819
+ case .frozen(let target):
1820
+ // Keep explicit nil distinct from omission, including when a previously
1821
+ // observed target terminated between the host's snapshot and this call.
1822
+ frozenPasteTargetsByGeneration[recordingGeneration] = pasteTarget
1823
+ if let target, target.processIdentifier != myPID,
1824
+ let app = pasteTargetApplicationLookup(target.processIdentifier), target.matches(app) {
1825
+ frontmostApp = FrontmostAppSnapshot(pid: target.processIdentifier, bundleIdentifier: target.bundleIdentifier, launchDate: target.launchDate)
1826
+ } else { frontmostApp = nil }
1827
+ }
1782
1828
  let isOwnApp = frontmostApp?.pid == myPID
1783
1829
  targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
1784
1830
  targetAppPid = isOwnApp ? nil : frontmostApp?.pid
@@ -1798,8 +1844,8 @@ public final class RecordingEngine: ObservableObject {
1798
1844
  // The selection is still frozen for every recording (not only an exposed "command
1799
1845
  // mode"), so a later command decision can only ever act on the exact text and
1800
1846
  // element that were selected when the user started speaking. The Accessibility IPC
1801
- // that reads it runs on a detached task, concurrently with recorder start: the
1802
- // microphone must never wait on a beachballing target app, and the MainActor stays
1847
+ // that reads it runs on a blocking-work queue, concurrently with recorder start:
1848
+ // neither the microphone nor a cooperative worker waits on a beachballing app. The MainActor stays
1803
1849
  // free to process the key-up that stops the recording. Skipped entirely when intent
1804
1850
  // detection is off — no command route exists to consume it.
1805
1851
  let shouldCaptureSelection = Self.shouldCaptureSelection(
@@ -1812,12 +1858,14 @@ public final class RecordingEngine: ObservableObject {
1812
1858
  let windowTitleLookup = focusedWindowTitleLookup
1813
1859
  let windowTitlePid = frontmostApp?.pid
1814
1860
  let axSnapshotTask = Task.detached(priority: .userInitiated) { () -> RecordingStartAXSnapshot in
1815
- let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
1816
- let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
1817
- return RecordingStartAXSnapshot(
1818
- selectionToken: selectionToken,
1819
- focusedWindowTitle: focusedWindowTitle
1820
- )
1861
+ await BlockingOperation.run {
1862
+ let selectionToken = shouldCaptureSelection ? capturePid.flatMap { captureSelection($0) } : nil
1863
+ let focusedWindowTitle = windowTitlePid.flatMap { windowTitleLookup($0) }
1864
+ return RecordingStartAXSnapshot(
1865
+ selectionToken: selectionToken,
1866
+ focusedWindowTitle: focusedWindowTitle
1867
+ )
1868
+ }
1821
1869
  }
1822
1870
 
1823
1871
  // Project auto-selection and the processing configuration resolve with the
@@ -2367,14 +2415,22 @@ public final class RecordingEngine: ObservableObject {
2367
2415
  let pcm = await pipe?.finish() ?? Data()
2368
2416
  guard !Task.isCancelled, let self,
2369
2417
  self.recordingGeneration == pipelineGeneration else { return }
2418
+ var timings = [pipelineTrace.message(stage: "pcm_drain_complete", detail: "pcm_bytes=\(pcm.count)")]
2419
+ // Capture timestamps now, but batch their I/O after completion. Instrumentation
2420
+ // must not add a log-file write before the early network commit or WAV write.
2421
+ defer { self.log(timings.joined(separator: "\n")) }
2370
2422
  do {
2371
2423
  guard !pcm.isEmpty, let audioPath else { throw RecordingProviderError.noAudio }
2372
2424
  let audioURL = URL(fileURLWithPath: audioPath)
2425
+ session.inputEnded()
2426
+ timings.append(pipelineTrace.message(stage: "provider_input_ended"))
2427
+ let writeAudio = self.providerAudioWriter
2373
2428
  try await Task.detached(priority: .userInitiated) {
2374
- try Self.writeWAV(pcmData: pcm, sampleRate: 24_000, channelCount: 1, bitsPerSample: 16, to: audioURL)
2429
+ try writeAudio(pcm, audioURL)
2375
2430
  }.value
2376
2431
  try Task.checkCancellation()
2377
2432
  guard self.recordingGeneration == pipelineGeneration else { return }
2433
+ timings.append(pipelineTrace.message(stage: "wav_write_complete"))
2378
2434
  let duration = Double(pcm.count) / 48_000
2379
2435
  self.recordingDuration = duration
2380
2436
  let result = try await session.finish(RecordingTranscriptionRequest(
@@ -2383,6 +2439,7 @@ public final class RecordingEngine: ObservableObject {
2383
2439
  ))
2384
2440
  try Task.checkCancellation()
2385
2441
  guard self.recordingGeneration == pipelineGeneration else { return }
2442
+ timings.append(pipelineTrace.message(stage: "provider_finish_complete"))
2386
2443
  let rawText = result.rawText.trimmingCharacters(in: .whitespacesAndNewlines)
2387
2444
  let processed = result.processedText?.trimmingCharacters(in: .whitespacesAndNewlines)
2388
2445
  let text = (processed?.isEmpty == false ? processed : nil) ?? rawText
@@ -4067,10 +4124,12 @@ public final class RecordingEngine: ObservableObject {
4067
4124
  activeProjectId: canonicalProjectId,
4068
4125
  processingConfiguration: processingConfiguration
4069
4126
  )
4070
- let runCLI = self.commandCLI
4071
- let result = await Task.detached {
4072
- runCLI(rewriteArguments, homePath, Self.commandRewriteTimeout)
4073
- }.value
4127
+ let rewriteOperation = Self.makeCommandRewriteOperation(
4128
+ args: rewriteArguments,
4129
+ home: homePath,
4130
+ runCLI: self.commandCLI
4131
+ )
4132
+ let result = await BlockingOperation.run(rewriteOperation)
4074
4133
  if self.canOwnBusyState(pipelineGeneration: pipelineGeneration) {
4075
4134
  self.isTranscribing = false
4076
4135
  self.liveTranscriptionText = ""
@@ -4272,13 +4331,21 @@ public final class RecordingEngine: ObservableObject {
4272
4331
  deliveryCompleted?()
4273
4332
  return
4274
4333
  }
4275
- let pb = NSPasteboard.general
4334
+ if let generation = pipelineGeneration, frozenPasteTargetsByGeneration[generation] != nil,
4335
+ pasteTargetProcessIdentityByGeneration[generation] == nil {
4336
+ completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
4337
+ pipelineGeneration: pipelineGeneration)
4338
+ deliveryCompleted?()
4339
+ return
4340
+ }
4276
4341
  var previousClipboard: ClipboardSnapshot?
4277
4342
 
4278
4343
  let accessibility = protectedOperationTrust()
4279
4344
  guard accessibility.trusted else {
4280
4345
  let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
4281
- let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
4346
+ let copied = shouldCopy && pasteFallbackWriter(text)
4347
+ appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
4348
+ pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
4282
4349
  log("paste blocked by accessibility permission")
4283
4350
  let message = if deliveryKind == .commandRewrite {
4284
4351
  "Paste cancelled because Accessibility permission changed"
@@ -4306,18 +4373,8 @@ public final class RecordingEngine: ObservableObject {
4306
4373
  )
4307
4374
 
4308
4375
  guard let app = targetApp else {
4309
- let shouldCopy = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind)
4310
- let copied = shouldCopy && Self.writeClipboardPreservingOnFailure(text, to: pb)
4311
- log("paste target app not found")
4312
- updateDeliveryStatus(
4313
- deliveryKind == .commandRewrite
4314
- ? "Paste cancelled because the target app is unavailable"
4315
- : copied
4316
- ? "Copied — no target app found"
4317
- : "Transcription ready, but the clipboard could not be updated",
4318
- kind: .failure,
4319
- pipelineGeneration: pipelineGeneration
4320
- )
4376
+ completeUnavailablePaste(text, deliveryKind: deliveryKind, captureID: captureID,
4377
+ pipelineGeneration: pipelineGeneration, fallbackBundle: targetAppBundleIdentifier)
4321
4378
  deliveryCompleted?()
4322
4379
  return
4323
4380
  }
@@ -4840,15 +4897,37 @@ public final class RecordingEngine: ObservableObject {
4840
4897
  setBlockedReason(nil, for: .pressConsumed)
4841
4898
  }
4842
4899
 
4900
+ private func appendUndeliveredPaste(text: String, copied: Bool, captureID: String?,
4901
+ pipelineGeneration: UInt64?, fallbackBundle: String? = nil) {
4902
+ let selection = pipelineGeneration.flatMap { frozenPasteTargetsByGeneration[$0] }
4903
+ let target: RecordingPasteTarget? = if case .frozen(let value) = selection { value } else { nil }
4904
+ recentPastes.insert(RecentPaste(text: text, bundleIdentifier: target?.bundleIdentifier ?? fallbackBundle,
4905
+ appName: target?.applicationName ?? fallbackBundle ?? "No target app",
4906
+ location: copied ? "Clipboard only" : "", status: copied ? "Copied; paste not delivered" : "Paste not delivered",
4907
+ verified: false, captureID: captureID, deliveryStatus: .notDelivered), at: 0)
4908
+ if recentPastes.count > 50 { recentPastes.removeLast() }
4909
+ }
4910
+
4911
+ private func completeUnavailablePaste(_ text: String, deliveryKind: PasteDeliveryKind, captureID: String?,
4912
+ pipelineGeneration: UInt64?, fallbackBundle: String? = nil) {
4913
+ let copied = Self.shouldCopyPasteFallback(deliveryKind: deliveryKind) && pasteFallbackWriter(text)
4914
+ appendUndeliveredPaste(text: text, copied: copied, captureID: captureID,
4915
+ pipelineGeneration: pipelineGeneration, fallbackBundle: fallbackBundle)
4916
+ log("paste target app not found")
4917
+ updateDeliveryStatus(deliveryKind == .commandRewrite
4918
+ ? "Paste cancelled because the target app is unavailable"
4919
+ : copied ? "Copied — no target app found" : "Transcription ready, but the clipboard could not be updated",
4920
+ kind: .failure, pipelineGeneration: pipelineGeneration)
4921
+ }
4922
+
4843
4923
  private func selectedRunningPasteTarget(
4844
4924
  targetAppBundleIdentifier: String?,
4845
4925
  targetAppPid: pid_t?,
4846
4926
  frontmostPid: pid_t?,
4847
4927
  pipelineGeneration: UInt64?
4848
4928
  ) -> NSRunningApplication? {
4849
- let myPID = ProcessInfo.processInfo.processIdentifier
4850
4929
  let runningApps = NSWorkspace.shared.runningApplications
4851
- let candidates = runningApps.map {
4930
+ let candidates = runningApps.filter { !$0.isTerminated }.map {
4852
4931
  PasteTargetCandidate(
4853
4932
  pid: $0.processIdentifier,
4854
4933
  bundleIdentifier: $0.bundleIdentifier,
@@ -4856,23 +4935,30 @@ public final class RecordingEngine: ObservableObject {
4856
4935
  launchDate: $0.launchDate
4857
4936
  )
4858
4937
  }
4859
- let requiredProcessIdentity = pipelineGeneration.flatMap {
4860
- pasteTargetProcessIdentityByGeneration[$0]
4861
- }
4862
- let selectedTarget = Self.selectPasteTarget(
4863
- candidates: candidates,
4864
- currentPid: myPID,
4865
- targetBundleIdentifier: targetAppBundleIdentifier,
4866
- targetPid: targetAppPid,
4867
- frontmostPid: frontmostPid,
4868
- requiredProcessIdentity: requiredProcessIdentity,
4869
- requiresProcessIdentity: pipelineGeneration != nil && targetAppPid != nil
4938
+ let selectedTarget = resolvePasteTarget(
4939
+ candidates: candidates, targetBundleIdentifier: targetAppBundleIdentifier,
4940
+ targetPid: targetAppPid, frontmostPid: frontmostPid, pipelineGeneration: pipelineGeneration
4870
4941
  )
4871
4942
  return selectedTarget.flatMap { selected in
4872
4943
  runningApps.first { $0.processIdentifier == selected.pid }
4873
4944
  }
4874
4945
  }
4875
4946
 
4947
+ func resolvePasteTarget(candidates: [PasteTargetCandidate], targetBundleIdentifier: String?,
4948
+ targetPid: pid_t?, frontmostPid: pid_t?, pipelineGeneration: UInt64?) -> PasteTargetCandidate? {
4949
+ let frozen = pipelineGeneration.flatMap { frozenPasteTargetsByGeneration[$0] }
4950
+ let identity = pipelineGeneration.flatMap { pasteTargetProcessIdentityByGeneration[$0] }
4951
+ if frozen != nil {
4952
+ // No bundle-only or foreground fallback for an explicitly frozen capture.
4953
+ guard let identity, targetPid == identity.pid,
4954
+ targetBundleIdentifier == identity.bundleIdentifier else { return nil }
4955
+ }
4956
+ return Self.selectPasteTarget(candidates: frozen == nil ? candidates : candidates.filter(\.isRegularApp),
4957
+ currentPid: ProcessInfo.processInfo.processIdentifier, targetBundleIdentifier: targetBundleIdentifier,
4958
+ targetPid: targetPid, frontmostPid: frontmostPid, requiredProcessIdentity: identity,
4959
+ requiresProcessIdentity: frozen != nil || (pipelineGeneration != nil && targetPid != nil))
4960
+ }
4961
+
4876
4962
  nonisolated static func selectPasteTarget(
4877
4963
  candidates: [PasteTargetCandidate],
4878
4964
  currentPid: pid_t,
@@ -5031,12 +5117,15 @@ enum CLIRunner: Sendable {
5031
5117
 
5032
5118
  enum ExecutionError: Error, LocalizedError, Equatable {
5033
5119
  case timedOut(executable: String, seconds: TimeInterval)
5120
+ case deadlineExhausted
5034
5121
  case captureFailed(operation: CaptureOperation, code: Int32)
5035
5122
 
5036
5123
  var errorDescription: String? {
5037
5124
  switch self {
5038
5125
  case let .timedOut(executable, seconds):
5039
5126
  return "Command timed out after \(seconds.formatted()) seconds: \(executable)"
5127
+ case .deadlineExhausted:
5128
+ return "Command timed out: insufficient time remains to start safely."
5040
5129
  case let .captureFailed(operation, code):
5041
5130
  return "Failed to capture command output while \(operation.description): \(String(cString: strerror(code)))"
5042
5131
  }
@@ -5065,27 +5154,76 @@ enum CLIRunner: Sendable {
5065
5154
  _ lifecycleObserver: ((ProcessLifecycleEvent) -> Void)?
5066
5155
  ) throws -> Int32
5067
5156
 
5157
+ /// An explicit monotonic deadline can cross queue and preparation boundaries without
5158
+ /// resetting the budget. The clock is injectable for deterministic boundary tests.
5159
+ struct WallClockDeadline: Sendable {
5160
+ private let expiresAt: UInt64
5161
+ private let now: @Sendable () -> UInt64
5162
+
5163
+ init(
5164
+ after seconds: TimeInterval,
5165
+ now: @escaping @Sendable () -> UInt64 = { DispatchTime.now().uptimeNanoseconds }
5166
+ ) {
5167
+ precondition(seconds.isFinite && seconds >= 0)
5168
+ let startedAt = now()
5169
+ let maximumDelay = min(UInt64(Int64.max), UInt64.max - startedAt)
5170
+ let requested = seconds * 1_000_000_000
5171
+ // Truncate fractional nanoseconds: rounding up could extend the configured
5172
+ // budget and reject its own deadline at a subsequent handoff validation.
5173
+ let delay = requested >= Double(maximumDelay)
5174
+ ? maximumDelay : UInt64(requested)
5175
+ expiresAt = startedAt + delay
5176
+ self.now = now
5177
+ }
5178
+
5179
+ func remaining(reserving reserve: TimeInterval = 0) -> TimeInterval {
5180
+ let current = now()
5181
+ guard expiresAt > current else { return 0 }
5182
+ return max(0, Double(expiresAt - current) / 1_000_000_000 - reserve)
5183
+ }
5184
+ }
5185
+
5186
+ private static func requireStartBudget(_ deadline: WallClockDeadline?) throws {
5187
+ if let deadline, deadline.remaining() <= wallClockCleanupReserve {
5188
+ throw ExecutionError.deadlineExhausted
5189
+ }
5190
+ }
5191
+
5068
5192
  static func run(
5069
5193
  _ args: [String],
5070
5194
  home: String,
5071
5195
  timeout: TimeInterval = 120,
5072
5196
  totalWallClockBudget: TimeInterval? = nil,
5073
- environment suppliedEnvironment: [String: String]? = nil
5197
+ environment suppliedEnvironment: [String: String]? = nil,
5198
+ wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
5199
+ environmentProvider: (() throws -> [String: String])? = nil
5074
5200
  ) -> String {
5075
- let command = resolveCommand(home: home)
5076
- let arguments = command.argumentsPrefix + args
5201
+ if let totalWallClockBudget {
5202
+ precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
5203
+ }
5204
+ precondition(suppliedDeadline == nil || totalWallClockBudget != nil)
5205
+ let deadline = suppliedDeadline ?? totalWallClockBudget.map { WallClockDeadline(after: $0) }
5206
+ if let suppliedDeadline, let totalWallClockBudget {
5207
+ precondition(suppliedDeadline.remaining() <= totalWallClockBudget)
5208
+ }
5077
5209
  do {
5078
- let environment = try suppliedEnvironment ?? ServiceAPIConfiguration.childEnvironment(
5210
+ try requireStartBudget(deadline)
5211
+ let command = resolveCommand(home: home)
5212
+ let arguments = command.argumentsPrefix + args
5213
+ try requireStartBudget(deadline)
5214
+ let environment = try suppliedEnvironment ?? environmentProvider?() ?? ServiceAPIConfiguration.childEnvironment(
5079
5215
  base: OpenAIAPIKeyStore.childEnvironment(base: ProcessInfo.processInfo.environment.merging([
5080
5216
  "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
5081
5217
  ]) { _, new in new }, homePath: home)
5082
5218
  )
5219
+ try requireStartBudget(deadline)
5083
5220
  let output = try runExecutable(
5084
5221
  command.executable,
5085
5222
  arguments: arguments,
5086
5223
  environment: environment,
5087
5224
  executionTimeout: timeout,
5088
- totalWallClockBudget: totalWallClockBudget
5225
+ totalWallClockBudget: totalWallClockBudget,
5226
+ wallClockDeadline: deadline
5089
5227
  )
5090
5228
  if output.terminationStatus != 0 {
5091
5229
  let details = output.stderr.isEmpty ? output.stdout : output.stderr
@@ -5133,6 +5271,7 @@ enum CLIRunner: Sendable {
5133
5271
  forceKillGracePeriod: TimeInterval = 1,
5134
5272
  pipeDrainTimeout: TimeInterval = 2,
5135
5273
  totalWallClockBudget: TimeInterval? = nil,
5274
+ wallClockDeadline suppliedDeadline: WallClockDeadline? = nil,
5136
5275
  beforeExecutionDeadline: (() -> Void)? = nil,
5137
5276
  lifecycleObserver: ((ProcessLifecycleEvent) -> Void)? = nil,
5138
5277
  leaderReaper: LeaderReaper? = nil,
@@ -5146,23 +5285,24 @@ enum CLIRunner: Sendable {
5146
5285
  precondition(totalWallClockBudget.isFinite && totalWallClockBudget > wallClockCleanupReserve)
5147
5286
  }
5148
5287
 
5149
- // The budget clock starts before the spawn so setup latency cannot extend the
5150
- // observable wall time. Every wait below is clamped to what is left of it.
5151
- let wallClockDeadline = totalWallClockBudget.map { monotonicUptimeDeadline(after: $0) }
5288
+ precondition(suppliedDeadline == nil || totalWallClockBudget != nil)
5289
+ if let suppliedDeadline, let totalWallClockBudget {
5290
+ precondition(suppliedDeadline.remaining() <= totalWallClockBudget)
5291
+ }
5292
+ // A caller that prepared the command supplies its original deadline. Direct
5293
+ // executable callers begin here; neither path resets a previously spent budget.
5294
+ let wallClockDeadline = suppliedDeadline ?? totalWallClockBudget.map { WallClockDeadline(after: $0) }
5152
5295
  func clampedToWallClockBudget(
5153
5296
  _ phaseTimeout: TimeInterval,
5154
5297
  reserving reserve: TimeInterval = 0
5155
5298
  ) -> TimeInterval {
5156
5299
  guard let wallClockDeadline else { return phaseTimeout }
5157
- let now = DispatchTime.now().uptimeNanoseconds
5158
- let remaining = wallClockDeadline > now
5159
- ? Double(wallClockDeadline - now) / 1_000_000_000 - reserve
5160
- : 0
5161
- return max(0, min(phaseTimeout, remaining))
5300
+ return min(phaseTimeout, wallClockDeadline.remaining(reserving: reserve))
5162
5301
  }
5163
5302
  let contractualExecutionTimeout = totalWallClockBudget
5164
5303
  .map { min(executionTimeout, $0 - wallClockCleanupReserve) } ?? executionTimeout
5165
5304
 
5305
+ try requireStartBudget(wallClockDeadline)
5166
5306
  let stdoutReader = try PipeCaptureReader(systemCalls: captureSystemCalls)
5167
5307
  let stderrReader: PipeCaptureReader
5168
5308
  do {
@@ -5176,6 +5316,7 @@ enum CLIRunner: Sendable {
5176
5316
 
5177
5317
  let processIdentifier: pid_t
5178
5318
  do {
5319
+ try requireStartBudget(wallClockDeadline)
5179
5320
  processIdentifier = try spawnProcessGroup(
5180
5321
  executable,
5181
5322
  arguments: arguments,
@@ -5355,6 +5496,30 @@ enum CLIRunner: Sendable {
5355
5496
  )
5356
5497
  }
5357
5498
 
5499
+ // CLOEXEC_DEFAULT also closes stdin unless it is explicitly inherited. Keep
5500
+ // the existing stdin behavior, including an already closed/CLOEXEC stream;
5501
+ // a capture pipe that reused fd 0 must still be closed by the actions above.
5502
+ if !inheritedDescriptors.contains(STDIN_FILENO) {
5503
+ var inputFlags: Int32
5504
+ repeat {
5505
+ inputFlags = Darwin.fcntl(STDIN_FILENO, F_GETFD)
5506
+ } while inputFlags == -1 && errno == EINTR
5507
+ let inputError = errno
5508
+ if inputFlags == -1 && inputError != EBADF {
5509
+ throw NSError(
5510
+ domain: NSPOSIXErrorDomain,
5511
+ code: Int(inputError),
5512
+ userInfo: [NSLocalizedDescriptionKey: "Failed to inspect command standard input"]
5513
+ )
5514
+ }
5515
+ if inputFlags >= 0 && inputFlags & FD_CLOEXEC == 0 {
5516
+ try checkPOSIX(
5517
+ posix_spawn_file_actions_addinherit_np(&fileActions, STDIN_FILENO),
5518
+ operation: "inherit command standard input"
5519
+ )
5520
+ }
5521
+ }
5522
+
5358
5523
  var attributes: posix_spawnattr_t?
5359
5524
  try checkPOSIX(posix_spawnattr_init(&attributes), operation: "initialize spawn attributes")
5360
5525
  defer { posix_spawnattr_destroy(&attributes) }
@@ -5371,7 +5536,10 @@ enum CLIRunner: Sendable {
5371
5536
  posix_spawnattr_setsigmask(&attributes, &unblockedSignals),
5372
5537
  operation: "unblock command signals"
5373
5538
  )
5374
- let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK
5539
+ // A different reader may still be between pipe() and FD_CLOEXEC setup.
5540
+ // Inherit only our explicit standard streams, never that unrelated pipe.
5541
+ let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF
5542
+ | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT
5375
5543
  try checkPOSIX(
5376
5544
  posix_spawnattr_setflags(&attributes, Int16(spawnFlags)),
5377
5545
  operation: "configure command process group"
@@ -0,0 +1,114 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ /// An observed app process, not a bundle-ID request to launch or find another instance.
5
+ public struct RecordingPasteTarget: Equatable, Sendable {
6
+ public let processIdentifier: pid_t
7
+ public let bundleIdentifier: String
8
+ public let launchDate: Date
9
+ public let applicationName: String
10
+
11
+ init?(observation: PasteApplicationObservation, currentPID: pid_t) {
12
+ guard observation.pid > 0, observation.pid != currentPID,
13
+ observation.isRegular, !observation.isTerminated,
14
+ let bundle = observation.bundleIdentifier, !bundle.isEmpty,
15
+ let launchDate = observation.launchDate else { return nil }
16
+ processIdentifier = observation.pid
17
+ bundleIdentifier = bundle
18
+ self.launchDate = launchDate
19
+ applicationName = observation.name ?? "Application"
20
+ }
21
+
22
+ func matches(_ observation: PasteApplicationObservation) -> Bool {
23
+ observation.pid == processIdentifier && observation.bundleIdentifier == bundleIdentifier
24
+ && observation.launchDate == launchDate && observation.isRegular && !observation.isTerminated
25
+ }
26
+ }
27
+
28
+ /// Omission preserves the recorder's existing frontmost-app behavior. A frozen nil
29
+ /// explicitly means no destination; it never adopts another app at delivery time.
30
+ public enum RecordingPasteTargetSelection: Equatable, Sendable {
31
+ case frontmostApplication
32
+ case frozen(RecordingPasteTarget?)
33
+ }
34
+
35
+ struct PasteApplicationObservation: Sendable {
36
+ var pid: pid_t
37
+ var bundleIdentifier: String?
38
+ var launchDate: Date?
39
+ var name: String?
40
+ var isRegular: Bool
41
+ var isTerminated: Bool
42
+
43
+ init(_ app: NSRunningApplication) {
44
+ pid = app.processIdentifier; bundleIdentifier = app.bundleIdentifier
45
+ launchDate = app.launchDate; name = app.localizedName
46
+ isRegular = app.activationPolicy == .regular; isTerminated = app.isTerminated
47
+ }
48
+ init(pid: pid_t, bundleIdentifier: String?, launchDate: Date?, name: String? = nil,
49
+ isRegular: Bool = true, isTerminated: Bool = false) {
50
+ self.pid = pid; self.bundleIdentifier = bundleIdentifier; self.launchDate = launchDate
51
+ self.name = name; self.isRegular = isRegular; self.isTerminated = isTerminated
52
+ }
53
+ }
54
+
55
+ /// Keep one tracker for the host application's lifetime, before presenting its UI.
56
+ /// Observes workspace activation only; never installs input handlers, queries AX,
57
+ /// activates apps, reads their documents, or accesses the clipboard.
58
+ @MainActor public final class RecordingPasteTargetTracker {
59
+ private let currentPID: pid_t
60
+ private let frontmost: () -> PasteApplicationObservation?
61
+ private let lookup: (pid_t) -> PasteApplicationObservation?
62
+ private var lastExternal: RecordingPasteTarget?
63
+ private var observers: [NSObjectProtocol] = []
64
+ private var center: NotificationCenter?
65
+
66
+ public convenience init() {
67
+ self.init(currentPID: ProcessInfo.processInfo.processIdentifier,
68
+ frontmost: { NSWorkspace.shared.frontmostApplication.map(PasteApplicationObservation.init) },
69
+ lookup: { NSRunningApplication(processIdentifier: $0).map(PasteApplicationObservation.init) })
70
+ let center = NSWorkspace.shared.notificationCenter
71
+ self.center = center
72
+ observers.append(center.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { [weak self] notification in
73
+ guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
74
+ let observation = PasteApplicationObservation(app)
75
+ MainActor.assumeIsolated { self?.observe(observation) }
76
+ })
77
+ observers.append(center.addObserver(forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main) { [weak self] notification in
78
+ guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
79
+ let observation = PasteApplicationObservation(app)
80
+ MainActor.assumeIsolated { self?.terminated(observation) }
81
+ })
82
+ }
83
+
84
+ init(currentPID: pid_t, frontmost: @escaping () -> PasteApplicationObservation?,
85
+ lookup: @escaping (pid_t) -> PasteApplicationObservation?) {
86
+ self.currentPID = currentPID; self.frontmost = frontmost; self.lookup = lookup
87
+ if let app = frontmost() { observe(app) }
88
+ }
89
+
90
+ isolated deinit { for observer in observers { center?.removeObserver(observer) } }
91
+
92
+ func observe(_ app: PasteApplicationObservation) {
93
+ guard let target = RecordingPasteTarget(observation: app, currentPID: currentPID) else { return }
94
+ lastExternal = target
95
+ }
96
+
97
+ func terminated(_ app: PasteApplicationObservation) {
98
+ guard let target = lastExternal, app.pid == target.processIdentifier,
99
+ app.bundleIdentifier == target.bundleIdentifier, app.launchDate == target.launchDate else { return }
100
+ lastExternal = nil
101
+ }
102
+
103
+ /// Revalidate now, then pass this value to startRecording as `.frozen(value)`.
104
+ /// A terminated or replaced process clears the remembered destination; older
105
+ /// apps are not searched as a substitute.
106
+ public func snapshot() -> RecordingPasteTarget? {
107
+ if let app = frontmost() { observe(app) }
108
+ guard let target = lastExternal else { return nil }
109
+ guard let live = lookup(target.processIdentifier), target.matches(live) else {
110
+ lastExternal = nil; return nil
111
+ }
112
+ return target
113
+ }
114
+ }