feat: mute all microphones at once#5

open6
main
droid/add-global-mic-mute
opened16d agobyKKainoa Newton
View on GitHubTwo-way sync enabledPR last imported 7d ago

What

A global "Mute All Mics" switch, in the spirit of MacMute:

  • Right-click menu: "Mute All Mics" / "Unmute All Mics", with mic icons.
  • Microphone tab: a full-width mute tile. While muted, the tab icon, the input gain slider icon and recording app rows turn red, and the gain slider is frozen.
  • Menu bar: a second, red slashed-mic icon is shown while muted. A click opens a menu (status, any devices that can't be muted, and "Unmute All Mics"), so a stray click never opens the mics.

How it mutes

  • Every input device is muted at the CoreAudio HAL level, so apps that pick a non-default input are covered too. Sliders' own aggregate devices are skipped, because their input side carries tapped app audio, not a microphone.
  • Listeners on the device list, the default input, and each device's mute and volume react in the same main-queue turn. Newly plugged devices are muted as they appear.
  • If something else undoes a mute, it is put back and backed by input gain at zero from then on. A device that refuses the mute gets the gain lock at once. A 2 s watchdog covers drivers that change state without a notification.
  • Devices with no working control (for example, an iPhone Continuity mic) are named in the UI as "can't be muted", and stop being the default input while the mute lasts.
  • Fails closed: the engaged state and each device's original values are saved before any HAL write, so a crash or update relaunch stays muted. A normal quit restores every device exactly as it was, then re-mutes on the next launch.

Testing

  • 114 tests pass (MicMutePolicyTests is new). Lint passes.
  • On real hardware (MacBook Pro mic and an external mic), an earlier build re-muted a tampered mute within 2–10 ms, escalated to zero gain, and restored the original state on quit.
  • Not yet tested by hand: the latest build's menu and icon changes, hot-plugging, sleep and wake, a Core Audio restart, and Sparkle update relaunch.

Note

High Risk Writes Core Audio mute/gain and default input device system-wide with persisted fail-closed behavior—privacy-critical microphone surface with complex edge cases (unmutable devices, Core Audio restarts, quit vs update).

Overview Adds Mute All Microphones: one action from the microphone tab tile, the Sliders right‑click menu, and a dedicated red menu-bar icon (menu-only unmute so stray clicks don’t open mics).

Enforcement is new HAL-level logic (MicrophoneMute + policy/store): every real input device is held muted (Sliders plumbing aggregates skipped), hot-plug and tamper re-mute in the same main-queue turn with optional zero-gain backup, a watchdog, and naming devices that can’t be silenced while temporarily moving default input off them. State fails closed—engaged mute and restore snapshots persist across crash/update; normal quit restores prior levels via releaseForQuit() while update relaunch keeps mics muted.

UI follows mute state (frozen input slider, red tab/recording rows when fully protected). ControlSlider gains optional icon color and respects .disabled for scroll-wheel adjustment. README and render-shot scene 08-microphone-muted document the feature; MicMutePolicyTests covers policy/persistence.

Reviewed by Cursor Bugbot for commit bb0e5fb3df6bccdd77c90afb59a8d7827c497309. Bugbot is set up for automated code reviews on this repo. Configure here.

Checks

1 failing · 4 successful · 1 skipped
Some checks failed

Failing1

bb0e5fb

Added 1 commit

  • feat: mute all microphones at once8345037
Synced from GitHub

Your provider account is out of credit.

Pullfrog detected a billing-exhausted response from your provider — the agent stopped before completing this run.

Top up your provider account, then re-trigger Pullfrog.

1provider error: Upstream request failed: Insufficient account funds

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using kimi-k3 | 𝕏

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0e450821-80cb-4841-8075-770cc43ce234

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sliders/UI/MicMuteTile.swiftline 39Outdated · 8345037Open thread
……@@ -0,0 +1,70 @@
36+ .opacity(micMute.isReady ? 1 : 0.5)
37+ .onHover { isHovering = $0 }
38+ .help(muted ? "Unmute every microphone" : "Mute every microphone for every app")
39+ .accessibilityLabel(muted ? "Unmute microphones" : "Mute all microphones")
Ccubic-dev-aiBoton line 3916d ago

P3: This .accessibilityLabel replaces the button's combined title and subtitle, so VoiceOver never hears the subtitle's warning "N microphones can't be muted" — the one piece of information that must not vanish for non-visual users. Include the unprotected-device warning in the label when it is shown.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/UI/MicMuteTile.swift, line 39:
2
3<comment>This `.accessibilityLabel` replaces the button's combined title and subtitle, so VoiceOver never hears the subtitle's warning "N microphones can't be muted" — the one piece of information that must not vanish for non-visual users. Include the unprotected-device warning in the label when it is shown.</comment>
4
5<file context>
6@@ -0,0 +1,70 @@
7+ .opacity(micMute.isReady ? 1 : 0.5)
8+ .onHover { isHovering = $0 }
9+ .help(muted ? "Unmute every microphone" : "Mute every microphone for every app")
10+ .accessibilityLabel(muted ? "Unmute microphones" : "Mute all microphones")
11+ }
12+
13</file context>
suggestion
1 .accessibilityLabel(muted
2 ? (unprotected.isEmpty
3 ? "Unmute microphones"
4 : "Unmute microphones; \(unprotected.count == 1 ? unprotected[0] : "\(unprotected.count) microphones") can't be muted")
5 : "Mute all microphones")

✅ Addressed in c8b9b98

