From afcb7f477262e2aea77a38046ed431774a5ce779 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Apr 2026 13:36:20 +0000 Subject: [PATCH 1/4] Release v0.6.1 --- assistant/package.json | 2 +- cli/package.json | 2 +- clients/Package.swift | 2 +- credential-executor/package.json | 2 +- gateway/package.json | 2 +- meta/package.json | 10 +++++----- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/assistant/package.json b/assistant/package.json index 8b2de2bb1ab..afbf08d7f30 100644 --- a/assistant/package.json +++ b/assistant/package.json @@ -1,6 +1,6 @@ { "name": "@vellumai/assistant", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "type": "module", "exports": { diff --git a/cli/package.json b/cli/package.json index 648c4cffc22..3e94bfb2632 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@vellumai/cli", - "version": "0.6.0", + "version": "0.6.1", "description": "CLI tools for vellum-assistant", "type": "module", "exports": { diff --git a/clients/Package.swift b/clients/Package.swift index d93e88d18aa..b1e4674f6ea 100644 --- a/clients/Package.swift +++ b/clients/Package.swift @@ -1,7 +1,7 @@ // swift-tools-version: 5.9 import PackageDescription -let appVersion = "0.6.0" +let appVersion = "0.6.1" let package = Package( name: "vellum-assistant", diff --git a/credential-executor/package.json b/credential-executor/package.json index db2606b1499..3e9aa627ac4 100644 --- a/credential-executor/package.json +++ b/credential-executor/package.json @@ -1,6 +1,6 @@ { "name": "@vellumai/credential-executor", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "type": "module", "exports": { diff --git a/gateway/package.json b/gateway/package.json index 1c1cdc4084b..300bb3aff92 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -1,6 +1,6 @@ { "name": "@vellumai/vellum-gateway", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "type": "module", "exports": { diff --git a/meta/package.json b/meta/package.json index 2595078e45b..dbeeb4ca119 100644 --- a/meta/package.json +++ b/meta/package.json @@ -1,6 +1,6 @@ { "name": "vellum", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "description": "Install the full Vellum stack locally", "bin": { @@ -15,10 +15,10 @@ "Dockerfile" ], "dependencies": { - "@vellumai/assistant": "0.6.0", - "@vellumai/cli": "0.6.0", - "@vellumai/credential-executor": "0.6.0", - "@vellumai/vellum-gateway": "0.6.0" + "@vellumai/assistant": "0.6.1", + "@vellumai/cli": "0.6.1", + "@vellumai/credential-executor": "0.6.1", + "@vellumai/vellum-gateway": "0.6.1" }, "overrides": { "lodash": "^4.18.0", From a649d60d7c801b0a182bfad5c8800b289fdf68be Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Mon, 6 Apr 2026 12:39:30 -0400 Subject: [PATCH 2/4] Cherry-pick fixes for v0.6.1 (#23785) * Increase teleport import timeout from 2 to 5 minutes (#23749) * increase teleport import timeout from 2 to 5 minutes * fix: update platform import timeout error message to say 5 minutes Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * Update billing tab copy: referral subtitle, remove earning cap note, move credit info to card subtitle (#23751) * fix(macos): always collapse thinking blocks by default (#23750) Thinking blocks were auto-expanding during streaming, showing a wall of text. Remove the auto-expand logic so blocks always start collapsed. Users can still manually expand them. The header already shows "Thinking..." vs "Thought process" as a streaming indicator. Closes LUM-729 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ashlee@vellum.ai * [LUM-684/LUM-726] Fix dictation crash: pass nil format to installTap (#23754) * Fix dictation crash: pass nil format to installTap, consolidate audio engine calls Pass nil for the format parameter in AVAudioNode.installTap(onBus:bufferSize:format:block:) so AVAudioEngine uses its own internal hardware format, which is always self-consistent. This prevents NSInternalInconsistencyException crashes caused by format.sampleRate != hwFormat.sampleRate when the cached format from outputFormat(forBus:) diverges from the engine's internal hardware format after audio route changes (Bluetooth, USB mic, AirPods mode switch). AudioEngineController.swift: - installTapAndStart() now passes nil instead of explicit format to installTap - Removed 6 now-unused methods: inputNodeFormat(), installTap(bufferSize:format:block:), removeTap(), prepare(), start(), prepareAndStart() OpenAIVoiceService.swift: - startRecording(): replaced separate inputNodeFormat/installTap/prepare/start chain with single installTapAndStart() call - startBargeInMonitor(): same migration to installTapAndStart() - Removed error-path removeTap() call (handled internally by installTapAndStart) Resolves: LUM-684, LUM-726 Co-Authored-By: tkheyfets * fix: use explicit block: parameter in guard statements for installTapAndStart Swift doesn't support trailing closure syntax with guard statements, causing compilation errors. Use explicit block: parameter label instead. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: tkheyfets Co-authored-by: Claude Opus 4.6 (1M context) * fix: replace contrast buttons with primary style (#23753) Remove all production usages of .contrast button style in favor of .primary. Fixes white-on-white button visibility issues in chat composer. Co-authored-by: Claude Opus 4.6 (1M context) * Inject host environment via transport hints (#23779) * refactor: discriminated union for transport metadata, remove iOS proxy setup (#23776) * feat: inject interface ID and macOS host environment into transport hints (#23777) * feat: send hostHomeDir and hostUsername from macOS client (#23778) * fix: remove iOS from proxy restoration in conversation-process.ts (#23782) --------- Co-authored-by: Carson Shaar Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ashlee@vellum.ai Co-authored-by: tkheyfets Co-authored-by: Tirman Sidhu --- assistant/src/daemon/conversation-process.ts | 8 +- .../src/daemon/handlers/conversations.ts | 3 +- assistant/src/daemon/server.ts | 16 +- .../src/runtime/routes/conversation-routes.ts | 28 +--- .../SettingsBillingReferralCard.swift | 2 +- .../Voice/AudioEngineController.swift | 137 ++++-------------- 6 files changed, 36 insertions(+), 158 deletions(-) diff --git a/assistant/src/daemon/conversation-process.ts b/assistant/src/daemon/conversation-process.ts index bd15692c182..19c6a6fb754 100644 --- a/assistant/src/daemon/conversation-process.ts +++ b/assistant/src/daemon/conversation-process.ts @@ -15,11 +15,7 @@ import type { TurnChannelContext, TurnInterfaceContext, } from "../channels/types.js"; -import { - parseChannelId, - parseInterfaceId, - supportsHostProxy, -} from "../channels/types.js"; +import { parseChannelId, parseInterfaceId } from "../channels/types.js"; import { getConfig } from "../config/loader.js"; import type { ContextWindowResult } from "../context/window-manager.js"; import { listPendingRequestsByConversationScope } from "../memory/canonical-guardian-store.js"; @@ -306,7 +302,7 @@ export async function drainQueue( const interfaceCtx = queuedInterfaceCtx ?? conversation.getTurnInterfaceContext(); const sourceInterface = interfaceCtx?.userMessageInterface; - if (sourceInterface && supportsHostProxy(sourceInterface)) { + if (sourceInterface === "macos") { conversation.restoreProxyAvailability(); conversation.addPreactivatedSkillId("computer-use"); } diff --git a/assistant/src/daemon/handlers/conversations.ts b/assistant/src/daemon/handlers/conversations.ts index 37da5e9ec47..39e23c637f2 100644 --- a/assistant/src/daemon/handlers/conversations.ts +++ b/assistant/src/daemon/handlers/conversations.ts @@ -4,7 +4,6 @@ import { type InterfaceId, parseChannelId, parseInterfaceId, - supportsHostProxy, } from "../../channels/types.js"; import { getConfig } from "../../config/loader.js"; import { @@ -302,7 +301,7 @@ export async function handleConversationCreate( // Only create the host bash proxy for desktop client interfaces that can // execute commands on the user's machine. Set before updateClient so // updateClient's call to hostBashProxy.updateSender targets the new proxy. - if (supportsHostProxy(transportInterface)) { + if (transportInterface === "macos") { const proxy = new HostBashProxy(sendEvent, (requestId) => { pendingInteractions.resolve(requestId); }); diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index fae484ecef7..38a63f14e04 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -17,7 +17,6 @@ import { type InterfaceId, parseChannelId, parseInterfaceId, - supportsHostProxy, } from "../channels/types.js"; import { getConfig } from "../config/loader.js"; import { onContactChange } from "../contacts/contact-events.js"; @@ -549,18 +548,10 @@ export class DaemonServer { } } - private broadcastConfigChanged(): void { - this.broadcast({ type: "config_changed" }); - } - private broadcastSoundsConfigUpdated(): void { this.broadcast({ type: "sounds_config_updated" }); } - private broadcastFeatureFlagsChanged(): void { - this.broadcast({ type: "feature_flags_changed" }); - } - private broadcastAvatarUpdated(): void { this.broadcast({ type: "avatar_updated", @@ -757,8 +748,6 @@ export class DaemonServer { () => this.broadcastIdentityChanged(), () => this.broadcastSoundsConfigUpdated(), () => this.broadcastAvatarUpdated(), - () => this.broadcastConfigChanged(), - () => this.broadcastFeatureFlagsChanged(), ); this.appSourceWatcher.start((appId) => this.handleAppSourceChange(appId)); @@ -1102,7 +1091,7 @@ export class DaemonServer { // Guard: don't replace an active proxy during concurrent turn races — // another request may have started processing between the isProcessing() // check above and the await on ensureActorScopedHistory(). - if (supportsHostProxy(resolvedInterface)) { + if (resolvedInterface === "macos") { if (!conversation.isProcessing() || !conversation.hostBashProxy) { conversation.setHostBashProxy( new HostBashProxy(conversation.getCurrentSender(), (requestId) => { @@ -1459,9 +1448,8 @@ export class DaemonServer { */ async getConversationForMessages( conversationId: string, - options?: ConversationCreateOptions, ): Promise { - return this.getOrCreateConversation(conversationId, options); + return this.getOrCreateConversation(conversationId); } /** diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index dd0bda18004..5e9e81a9c7d 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -17,7 +17,6 @@ import { isInteractiveInterface, parseChannelId, parseInterfaceId, - supportsHostProxy, } from "../../channels/types.js"; import { isHttpAuthDisabled } from "../../config/env.js"; import { getConfig } from "../../config/loader.js"; @@ -39,10 +38,6 @@ import { HostBashProxy } from "../../daemon/host-bash-proxy.js"; import { HostCuProxy } from "../../daemon/host-cu-proxy.js"; import { HostFileProxy } from "../../daemon/host-file-proxy.js"; import type { ServerMessage } from "../../daemon/message-protocol.js"; -import type { - MacosTransportMetadata, - NonMacosTransportMetadata, -} from "../../daemon/message-types/conversations.js"; import type { HeartbeatService } from "../../heartbeat/heartbeat-service.js"; import * as attachmentsStore from "../../memory/attachments-store.js"; import { @@ -947,8 +942,6 @@ export async function handleSendMessage( conversationType?: string; automated?: boolean; bypassSecretCheck?: boolean; - hostHomeDir?: string; - hostUsername?: string; }; const { conversationKey, content, attachmentIds } = body; @@ -1052,25 +1045,8 @@ export async function handleSendMessage( conversationType, }); const smDeps = deps.sendMessageDeps; - - // Build transport metadata from the request so the daemon can inject - // host environment hints (home directory, username) into the LLM context. - const transport = - sourceInterface === "macos" - ? ({ - channelId: sourceChannel, - interfaceId: "macos" as const, - hostHomeDir: body.hostHomeDir, - hostUsername: body.hostUsername, - } satisfies MacosTransportMetadata) - : ({ - channelId: sourceChannel, - interfaceId: sourceInterface, - } satisfies NonMacosTransportMetadata); - const conversation = await smDeps.getOrCreateConversation( mapping.conversationId, - { transport }, ); // Resolve guardian context from the AuthContext's actorPrincipalId. @@ -1141,7 +1117,7 @@ export async function handleSendMessage( // channels, headless) fall back to local execution. // Set the proxy BEFORE updateClient so updateClient's call to // hostBashProxy.updateSender targets the correct (new) proxy. - if (supportsHostProxy(sourceInterface)) { + if (sourceInterface === "macos") { // Reuse the existing proxy if the conversation is actively processing a // host bash request to avoid orphaning in-flight requests. if (!conversation.isProcessing() || !conversation.hostBashProxy) { @@ -1178,7 +1154,7 @@ export async function handleSendMessage( // When proxies are preserved during an active turn (non-desktop request while // processing), skip updating proxy senders to avoid degrading them. const preservingProxies = - conversation.isProcessing() && !supportsHostProxy(sourceInterface); + conversation.isProcessing() && sourceInterface !== "macos"; conversation.updateClient(onEvent, !isInteractive, { skipProxySenderUpdate: preservingProxies, }); diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift index e639eab62b0..08fb20d7dcb 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift @@ -46,7 +46,7 @@ struct SettingsBillingReferralCard: View { // MARK: - Has Code State private func hasCodeState(_ code: ReferralCodeResponse) -> some View { - SettingsCard(title: "Referrals", subtitle: "Share your referral link to earn up to 100 free credits.") { + SettingsCard(title: "Referrals", subtitle: "Share your referral link to earn up to 100 free credits") { VStack(alignment: .leading, spacing: VSpacing.lg) { // Referral URL row HStack(spacing: VSpacing.sm) { diff --git a/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift b/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift index 2cb90e63f14..cfe6ed34f3b 100644 --- a/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift +++ b/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift @@ -1,5 +1,4 @@ import AVFoundation -import AVFAudio import os private let log = Logger(subsystem: Bundle.appBundleIdentifier, category: "AudioEngineController") @@ -17,53 +16,14 @@ private let log = Logger(subsystem: Bundle.appBundleIdentifier, category: "Audio /// ensure `prewarm()` has run first so `inputNode` is already initialized and /// sync calls complete in sub-milliseconds. /// -/// Listens for `AVAudioEngineConfigurationChange` notifications to re-warm -/// `inputNode` after audio route changes (Bluetooth connect/disconnect, -/// AirPods mode switch, USB mic plug/unplug). -/// /// See: https://developer.apple.com/documentation/avfaudio/avaudionode/1387122-installtap final class AudioEngineController: @unchecked Sendable { private let audioEngine = AVAudioEngine() private let queue: DispatchQueue - private var configChangeObserver: (any NSObjectProtocol)? init(label: String = "com.vellum.audioEngine") { self.queue = DispatchQueue(label: label, qos: .userInitiated) - observeConfigurationChanges() - } - - deinit { - if let observer = configChangeObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - // MARK: - Configuration Change Monitoring - - /// Re-prewarm `inputNode` when the audio hardware configuration changes - /// (Bluetooth device connect/disconnect, USB mic plug/unplug, AirPods - /// mode switch). Keeps the cached inputNode format fresh so subsequent - /// `installTapAndStart` calls complete in sub-milliseconds. - /// - /// See: https://developer.apple.com/documentation/avfaudio/avaudioengine/1386063-configurationchangenotification - private func observeConfigurationChanges() { - configChangeObserver = NotificationCenter.default.addObserver( - forName: .AVAudioEngineConfigurationChange, - object: audioEngine, - queue: nil - ) { [weak self] _ in - guard let self else { return } - guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { - log.info("Audio configuration changed — skipping re-warm (mic not authorized)") - return - } - log.info("Audio configuration changed — re-warming inputNode") - self.queue.async { - let _ = self.audioEngine.inputNode - log.info("Audio engine re-warmed after configuration change") - } - } } // MARK: - Pre-warm @@ -110,23 +70,20 @@ final class AudioEngineController: @unchecked Sendable { // MARK: - Combined Operations - /// Atomically resets the engine, validates audio input, installs a tap - /// with the freshly-queried hardware format, and starts the engine in a - /// single synchronous dispatch to the audio queue. + /// Atomically validates audio input, installs a tap with `nil` format, and + /// starts the engine in a single synchronous dispatch to the audio queue. /// - /// After audio-route changes (Bluetooth, USB mic, AirPods mode switch) - /// the format cached inside `AVAudioInputNode` can diverge from the - /// engine's actual hardware format. Both `outputFormat(forBus:)` **and** - /// a `nil` format argument to `installTap` resolve to this stale value, - /// causing: + /// Passing `nil` for `installTap`'s format parameter lets AVAudioEngine use + /// its own internal hardware format, which is always self-consistent. This + /// prevents `NSInternalInconsistencyException` crashes caused by + /// `format.sampleRate != hwFormat.sampleRate` — the cached format from + /// `outputFormat(forBus:)` can diverge from the engine's internal hardware + /// format after audio route changes (Bluetooth, USB mic, AirPods mode + /// switch), even within a single synchronous block. /// - /// "Failed to create tap due to format mismatch, - /// " - /// - /// Calling `audioEngine.reset()` before re-querying forces the engine to - /// discard its cached graph state and re-read the hardware on the next - /// access. The fresh format is then passed **explicitly** to `installTap` - /// so the tap, the node, and the engine all agree. + /// The format validation (channels > 0, sampleRate > 0) is kept as a + /// pre-check to detect "no audio input available" — but the validated format + /// is **not** forwarded to `installTap`. /// /// Returns `true` on success, or `false` if no audio input is available or /// the engine fails to start. @@ -137,63 +94,25 @@ final class AudioEngineController: @unchecked Sendable { block: @escaping AVAudioNodeTapBlock ) -> Bool { queue.sync { [self] in - installTapAndStartImpl(bufferSize: bufferSize, block: block) - } - } - - /// Non-blocking variant of `installTapAndStart` using Swift concurrency. - /// Dispatches to the audio queue asynchronously and returns the result via - /// async/await, keeping the caller's thread free during engine initialization. - /// - /// Use this for latency-sensitive flows (e.g. PTT dictation) where showing - /// immediate UI feedback before the engine is ready improves perceived - /// responsiveness. - func installTapAndStartAsync( - bufferSize: AVAudioFrameCount, - block: @escaping AVAudioNodeTapBlock - ) async -> Bool { - await withCheckedContinuation { continuation in - queue.async { [self] in - let success = installTapAndStartImpl(bufferSize: bufferSize, block: block) - continuation.resume(returning: success) + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + log.error("Invalid audio format — channels: \(format.channelCount), sampleRate: \(format.sampleRate)") + return false } - } - } - /// Shared implementation for both sync and async tap+start paths. - /// - /// Stops, removes any existing tap, and resets the engine before querying - /// `outputFormat(forBus:)` so the returned format reflects the current - /// hardware — not a stale cache from a previous audio route. - private func installTapAndStartImpl( - bufferSize: AVAudioFrameCount, - block: @escaping AVAudioNodeTapBlock - ) -> Bool { - let inputNode = audioEngine.inputNode - - // Stop, remove any existing tap, and reset the engine so that - // outputFormat(forBus:) returns a value consistent with the - // current hardware — not a stale cache from a previous route. - audioEngine.stop() - inputNode.removeTap(onBus: 0) - audioEngine.reset() - - let format = inputNode.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - log.error("Invalid audio format — channels: \(format.channelCount), sampleRate: \(format.sampleRate)") - return false - } - - inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: format, block: block) - - audioEngine.prepare() - do { - try audioEngine.start() - return true - } catch { - log.error("Failed to start audio engine: \(error.localizedDescription)") inputNode.removeTap(onBus: 0) - return false + inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: nil, block: block) + + audioEngine.prepare() + do { + try audioEngine.start() + return true + } catch { + log.error("Failed to start audio engine: \(error.localizedDescription)") + inputNode.removeTap(onBus: 0) + return false + } } } From 5bfb2f88728fc6b73d4ac0f19f499ec2b329cb67 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Mon, 6 Apr 2026 17:12:59 -0400 Subject: [PATCH 3/4] [skip ci] Cherry-pick fixes for v0.6.1 (#23820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * revert: disable Teleport feature flag by default (#23744) (#23815) * fix: replace auxWhite-on-primaryBase with VButton across the app (#23802) * fix: use VButton for inline surface action buttons Replace raw Button with manual color functions in InlineSurfaceRouter with the design system VButton component. The manual buttonForeground used VColor.auxWhite (always #FFFFFF) against VColor.primaryBase which resolves to #FDFDFC in dark mode, producing invisible white-on-white text. Closes LUM-730 Co-Authored-By: ashlee@vellum.ai * fix: replace auxWhite-on-primaryBase with VButton in additional locations FileUploadSurfaceView: Upload/Cancel buttons used raw Button with VColor.auxWhite on VColor.primaryBase — white-on-white in dark mode. Replaced with VButton(.primary) and VButton(.outlined). JITPermissionView: Permission buttons used the same auxWhite pattern. Replaced with VButton(.primary/.outlined, isFullWidth: true). ImproveExperienceStepView: ToS checkbox checkmark used auxWhite on primaryBase fill. Changed to VColor.contentInset which adapts per color scheme. ChatGallerySection: Gallery demo of surface action pills mirrored the old buggy pattern. Updated to use VButton so the gallery accurately represents production rendering. Co-Authored-By: ashlee@vellum.ai --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ashlee@vellum.ai * Make dictation engine start non-blocking with audio route resilience (#23811) * Make dictation engine start non-blocking and improve audio resilience - Add installTapAndStartAsync to AudioEngineController for non-blocking engine start using Swift concurrency (withCheckedContinuation) - Extract installTapAndStartImpl to share logic between sync/async paths - Listen for AVAudioEngineConfigurationChange to re-prewarm inputNode after Bluetooth device connect/disconnect and AirPods mode switches - Restructure VoiceInputManager.beginRecording() to show recording UI and play activation chime immediately, then start engine async via Task - Move DictationContextCapture off the critical path: engine starts concurrently on its audio queue while context capture runs on main - Add SFSpeechRecognizer transient unavailability retry (recreate if isAvailable returns false after sleep/wake or heavy use) - Handle edge case where PTT is released before async engine start completes (stopRecordingForDictation cleans up directly) Co-Authored-By: tkheyfets * Tear down engine when async startup outlives recording session When PTT is released before installTapAndStartAsync completes, the isRecording guard now stops and removes the tap if the engine started successfully, preventing the mic path from staying alive with no active recording session. Co-Authored-By: tkheyfets * Add recording generation token and gate context capture on start success Co-Authored-By: tkheyfets * Guard stale teardown against active sessions and gate rewarm on mic auth Co-Authored-By: tkheyfets * Move context capture to Task.detached to avoid blocking main actor Co-Authored-By: tkheyfets --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: tkheyfets * [LUM-681] Fix audio tap format mismatch by resetting engine before installTap (#23766) After audio-route changes (Bluetooth, USB mic, AirPods mode switch), the format cached inside AVAudioInputNode diverges from the engine's actual hardware format. Both outputFormat(forBus:) and a nil format argument to installTap resolve to this stale value, causing: 'Failed to create tap due to format mismatch, ' Fix: call audioEngine.reset() before re-querying the format, then pass it explicitly to installTap. This forces the engine to discard its cached graph state and re-read the hardware, so the tap, node, and engine all agree. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: tkheyfets * fix: pass transport hints through HTTP message endpoint for managed-mode conversations (#23824) * fix: pass transport metadata through POST /v1/messages to enable host environment hints The HTTP message handler auto-creates conversations without transport metadata, so applyTransportMetadata() returns early and host environment hints (hostHomeDir, hostUsername) are never injected into the LLM context. This causes the assistant to hallucinate the user's home directory path from their display name instead of using the actual macOS username. Thread transport metadata from the message request body through SendMessageDeps.getOrCreateConversation() to the daemon, and send hostHomeDir/hostUsername from the macOS client in every message request. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: replace dynamic imports with static type imports Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ashlee@vellum.ai Co-authored-by: tkheyfets Co-authored-by: Claude Opus 4.6 (1M context) --- assistant/src/daemon/server.ts | 3 +- .../src/runtime/routes/conversation-routes.ts | 23 +++ .../Voice/AudioEngineController.swift | 137 ++++++++++++++---- 3 files changed, 134 insertions(+), 29 deletions(-) diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index 38a63f14e04..a2f4c927889 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -1448,8 +1448,9 @@ export class DaemonServer { */ async getConversationForMessages( conversationId: string, + options?: ConversationCreateOptions, ): Promise { - return this.getOrCreateConversation(conversationId); + return this.getOrCreateConversation(conversationId, options); } /** diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index 5e9e81a9c7d..1bfb8b7ba59 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -38,6 +38,10 @@ import { HostBashProxy } from "../../daemon/host-bash-proxy.js"; import { HostCuProxy } from "../../daemon/host-cu-proxy.js"; import { HostFileProxy } from "../../daemon/host-file-proxy.js"; import type { ServerMessage } from "../../daemon/message-protocol.js"; +import type { + MacosTransportMetadata, + NonMacosTransportMetadata, +} from "../../daemon/message-types/conversations.js"; import type { HeartbeatService } from "../../heartbeat/heartbeat-service.js"; import * as attachmentsStore from "../../memory/attachments-store.js"; import { @@ -942,6 +946,8 @@ export async function handleSendMessage( conversationType?: string; automated?: boolean; bypassSecretCheck?: boolean; + hostHomeDir?: string; + hostUsername?: string; }; const { conversationKey, content, attachmentIds } = body; @@ -1045,8 +1051,25 @@ export async function handleSendMessage( conversationType, }); const smDeps = deps.sendMessageDeps; + + // Build transport metadata from the request so the daemon can inject + // host environment hints (home directory, username) into the LLM context. + const transport = + sourceInterface === "macos" + ? ({ + channelId: sourceChannel, + interfaceId: "macos" as const, + hostHomeDir: body.hostHomeDir, + hostUsername: body.hostUsername, + } satisfies MacosTransportMetadata) + : ({ + channelId: sourceChannel, + interfaceId: sourceInterface, + } satisfies NonMacosTransportMetadata); + const conversation = await smDeps.getOrCreateConversation( mapping.conversationId, + { transport }, ); // Resolve guardian context from the AuthContext's actorPrincipalId. diff --git a/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift b/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift index cfe6ed34f3b..2cb90e63f14 100644 --- a/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift +++ b/clients/macos/vellum-assistant/Features/Voice/AudioEngineController.swift @@ -1,4 +1,5 @@ import AVFoundation +import AVFAudio import os private let log = Logger(subsystem: Bundle.appBundleIdentifier, category: "AudioEngineController") @@ -16,14 +17,53 @@ private let log = Logger(subsystem: Bundle.appBundleIdentifier, category: "Audio /// ensure `prewarm()` has run first so `inputNode` is already initialized and /// sync calls complete in sub-milliseconds. /// +/// Listens for `AVAudioEngineConfigurationChange` notifications to re-warm +/// `inputNode` after audio route changes (Bluetooth connect/disconnect, +/// AirPods mode switch, USB mic plug/unplug). +/// /// See: https://developer.apple.com/documentation/avfaudio/avaudionode/1387122-installtap final class AudioEngineController: @unchecked Sendable { private let audioEngine = AVAudioEngine() private let queue: DispatchQueue + private var configChangeObserver: (any NSObjectProtocol)? init(label: String = "com.vellum.audioEngine") { self.queue = DispatchQueue(label: label, qos: .userInitiated) + observeConfigurationChanges() + } + + deinit { + if let observer = configChangeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Configuration Change Monitoring + + /// Re-prewarm `inputNode` when the audio hardware configuration changes + /// (Bluetooth device connect/disconnect, USB mic plug/unplug, AirPods + /// mode switch). Keeps the cached inputNode format fresh so subsequent + /// `installTapAndStart` calls complete in sub-milliseconds. + /// + /// See: https://developer.apple.com/documentation/avfaudio/avaudioengine/1386063-configurationchangenotification + private func observeConfigurationChanges() { + configChangeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: audioEngine, + queue: nil + ) { [weak self] _ in + guard let self else { return } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + log.info("Audio configuration changed — skipping re-warm (mic not authorized)") + return + } + log.info("Audio configuration changed — re-warming inputNode") + self.queue.async { + let _ = self.audioEngine.inputNode + log.info("Audio engine re-warmed after configuration change") + } + } } // MARK: - Pre-warm @@ -70,20 +110,23 @@ final class AudioEngineController: @unchecked Sendable { // MARK: - Combined Operations - /// Atomically validates audio input, installs a tap with `nil` format, and - /// starts the engine in a single synchronous dispatch to the audio queue. + /// Atomically resets the engine, validates audio input, installs a tap + /// with the freshly-queried hardware format, and starts the engine in a + /// single synchronous dispatch to the audio queue. /// - /// Passing `nil` for `installTap`'s format parameter lets AVAudioEngine use - /// its own internal hardware format, which is always self-consistent. This - /// prevents `NSInternalInconsistencyException` crashes caused by - /// `format.sampleRate != hwFormat.sampleRate` — the cached format from - /// `outputFormat(forBus:)` can diverge from the engine's internal hardware - /// format after audio route changes (Bluetooth, USB mic, AirPods mode - /// switch), even within a single synchronous block. + /// After audio-route changes (Bluetooth, USB mic, AirPods mode switch) + /// the format cached inside `AVAudioInputNode` can diverge from the + /// engine's actual hardware format. Both `outputFormat(forBus:)` **and** + /// a `nil` format argument to `installTap` resolve to this stale value, + /// causing: /// - /// The format validation (channels > 0, sampleRate > 0) is kept as a - /// pre-check to detect "no audio input available" — but the validated format - /// is **not** forwarded to `installTap`. + /// "Failed to create tap due to format mismatch, + /// " + /// + /// Calling `audioEngine.reset()` before re-querying forces the engine to + /// discard its cached graph state and re-read the hardware on the next + /// access. The fresh format is then passed **explicitly** to `installTap` + /// so the tap, the node, and the engine all agree. /// /// Returns `true` on success, or `false` if no audio input is available or /// the engine fails to start. @@ -94,25 +137,63 @@ final class AudioEngineController: @unchecked Sendable { block: @escaping AVAudioNodeTapBlock ) -> Bool { queue.sync { [self] in - let inputNode = audioEngine.inputNode - let format = inputNode.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - log.error("Invalid audio format — channels: \(format.channelCount), sampleRate: \(format.sampleRate)") - return false + installTapAndStartImpl(bufferSize: bufferSize, block: block) + } + } + + /// Non-blocking variant of `installTapAndStart` using Swift concurrency. + /// Dispatches to the audio queue asynchronously and returns the result via + /// async/await, keeping the caller's thread free during engine initialization. + /// + /// Use this for latency-sensitive flows (e.g. PTT dictation) where showing + /// immediate UI feedback before the engine is ready improves perceived + /// responsiveness. + func installTapAndStartAsync( + bufferSize: AVAudioFrameCount, + block: @escaping AVAudioNodeTapBlock + ) async -> Bool { + await withCheckedContinuation { continuation in + queue.async { [self] in + let success = installTapAndStartImpl(bufferSize: bufferSize, block: block) + continuation.resume(returning: success) } + } + } + /// Shared implementation for both sync and async tap+start paths. + /// + /// Stops, removes any existing tap, and resets the engine before querying + /// `outputFormat(forBus:)` so the returned format reflects the current + /// hardware — not a stale cache from a previous audio route. + private func installTapAndStartImpl( + bufferSize: AVAudioFrameCount, + block: @escaping AVAudioNodeTapBlock + ) -> Bool { + let inputNode = audioEngine.inputNode + + // Stop, remove any existing tap, and reset the engine so that + // outputFormat(forBus:) returns a value consistent with the + // current hardware — not a stale cache from a previous route. + audioEngine.stop() + inputNode.removeTap(onBus: 0) + audioEngine.reset() + + let format = inputNode.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + log.error("Invalid audio format — channels: \(format.channelCount), sampleRate: \(format.sampleRate)") + return false + } + + inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: format, block: block) + + audioEngine.prepare() + do { + try audioEngine.start() + return true + } catch { + log.error("Failed to start audio engine: \(error.localizedDescription)") inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: nil, block: block) - - audioEngine.prepare() - do { - try audioEngine.start() - return true - } catch { - log.error("Failed to start audio engine: \(error.localizedDescription)") - inputNode.removeTap(onBus: 0) - return false - } + return false } } From d736b3d575bc9608ae8b404d8333f8cd010a4f0f Mon Sep 17 00:00:00 2001 From: David Vargas Fuertes Date: Mon, 6 Apr 2026 18:49:21 -0400 Subject: [PATCH 4/4] chore: reset non-version-bump files to match main Co-Authored-By: Claude Opus 4.6 (1M context) --- assistant/src/daemon/conversation-process.ts | 8 ++++++-- assistant/src/daemon/handlers/conversations.ts | 3 ++- assistant/src/daemon/server.ts | 13 ++++++++++++- assistant/src/runtime/routes/conversation-routes.ts | 5 +++-- .../Settings/SettingsBillingReferralCard.swift | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/assistant/src/daemon/conversation-process.ts b/assistant/src/daemon/conversation-process.ts index 19c6a6fb754..bd15692c182 100644 --- a/assistant/src/daemon/conversation-process.ts +++ b/assistant/src/daemon/conversation-process.ts @@ -15,7 +15,11 @@ import type { TurnChannelContext, TurnInterfaceContext, } from "../channels/types.js"; -import { parseChannelId, parseInterfaceId } from "../channels/types.js"; +import { + parseChannelId, + parseInterfaceId, + supportsHostProxy, +} from "../channels/types.js"; import { getConfig } from "../config/loader.js"; import type { ContextWindowResult } from "../context/window-manager.js"; import { listPendingRequestsByConversationScope } from "../memory/canonical-guardian-store.js"; @@ -302,7 +306,7 @@ export async function drainQueue( const interfaceCtx = queuedInterfaceCtx ?? conversation.getTurnInterfaceContext(); const sourceInterface = interfaceCtx?.userMessageInterface; - if (sourceInterface === "macos") { + if (sourceInterface && supportsHostProxy(sourceInterface)) { conversation.restoreProxyAvailability(); conversation.addPreactivatedSkillId("computer-use"); } diff --git a/assistant/src/daemon/handlers/conversations.ts b/assistant/src/daemon/handlers/conversations.ts index 39e23c637f2..37da5e9ec47 100644 --- a/assistant/src/daemon/handlers/conversations.ts +++ b/assistant/src/daemon/handlers/conversations.ts @@ -4,6 +4,7 @@ import { type InterfaceId, parseChannelId, parseInterfaceId, + supportsHostProxy, } from "../../channels/types.js"; import { getConfig } from "../../config/loader.js"; import { @@ -301,7 +302,7 @@ export async function handleConversationCreate( // Only create the host bash proxy for desktop client interfaces that can // execute commands on the user's machine. Set before updateClient so // updateClient's call to hostBashProxy.updateSender targets the new proxy. - if (transportInterface === "macos") { + if (supportsHostProxy(transportInterface)) { const proxy = new HostBashProxy(sendEvent, (requestId) => { pendingInteractions.resolve(requestId); }); diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index a2f4c927889..fae484ecef7 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -17,6 +17,7 @@ import { type InterfaceId, parseChannelId, parseInterfaceId, + supportsHostProxy, } from "../channels/types.js"; import { getConfig } from "../config/loader.js"; import { onContactChange } from "../contacts/contact-events.js"; @@ -548,10 +549,18 @@ export class DaemonServer { } } + private broadcastConfigChanged(): void { + this.broadcast({ type: "config_changed" }); + } + private broadcastSoundsConfigUpdated(): void { this.broadcast({ type: "sounds_config_updated" }); } + private broadcastFeatureFlagsChanged(): void { + this.broadcast({ type: "feature_flags_changed" }); + } + private broadcastAvatarUpdated(): void { this.broadcast({ type: "avatar_updated", @@ -748,6 +757,8 @@ export class DaemonServer { () => this.broadcastIdentityChanged(), () => this.broadcastSoundsConfigUpdated(), () => this.broadcastAvatarUpdated(), + () => this.broadcastConfigChanged(), + () => this.broadcastFeatureFlagsChanged(), ); this.appSourceWatcher.start((appId) => this.handleAppSourceChange(appId)); @@ -1091,7 +1102,7 @@ export class DaemonServer { // Guard: don't replace an active proxy during concurrent turn races — // another request may have started processing between the isProcessing() // check above and the await on ensureActorScopedHistory(). - if (resolvedInterface === "macos") { + if (supportsHostProxy(resolvedInterface)) { if (!conversation.isProcessing() || !conversation.hostBashProxy) { conversation.setHostBashProxy( new HostBashProxy(conversation.getCurrentSender(), (requestId) => { diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index 1bfb8b7ba59..dd0bda18004 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -17,6 +17,7 @@ import { isInteractiveInterface, parseChannelId, parseInterfaceId, + supportsHostProxy, } from "../../channels/types.js"; import { isHttpAuthDisabled } from "../../config/env.js"; import { getConfig } from "../../config/loader.js"; @@ -1140,7 +1141,7 @@ export async function handleSendMessage( // channels, headless) fall back to local execution. // Set the proxy BEFORE updateClient so updateClient's call to // hostBashProxy.updateSender targets the correct (new) proxy. - if (sourceInterface === "macos") { + if (supportsHostProxy(sourceInterface)) { // Reuse the existing proxy if the conversation is actively processing a // host bash request to avoid orphaning in-flight requests. if (!conversation.isProcessing() || !conversation.hostBashProxy) { @@ -1177,7 +1178,7 @@ export async function handleSendMessage( // When proxies are preserved during an active turn (non-desktop request while // processing), skip updating proxy senders to avoid degrading them. const preservingProxies = - conversation.isProcessing() && sourceInterface !== "macos"; + conversation.isProcessing() && !supportsHostProxy(sourceInterface); conversation.updateClient(onEvent, !isInteractive, { skipProxySenderUpdate: preservingProxies, }); diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift index 08fb20d7dcb..e639eab62b0 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsBillingReferralCard.swift @@ -46,7 +46,7 @@ struct SettingsBillingReferralCard: View { // MARK: - Has Code State private func hasCodeState(_ code: ReferralCodeResponse) -> some View { - SettingsCard(title: "Referrals", subtitle: "Share your referral link to earn up to 100 free credits") { + SettingsCard(title: "Referrals", subtitle: "Share your referral link to earn up to 100 free credits.") { VStack(alignment: .leading, spacing: VSpacing.lg) { // Referral URL row HStack(spacing: VSpacing.sm) {