Sliders/Audio/MicMutePolicy.swiftline 63Outdated · 8345037Open thread
……@@ -0,0 +1,106 @@
60+ var state = existing ?? MicRestoreState()
61+ switch layer {
62+ case .mute:
63+ if state.wasMuted == nil { state.wasMuted = currentMute ?? false }
Ccubic-dev-aiBoton line 6316d ago

P1: record treats a failed mute read as an original false, so release can unmute a device that was already muted. Skip the mute write when its pre-mute state cannot be read.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicMutePolicy.swift, line 63:
2
3<comment>`record` treats a failed mute read as an original `false`, so release can unmute a device that was already muted. Skip the mute write when its pre-mute state cannot be read.</comment>
4
5<file context>
6@@ -0,0 +1,106 @@
7+ var state = existing ?? MicRestoreState()
8+ switch layer {
9+ case .mute:
10+ if state.wasMuted == nil { state.wasMuted = currentMute ?? false }
11+ case .zeroGain:
12+ if state.gain == nil, let currentGain { state.gain = currentGain }
13</file context>

✅ Addressed in c8b9b98

Fixed in c8b9b98. record now returns nil when the original value was never read, and apply then leaves that control alone (a refused mute escalates to the gain lock; a device with no usable lock is reported as can't-be-muted).

Sliders/Audio/MicMutePolicy.swiftline 65Outdated · 8345037Open thread
……@@ -0,0 +1,106 @@
62+ case .mute:
63+ if state.wasMuted == nil { state.wasMuted = currentMute ?? false }
64+ case .zeroGain:
65+ if state.gain == nil, let currentGain { state.gain = currentGain }
Ccubic-dev-aiBoton line 6516d ago

P2: record leaves gain unrecorded when the read fails, but the controller can still write zero; release then skips restoration and may leave the device at zero gain. Skip the gain write unless the original gain was captured.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicMutePolicy.swift, line 65:
2
3<comment>`record` leaves gain unrecorded when the read fails, but the controller can still write zero; release then skips restoration and may leave the device at zero gain. Skip the gain write unless the original gain was captured.</comment>
4
5<file context>
6@@ -0,0 +1,106 @@
7+ case .mute:
8+ if state.wasMuted == nil { state.wasMuted = currentMute ?? false }
9+ case .zeroGain:
10+ if state.gain == nil, let currentGain { state.gain = currentGain }
11+ }
12+ return state
13</file context>

✅ Addressed in c8b9b98

Sliders/Audio/MicrophoneMute.swiftline 193Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
190+ case .mute:
191+ if currentMute == true { return true }
192+ if endpoint.writeMute(true) != nil { return true }
193+ Self.logger.error("Failed to mute \(device.name)")
Ccubic-dev-aiBoton line 19316d ago

P3: The 2-second watchdog (startWatchdog → reconcile → hold → apply) re-attempts and re-logs every failing HAL write indefinitely. A device whose mute or zeroGain write persistently fails (dead virtual device, denied driver, or a device whose mute is readable as false but not settable) logs Failed to mute…/Failed to zero gain… every two seconds for as long as the mute is engaged, plus once per property notification via enforce. The same pattern repeats for a default-input swap that never succeeds (Failed to move default input off…) and for devices that refuse restoration after disengage (Failed to restore…). This produces unbounded log growth and constant HAL churn for one permanently failing device. Rate-limit the error logging and back off the per-device retry (e.g., only re-attempt a failed write when its property actually changes, and log once per device per escalation).

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicrophoneMute.swift, line 193:
2
3<comment>The 2-second watchdog (`startWatchdog` → `reconcile` → `hold` → `apply`) re-attempts and re-logs every failing HAL write indefinitely. A device whose mute or zeroGain write persistently fails (dead virtual device, denied driver, or a device whose mute is readable as `false` but not settable) logs `Failed to mute…`/`Failed to zero gain…` every two seconds for as long as the mute is engaged, plus once per property notification via `enforce`. The same pattern repeats for a default-input swap that never succeeds (`Failed to move default input off…`) and for devices that refuse restoration after disengage (`Failed to restore…`). This produces unbounded log growth and constant HAL churn for one permanently failing device. Rate-limit the error logging and back off the per-device retry (e.g., only re-attempt a failed write when its property actually changes, and log once per device per escalation).</comment>
4
5<file context>
6@@ -0,0 +1,370 @@
7+ case .mute:
8+ if currentMute == true { return true }
9+ if endpoint.writeMute(true) != nil { return true }
10+ Self.logger.error("Failed to mute \(device.name)")
11+ case .zeroGain:
12+ if currentGain == 0 { return true }
13</file context>

✅ Addressed in c8b9b98

Fixed in c8b9b98. Each refused write (mute, zero gain, restore, default swap) is logged once and cleared when it next succeeds. The retry itself stays on purpose: a mute that fails once may take later, and this is a privacy control. The extra cost is one property write per device every 2 s, only while a device is refusing.

Sliders/UI/InputVolumeSection.swiftline 13Outdated · 8345037Open thread
……@@ -0,0 +1,46 @@
10+ // The global mute owns every input while engaged; gain and the
11+ // per-device mute would only be overridden, so they freeze.
12+ let globallyMuted = engine.micMute.isEngaged
13+ let editable = inputVolume.canSetVolume && !globallyMuted
Ccubic-dev-aiBoton line 1316d ago

P2: The input control remains interactive before engine.start(): SystemVolumeController initially reports canSetVolume = true with an unknown endpoint, so gain and mute writes fail while the microphone tile is still waiting. Include engine.isStarted in editable so the controls stay disabled until Core Audio initialization completes.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/UI/InputVolumeSection.swift, line 13:
2
3<comment>The input control remains interactive before `engine.start()`: `SystemVolumeController` initially reports `canSetVolume = true` with an unknown endpoint, so gain and mute writes fail while the microphone tile is still waiting. Include `engine.isStarted` in `editable` so the controls stay disabled until Core Audio initialization completes.</comment>
4
5<file context>
6@@ -0,0 +1,46 @@
7+ // The global mute owns every input while engaged; gain and the
8+ // per-device mute would only be overridden, so they freeze.
9+ let globallyMuted = engine.micMute.isEngaged
10+ let editable = inputVolume.canSetVolume && !globallyMuted
11+ VStack(alignment: .leading, spacing: 5) {
12+ HStack {
13</file context>

✅ Addressed in c8b9b98

Fixed in c8b9b98. The slider and its mute tap now also require engine.isStarted.

Sliders/Audio/MicrophoneMute.swiftline 83Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
80+ guard isReady else { return }
81+ stopWatchdog()
82+ systemListeners.removeAll()
83+ deviceListeners.removeAll()
Ccubic-dev-aiBoton line 8316d ago

P2: A failed restore stays in state.restore, but this branch stops the watchdog and removes device listeners before attempting it. Schedule retries while restore entries remain, or a still-present device that later accepts writes can remain muted or at zero gain indefinitely.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicrophoneMute.swift, line 83:
2
3<comment>A failed restore stays in `state.restore`, but this branch stops the watchdog and removes device listeners before attempting it. Schedule retries while restore entries remain, or a still-present device that later accepts writes can remain muted or at zero gain indefinitely.</comment>
4
5<file context>
6@@ -0,0 +1,370 @@
7+ guard isReady else { return }
8+ stopWatchdog()
9+ systemListeners.removeAll()
10+ deviceListeners.removeAll()
11+ release(MicInputDevice.all())
12+ persist()
13</file context>

✅ Addressed in c8b9b98

Fixed in c8b9b98. After unmuting, the watchdog keeps running while a present device still has a pending restore, and stops once none does. Absent devices are restored when the device list changes.

Sliders/UI/InputVolumeSection.swiftline 30Outdated · 8345037Open thread
……@@ -0,0 +1,46 @@
27+ ControlSlider(
28+ value: Binding(
29+ get: { inputVolume.volume },
30+ set: { inputVolume.setVolume($0) }
Ccubic-dev-aiBoton line 3016d ago

P2: The dimmed "frozen" gain slider is still live: ControlSlider installs an app-side NSEvent scroll-wheel monitor on hover that writes the value binding unconditionally, and .disabled(!editable) does not gate it. Scrolling over the slider while the global mute is engaged therefore calls inputVolume.setVolume, moves the thumb, and writes input gain to the HAL — exactly what the freeze is meant to prevent, and the mute policy's hold/watchdog will treat any non-zero gain as a breach and escalate the device to its tampered zero-gain lock. Guard the binding setter so no write happens while frozen.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/UI/InputVolumeSection.swift, line 30:
2
3<comment>The dimmed "frozen" gain slider is still live: `ControlSlider` installs an app-side `NSEvent` scroll-wheel monitor on hover that writes the `value` binding unconditionally, and `.disabled(!editable)` does not gate it. Scrolling over the slider while the global mute is engaged therefore calls `inputVolume.setVolume`, moves the thumb, and writes input gain to the HAL — exactly what the freeze is meant to prevent, and the mute policy's `hold`/watchdog will treat any non-zero gain as a breach and escalate the device to its tampered zero-gain lock. Guard the binding setter so no write happens while frozen.</comment>
4
5<file context>
6@@ -0,0 +1,46 @@
7+ ControlSlider(
8+ value: Binding(
9+ get: { inputVolume.volume },
10+ set: { inputVolume.setVolume($0) }
11+ ),
12+ icon: inputVolume.isMuted || globallyMuted ? "mic.slash.fill" : "mic.fill",
13</file context>
suggestion
1 set: { if !globallyMuted { inputVolume.setVolume($0) } }

✅ Addressed in c8b9b98

Fixed in c8b9b98. ControlSlider reads isEnabled: it no longer installs the scroll monitor while disabled and removes it when it becomes disabled. This also covers every other disabled slider.

Sliders/Audio/MicrophoneMute.swiftline 175Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
172+
173+ private func escalate(_ device: MicInputDevice, reason: String) {
174+ guard tampered.insert(device.uid).inserted else { return }
175+ Self.logger.warning("\(device.name) \(reason); holding it with gain at zero too")
Ccubic-dev-aiBoton line 17516d ago

P3: escalate always logs "holding it with gain at zero too", but for a device where .mute failed and canZeroGain is false, no gain lock can be or is applied (added computes to empty). The warning is then misleading: it claims a protection layer that does not exist, which is exactly the case the UI relies on to call the device "unprotected". Only log the gain-lock claim when device.controls.canZeroGain is true, or word the message as an attempted lock/escaped mute.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicrophoneMute.swift, line 175:
2
3<comment>`escalate` always logs "holding it with gain at zero too", but for a device where `.mute` failed and `canZeroGain` is `false`, no gain lock can be or is applied (`added` computes to empty). The warning is then misleading: it claims a protection layer that does not exist, which is exactly the case the UI relies on to call the device "unprotected". Only log the gain-lock claim when `device.controls.canZeroGain` is true, or word the message as an attempted lock/escaped mute.</comment>
4
5<file context>
6@@ -0,0 +1,370 @@
7+
8+ private func escalate(_ device: MicInputDevice, reason: String) {
9+ guard tampered.insert(device.uid).inserted else { return }
10+ Self.logger.warning("\(device.name) \(reason); holding it with gain at zero too")
11+ }
12+
13</file context>
suggestion
1 Self.logger.warning("\(device.name) \(reason); \(device.controls.canZeroGain ? "holding it with gain at zero too" : "cannot be held") ")

✅ Addressed in c8b9b98

Fixed in c8b9b98. The message only claims the gain lock when the device has one.

Sliders/Audio/MicrophoneMute.swiftline 353Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
350+ // Tap-only aggregates (another app's per-app audio) have no
351+ // microphone in them.
352+ guard !inputs.isEmpty else { return nil }
353+ covered = inputs.allSatisfy { Self.controls(of: $0).isProtectable }
Ccubic-dev-aiBoton line 35316d ago

P1: This marks an aggregate protected based on member capabilities even when hold() failed to mute a member. If that aggregate is the default input, it remains selected and can still capture from the live mic; determine aggregate coverage from whether each member was actually silenced.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicrophoneMute.swift, line 353:
2
3<comment>This marks an aggregate protected based on member capabilities even when `hold()` failed to mute a member. If that aggregate is the default input, it remains selected and can still capture from the live mic; determine aggregate coverage from whether each member was actually silenced.</comment>
4
5<file context>
6@@ -0,0 +1,370 @@
7+ // Tap-only aggregates (another app's per-app audio) have no
8+ // microphone in them.
9+ guard !inputs.isEmpty else { return nil }
10+ covered = inputs.allSatisfy { Self.controls(of: $0).isProtectable }
11+ }
12+ self.id = id
13</file context>

✅ Addressed in c8b9b98

Sliders/SlidersApp.swiftline 38Outdated · 8345037Open thread
……@@ -33,6 +33,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
3535 Self.engine?.multiOutput.shutdown()
36+ // An update relaunch keeps the microphones muted across the gap;
37+ // the incoming instance adopts the persisted mute.
38+ Self.engine?.micMute.releaseForQuit()
Ccubic-dev-aiBoton line 3816d ago

P2: releaseForQuit() does nothing until the detached HAL probe lets micMute.start() set isReady. Quitting during that startup window after a crash can leave the persisted microphone mute active with no app running to restore it; restore the saved state before allowing this termination path to finish.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/SlidersApp.swift, line 38:
2
3<comment>`releaseForQuit()` does nothing until the detached HAL probe lets `micMute.start()` set `isReady`. Quitting during that startup window after a crash can leave the persisted microphone mute active with no app running to restore it; restore the saved state before allowing this termination path to finish.</comment>
4
5<file context>
6@@ -33,6 +33,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
7 Self.engine?.multiOutput.shutdown()
8+ // An update relaunch keeps the microphones muted across the gap;
9+ // the incoming instance adopts the persisted mute.
10+ Self.engine?.micMute.releaseForQuit()
11 }
12 }
13</file context>

Won't fix, by design; documented in releaseForQuit in c8b9b98. Before isReady the launch probe hasn't heard back from coreaudiod, so a HAL call during termination could hang the quit. Devices a previous run muted stay muted (fail closed) and the next launch adopts the persisted state.

README.mdline 26Outdated · 8345037Open thread
……@@ -23,6 +23,7 @@ Download [the latest dmg](https://github.com/notkainoa/sliders/releases/latest/d
2323- **Per-app output** — drag one or more devices onto an app to play it through exactly those outputs, each with its own volume; everything else stays on your main output.
2424- **Now playing** — every track, video, and browser tab that's playing shows under its app with artwork, play/pause, and scrubbing. Control Center shows one; Sliders shows all of them.
2525- **Microphone** — switch the default input, set gain, and see which apps are listening.
26+- **Mute all microphones** — one click, from the popover or the right-click menu, mutes every input device for every app, and a red mic joins the menu bar until you unmute. A mute something else takes off is put back within milliseconds; a microphone macOS can't mute is named, never glossed over.
Ccubic-dev-aiBoton line 2616d ago

P3: The sentence “A mute something else takes off” is ungrammatical and makes the behavior hard to understand; say “If another process unmutes a device, Sliders re-mutes it within milliseconds.”

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At README.md, line 26:
2
3<comment>The sentence “A mute something else takes off” is ungrammatical and makes the behavior hard to understand; say “If another process unmutes a device, Sliders re-mutes it within milliseconds.”</comment>
4
5<file context>
6@@ -23,6 +23,7 @@ Download [the latest dmg](https://github.com/notkainoa/sliders/releases/latest/d
7 - **Per-app output** — drag one or more devices onto an app to play it through exactly those outputs, each with its own volume; everything else stays on your main output.
8 - **Now playing** — every track, video, and browser tab that's playing shows under its app with artwork, play/pause, and scrubbing. Control Center shows one; Sliders shows all of them.
9 - **Microphone** — switch the default input, set gain, and see which apps are listening.
10+- **Mute all microphones** — one click, from the popover or the right-click menu, mutes every input device for every app, and a red mic joins the menu bar until you unmute. A mute something else takes off is put back within milliseconds; a microphone macOS can't mute is named, never glossed over.
11 - **In sync** — system volume follows the volume keys and Control Center; scrolling over any slider adjusts it.
12
13</file context>
suggestion
1- **Mute all microphones** — one click, from the popover or the right-click menu, mutes every input device for every app, and a red mic joins the menu bar until you unmute. If another process unmutes a device, Sliders re-mutes it within milliseconds; a microphone macOS can't mute is named, never glossed over.

✅ Addressed in c8b9b98

Sliders/UI/MutedMicStatusItem.swiftlines 71–74Outdated · 8345037Open thread
……@@ -0,0 +1,109 @@
71+ item.menu = menu
72+ return item
73+ }
74+
Ccubic-dev-aiBoton lines 71–7416d ago

P3: This menu is never given an explicit appearance, but the related Sliders menu in StatusItemMenu.swift needed menu.appearance = NSApp.effectiveAppearance because "the status bar window is pinned dark" and otherwise the menu inherits that and ignores the system theme. This menu is presented from the same kind of NSStatusBarWindow, so it can render pinned dark even when the system theme is light. Set the appearance here too.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/UI/MutedMicStatusItem.swift, line 71:
2
3<comment>This menu is never given an explicit appearance, but the related Sliders menu in StatusItemMenu.swift needed `menu.appearance = NSApp.effectiveAppearance` because "the status bar window is pinned dark" and otherwise the menu inherits that and ignores the system theme. This menu is presented from the same kind of NSStatusBarWindow, so it can render pinned dark even when the system theme is light. Set the appearance here too.</comment>
4
5<file context>
6@@ -0,0 +1,109 @@
7+ let menu = NSMenu()
8+ menu.autoenablesItems = false
9+ menu.delegate = self
10+ item.menu = menu
11+ return item
12+ }
13</file context>
suggestion
1 let menu = NSMenu()
2 menu.autoenablesItems = false
3 // The status bar window is pinned dark; without this the menu
4 // inherits that and ignores the system theme.
5 menu.appearance = NSApp.effectiveAppearance
6 menu.delegate = self
7 item.menu = menu

✅ Addressed in c8b9b98

Fixed in c8b9b98. The menu takes NSApp.effectiveAppearance in menuNeedsUpdate.

Sliders/RenderHarness.swiftline 57Outdated · 8345037Open thread
……@@ -54,6 +54,7 @@
5454 Scene(id: "06-bluetooth", direction: .output, pairTargeted: false, make: bluetoothEngine,
5555 forcedHoverBT: "12-34-56-78-9A-BC"),
5656 Scene(id: "07-now-playing", direction: .output, pairTargeted: false, make: nowPlayingEngine),
57+ Scene(id: "08-microphone-muted", direction: .input, pairTargeted: false, make: mutedMicrophoneEngine),
Ccubic-dev-aiBoton line 5716d ago

P3: The scene doc comment above still says "The five scenes mirror the product's headline states…" while the array now holds eight scenes, including the newly added 08-microphone-muted. Update the comment to the real scene list and add the muted-mic headline state.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/RenderHarness.swift, line 57:
2
3<comment>The scene doc comment above still says "The five scenes mirror the product's headline states…" while the array now holds eight scenes, including the newly added 08-microphone-muted. Update the comment to the real scene list and add the muted-mic headline state.</comment>
4
5<file context>
6@@ -54,6 +54,7 @@
7 Scene(id: "06-bluetooth", direction: .output, pairTargeted: false, make: bluetoothEngine,
8 forcedHoverBT: "12-34-56-78-9A-BC"),
9 Scene(id: "07-now-playing", direction: .output, pairTargeted: false, make: nowPlayingEngine),
10+ Scene(id: "08-microphone-muted", direction: .input, pairTargeted: false, make: mutedMicrophoneEngine),
11 ]
12
13</file context>

✅ Addressed in c8b9b98

Sliders/UI/MutedMicStatusItem.swiftline 67Outdated · 8345037Open thread
……@@ -0,0 +1,109 @@
64+ // Setting the name re-reads the saved position (and visibility) for it.
65+ item.autosaveName = Self.autosaveName
66+ item.button?.image = Self.icon
67+ item.button?.setAccessibilityLabel("Microphones muted")
Ccubic-dev-aiBoton line 6716d ago

P3: The accessibility label is fixed to "Microphones muted" even when unprotectedDeviceNames is non-empty, while the tooltip and menu report "Microphones muted, except X". VoiceOver users hear a label that contradicts the actual state and are never told which mics remain live. Set the label in update() from the same conditional that builds the tooltip.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/UI/MutedMicStatusItem.swift, line 67:
2
3<comment>The accessibility label is fixed to "Microphones muted" even when `unprotectedDeviceNames` is non-empty, while the tooltip and menu report "Microphones muted, except X". VoiceOver users hear a label that contradicts the actual state and are never told which mics remain live. Set the label in `update()` from the same conditional that builds the tooltip.</comment>
4
5<file context>
6@@ -0,0 +1,109 @@
7+ // Setting the name re-reads the saved position (and visibility) for it.
8+ item.autosaveName = Self.autosaveName
9+ item.button?.image = Self.icon
10+ item.button?.setAccessibilityLabel("Microphones muted")
11+ let menu = NSMenu()
12+ menu.autoenablesItems = false
13</file context>

✅ Addressed in c8b9b98

Fixed in c8b9b98. The accessibility label is set in update() from the same text as the tooltip.

Synced from GitHub

RetriggerConfidence Score: 2/5

[Medium impact] Adds global microphone mute feature to the audio mixer.

Fix aggregate mute coverage and preservation of original settings before merging.

Findings

  1. P1 Security Aggregate input can stay live ▶
  2. P1 Original microphone settings are lost ▶
  3. P2 Default microphone restore is forgotten ▶
  4. P2 New microphone choice is forgotten ▶
Fix with agent prompt
markdown
1### Issue 1
2Sliders/Audio/MicrophoneMute.swift:353
3`isCoveredBySubDevices` checks whether members advertise controls, not whether those controls worked. If an aggregate has no working controls and a member refuses both writes, `hold()` still reports the aggregate as silent. Sliders then hides its warning and leaves it as the default input, so apps using it can still hear the microphone. Calculate coverage from the members' actual mute results.
4
5**How this was verified:** Aggregate coverage uses only `isSettable`, while `hold()` accepts that coverage even when mute and gain writes fail.
6
7### Issue 2
8Sliders/Audio/MicrophoneMute.swift:181-188
9`apply()` changes a control even when reading its original value failed:
10- A missing mute reading becomes `false`, so release can unmute a microphone the user had already muted.
11- A missing gain reading leaves no saved gain, but Sliders still writes zero. Release then skips restoring it, or a later pass saves zero as the original.
12
13Do not change a control until its original value is known, and add tests for failed reads.
14
15### Issue 3
16Sliders/Audio/MicrophoneMute.swift:248-253
17`restoreDefault()` deletes `defaultSwap` before finding the original device or successfully restoring it. If that device is unplugged when the user unmutes, or the restore write fails, the saved choice is lost permanently. Keep the record for retry and clear it only after restoration succeeds or the user has chosen another default.
18
19### Issue 4
20Sliders/Audio/MicrophoneMute.swift:239
21`swapDefaultIfUnprotected()` always keeps the first `from`, even after the user selects a different microphone that cannot be muted. For example:
22- Sliders replaces input A with protected input B.
23- The user selects unprotectable input C, which Sliders also replaces with B.
24- Unmuting restores A instead of the user's newer choice C.
25
26Save the newer choice when replacing it, while keeping the protected input active during mute.
27
28---
29
30For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a global microphone mute with saved restore values, device listeners, wake and restart recovery, and a watchdog.

  • Sliders can mute every input microphone across apps.
  • The microphone tab shows a global mute and its state at a glance.
  • The menu bar offers clear ways to mute and unmute all microphones.

Diagram

mermaid
1%%{init: {'theme': 'neutral'}}%%
2flowchart TD
3 A[Toggle or device change] --> B{Mute engaged?}
4 B -->|Yes| C[Save original values]
5 C --> D[Apply mute or zero gain]
6 D --> E[Find unprotected inputs]
7 E --> F[Replace unprotected default]
8 F --> G[Show state and warnings]
9 G --> H[Watch device changes]
10 H --> A
11 B -->|No| I[Restore device values]
12 I --> J[Restore saved default]

Reviews (1) · Last reviewed commit: "feat: mute all microphones at once" · Reviewed by Greptile

Sliders/Audio/MicrophoneMute.swiftlines 181–188Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
185+ // Before the HAL write: a crash between the two must still leave
186+ // the original value on disk to restore.
187+ persist()
188+ }
Ggreptile-appsBoton lines 181–18816d ago

P1 Original microphone settings are lost

apply() changes a control even when reading its original value failed:

  • A missing mute reading becomes false, so release can unmute a microphone the user had already muted.
  • A missing gain reading leaves no saved gain, but Sliders still writes zero. Release then skips restoring it, or a later pass saves zero as the original.

Do not change a control until its original value is known, and add tests for failed reads.

Prompt To Fix With AI
markdown
1This is a comment left during a code review.
2Path: Sliders/Audio/MicrophoneMute.swift
3Line: 181-188
4
5Comment:
6**Original microphone settings are lost**
7
8`apply()` changes a control even when reading its original value failed:
9- A missing mute reading becomes `false`, so release can unmute a microphone the user had already muted.
10- A missing gain reading leaves no saved gain, but Sliders still writes zero. Release then skips restoring it, or a later pass saves zero as the original.
11
12Do not change a control until its original value is known, and add tests for failed reads.
13
14---
15
16For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fixed in c8b9b98. MicMutePolicy.record returns nil for a never-read original, and apply then leaves the control alone. Covered by the new unreadableOriginal test.

Sliders/Audio/MicrophoneMute.swiftlines 248–253Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
250+ let currentID = try? AudioObjectID.readDefaultDevice(.input),
251+ devices.first(where: { $0.id == currentID })?.uid == swap.to
252+ else { return }
253+ try? AudioObjectID.system.write(kAudioHardwarePropertyDefaultInputDevice, value: from.id)
Ggreptile-appsBoton lines 248–25316d ago

P2 Default microphone restore is forgotten

restoreDefault() deletes defaultSwap before finding the original device or successfully restoring it. If that device is unplugged when the user unmutes, or the restore write fails, the saved choice is lost permanently. Keep the record for retry and clear it only after restoration succeeds or the user has chosen another default.

Prompt To Fix With AI
markdown
1This is a comment left during a code review.
2Path: Sliders/Audio/MicrophoneMute.swift
3Line: 248-253
4
5Comment:
6**Default microphone restore is forgotten**
7
8`restoreDefault()` deletes `defaultSwap` before finding the original device or successfully restoring it. If that device is unplugged when the user unmutes, or the restore write fails, the saved choice is lost permanently. Keep the record for retry and clear it only after restoration succeeds or the user has chosen another default.
9
10---
11
12For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fixed in 0d2467c. restoreDefault keeps the swap record until the write succeeds, or until the default has moved off to (a newer choice wins). A refused write is retried by the watchdog; an absent from is retried when the device list changes.

Sliders/Audio/MicrophoneMute.swiftline 239Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
236+ do {
237+ try AudioObjectID.system.write(kAudioHardwarePropertyDefaultInputDevice, value: target.id)
238+ // Swapping again keeps the original `from`: that is the user's.
239+ state.defaultSwap = DefaultInputSwap(from: state.defaultSwap?.from ?? current.uid, to: target.uid)
Ggreptile-appsBoton line 23916d ago

P2 New microphone choice is forgotten

swapDefaultIfUnprotected() always keeps the first from, even after the user selects a different microphone that cannot be muted. For example:

  • Sliders replaces input A with protected input B.
  • The user selects unprotectable input C, which Sliders also replaces with B.
  • Unmuting restores A instead of the user's newer choice C.

Save the newer choice when replacing it, while keeping the protected input active during mute.

Prompt To Fix With AI
markdown
1This is a comment left during a code review.
2Path: Sliders/Audio/MicrophoneMute.swift
3Line: 239
4
5Comment:
6**New microphone choice is forgotten**
7
8`swapDefaultIfUnprotected()` always keeps the first `from`, even after the user selects a different microphone that cannot be muted. For example:
9- Sliders replaces input A with protected input B.
10- The user selects unprotectable input C, which Sliders also replaces with B.
11- Unmuting restores A instead of the user's newer choice C.
12
13Save the newer choice when replacing it, while keeping the protected input active during mute.
14
15---
16
17For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fixed in 0d2467c. A swap now records the current default as from. Our substitute is protectable, so swapping again means someone picked another unprotectable device, and that pick is what gets restored.

Sliders/Audio/MicrophoneMute.swiftline 353Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
350+ // Tap-only aggregates (another app's per-app audio) have no
351+ // microphone in them.
352+ guard !inputs.isEmpty else { return nil }
353+ covered = inputs.allSatisfy { Self.controls(of: $0).isProtectable }
Ggreptile-appsBoton line 35316d ago

P1 security Aggregate input can stay live

isCoveredBySubDevices checks whether members advertise controls, not whether those controls worked. If an aggregate has no working controls and a member refuses both writes, hold() still reports the aggregate as silent. Sliders then hides its warning and leaves it as the default input, so apps using it can still hear the microphone. Calculate coverage from the members' actual mute results.

How this was verified: Aggregate coverage uses only isSettable, while hold() accepts that coverage even when mute and gain writes fail.

Prompt To Fix With AI
markdown
1This is a comment left during a code review.
2Path: Sliders/Audio/MicrophoneMute.swift
3Line: 353
4
5Comment:
6**Aggregate input can stay live**
7
8`isCoveredBySubDevices` checks whether members advertise controls, not whether those controls worked. If an aggregate has no working controls and a member refuses both writes, `hold()` still reports the aggregate as silent. Sliders then hides its warning and leaves it as the default input, so apps using it can still hear the microphone. Calculate coverage from the members' actual mute results.
9
10**How this was verified:** Aggregate coverage uses only `isSettable`, while `hold()` accepts that coverage even when mute and gain writes fail.
11
12---
13
14For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fixed in c8b9b98. Aggregate coverage is now computed in reconcile from the members hold() actually silenced.

CcursorBotreviewed · 2 inline threads16d ago
Synced from GitHub

Stale Bugbot comment from a previous run.

Sliders/Audio/MicrophoneMute.swiftline 254Outdated · 8345037Open thread
……@@ -0,0 +1,370 @@
251+ devices.first(where: { $0.id == currentID })?.uid == swap.to
252+ else { return }
253+ try? AudioObjectID.system.write(kAudioHardwarePropertyDefaultInputDevice, value: from.id)
254+ }
CcursorBoton line 25416d ago

Default input restore is dropped

Medium Severity

restoreDefault clears defaultSwap before the default is actually moved back. If from is briefly missing, to has already gone away, or the HAL write fails, the original default is forgotten and later release retries never restore it.

Reviewed by Cursor Bugbot for commit 8345037e6e4b3014213ad10f2fa5c6460ba05b28. Configure here.

Sliders/UI/MutedMicStatusItem.swiftline 65Outdated · 8345037Open thread
……@@ -0,0 +1,109 @@
62+ private func makeItem() -> NSStatusItem {
63+ let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
64+ // Setting the name re-reads the saved position (and visibility) for it.
65+ item.autosaveName = Self.autosaveName
CcursorBoton line 6516d ago

Mute icon can stay hidden

Medium Severity

Unmuting sets isVisible to false on an NSStatusItem with an autosaveName, which persists that hidden state. The next launch that mutes again creates a new item, restores the saved hidden visibility, and can overwrite the following isVisible = true on a later turn, so the red mic never appears while mute is engaged.

Reviewed by Cursor Bugbot for commit 8345037e6e4b3014213ad10f2fa5c6460ba05b28. Configure here.

Fixed in 0d2467c. makeItem clears the persisted NSStatusItem Visible/VisibleCC keys for this autosave name before setting it. Only the position is restored.

Added 1 commit

  • fix: address review findings on the global mic mutec8b9b98
Synced from GitHub

Your provider account is out of credit.

Pullfrog detected a billing-exhausted response from your provider — the agent stopped before completing this run.

Top up your provider account, then re-trigger Pullfrog.

1provider error: Upstream request failed: Insufficient account funds

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using kimi-k3 | 𝕏

Added 1 commit

  • fix: keep default-input and menu bar state until it is restored0d2467c
Synced from GitHub

Your provider account is out of credit.

Pullfrog detected a billing-exhausted response from your provider — the agent stopped before completing this run.

Top up your provider account, then re-trigger Pullfrog.

1provider error: Upstream request failed: Insufficient account funds

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using kimi-k3 | 𝕏

Sliders/Audio/MicrophoneMute.swiftlines 298–300Outdated · 0d2467cOpen thread
……@@ -0,0 +1,378 @@
297+ else { return false }
298+ guard devices.first(where: { $0.id == currentID })?.uid == swap.to else {
299+ state.defaultSwap = nil
300+ return false
Ccubic-dev-aiBoton lines 298–30016d ago

P2: restoreDefault decides whether to keep the swap record by looking the current default up in MicInputDevice.all(), and MicInputDevice.init(id:) filters devices out — a channel count that transiently reads 0 (sleep/wake, Bluetooth reconnect), an aggregate with no active sub-devices, or Sliders' own plumbing. If the current default is still to but filtered out, the lookup yields nil, the guard fails, and state.defaultSwap is dropped even though the default never moved. The record is then gone for good, so from is never restored and nothing retries it — the exact failure 0d2467c was designed to close. Compare the default's UID read directly from the HAL (currentID.readDeviceUID() == swap.to) and only drop the record when the identity is actually readable and different; keep it when indeterminate.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicrophoneMute.swift, line 298:
2
3<comment>`restoreDefault` decides whether to keep the swap record by looking the current default up in `MicInputDevice.all()`, and `MicInputDevice.init(id:)` filters devices out — a channel count that transiently reads 0 (sleep/wake, Bluetooth reconnect), an aggregate with no active sub-devices, or Sliders' own plumbing. If the current default is still `to` but filtered out, the lookup yields nil, the guard fails, and `state.defaultSwap` is dropped even though the default never moved. The record is then gone for good, so `from` is never restored and nothing retries it — the exact failure 0d2467c was designed to close. Compare the default's UID read directly from the HAL (`currentID.readDeviceUID() == swap.to`) and only drop the record when the identity is actually readable and different; keep it when indeterminate.</comment>
4
5<file context>
6@@ -235,22 +274,42 @@ final class MicrophoneMute {
7+ guard let swap = state.defaultSwap,
8+ let currentID = try? AudioObjectID.readDefaultDevice(.input)
9+ else { return false }
10+ guard devices.first(where: { $0.id == currentID })?.uid == swap.to else {
11+ state.defaultSwap = nil
12+ return false
13</file context>
suggestion
1if let currentUID = try? currentID.readDeviceUID(), currentUID != swap.to {
2 state.defaultSwap = nil
3 return false
4}

✅ Addressed in ad35f20

Fixed in ad35f20. restoreDefault reads the default's UID straight from the HAL (readDeviceUID()), and keeps the record whenever that read fails.

Sliders/Audio/MicrophoneMute.swiftline 199Outdated · 0d2467cOpen thread
……@@ -0,0 +1,378 @@
196+ /// True when the layer is in force: already set, or written successfully.
197+ private func apply(_ layer: MicMutePolicy.Layer, to device: MicInputDevice, endpoint: VolumeEndpoint,
198+ currentMute: Bool?, currentGain: Float?) -> Bool {
199+ let failure = "\(device.uid) \(layer)"
Ccubic-dev-aiBoton line 19916d ago

P3: The same failing key is used for two different refusal reasons in apply: the "Can't read ... before changing it" path (record returns nil) and the "Failed to mute/zero gain" path (write refused). Because failing keeps the key until a successful clearFailure, a device that first can't be read and later fails the write (or vice versa, in whichever order) never logs the second, distinct reason — it's swallowed as a duplicate. Use separate keys for the two reasons so each episode is reported once.

Prompt for AI agents
text
1Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Sliders/Audio/MicrophoneMute.swift, line 199:
2
3<comment>The same `failing` key is used for two different refusal reasons in `apply`: the "Can't read ... before changing it" path (record returns nil) and the "Failed to mute/zero gain" path (write refused). Because `failing` keeps the key until a successful `clearFailure`, a device that first can't be read and later fails the write (or vice versa, in whichever order) never logs the second, distinct reason — it's swallowed as a duplicate. Use separate keys for the two reasons so each episode is reported once.</comment>
4
5<file context>
6@@ -167,42 +182,62 @@ final class MicrophoneMute {
7 currentMute: Bool?, currentGain: Float?) -> Bool {
8- let next = MicMutePolicy.record(layer, currentMute: currentMute, currentGain: currentGain,
9- into: state.restore[device.uid])
10+ let failure = "\(device.uid) \(layer)"
11+ guard let next = MicMutePolicy.record(layer, currentMute: currentMute, currentGain: currentGain,
12+ into: state.restore[device.uid])
13</file context>

✅ Addressed in ad35f20

Added 1 commit

  • fix: judge the default-input swap by the HAL's own UIDad35f20
Synced from GitHub

Your provider account is out of credit.

Pullfrog detected a billing-exhausted response from your provider — the agent stopped before completing this run.

Top up your provider account, then re-trigger Pullfrog.

1provider error: Upstream request failed: Insufficient account funds

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using kimi-k3 | 𝕏

CcursorBotreviewed · 2 inline threads16d ago
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ad35f20f7d7d02f282b6d4e2e358a515e03652dc. Configure here.

Sliders/Audio/MicrophoneMute.swiftline 318Outdated · ad35f20Open thread
……@@ -0,0 +1,384 @@
315+ } catch {
316+ noteFailure("default restore", "Failed to give the default input back to \(from.name)")
317+ return true
318+ }
CcursorBoton line 31816d ago

Default restore stops after read failure

Medium Severity

restoreDefault keeps defaultSwap when the current default's UID cannot be read, but it returns false. reconcile treats that as nothing left to retry and stops the watchdog. After mute and gain restores have already succeeded, no later pass is scheduled, so the default input stays on the substitute device.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit ad35f20f7d7d02f282b6d4e2e358a515e03652dc. Configure here.

Sliders/UI/ControlSlider.swiftline 104Outdated · ad35f20Open thread
……@@ -94,6 +99,9 @@ struct ControlSlider: View {
96101 }
102+ .onChange(of: isEnabled) { _, enabled in
103+ if !enabled { removeScrollMonitor() }
104+ }
CcursorBoton line 10416d ago

Scroll stays off after re-enable

Low Severity

The scroll monitor is created only in onHover, and only when isEnabled is already true. onChange removes it when the slider disables, but does not install it when the slider enables again. A pointer that is already over the slider never regains scroll-to-adjust after global mute ends, or once input becomes live.

Reviewed by Cursor Bugbot for commit ad35f20f7d7d02f282b6d4e2e358a515e03652dc. Configure here.

Added 2 commits

  • fix: retry an unreadable default restore and re-arm slider scrolling32e31a0
  • chore: strip trailing whitespace from pullfrog.ymlbb0e5fb
Synced from GitHub

Your provider account is out of credit.

Pullfrog detected a billing-exhausted response from your provider — the agent stopped before completing this run.

Top up your provider account, then re-trigger Pullfrog.

1provider error: Upstream request failed: Insufficient account funds

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using kimi-k3 | 𝕏