From f035d60d21708feb53316c5277b4b3989272de21 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 18:36:24 +0100 Subject: [PATCH 1/2] fix: invoke stackvox via python3 to bypass pip's baked-in shebang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI-built bundles were arriving on user machines with a stackvox script shebang pointing at /Users/runner/work/.../python3 — the CI runner's filesystem path that pip stamped at install time. Direct execution failed immediately with "bad interpreter, no such file or directory", which surfaced in Settings as "Voices unavailable" and made the voice daemon plist exit with code 78 in a KeepAlive loop. Fix: never invoke the stackvox script directly. Always go through `python3 `. python3 is a real Mach-O binary (symlinked to python3.12) which is relocatable; calling it bypasses the script's shebang entirely. Python finds the venv via its own binary location, so site-packages auto-resolves. Three call sites updated: - Speaker.speak (daemon auto-start + say invocation) - PanelNav.runStackvoxVoices (Settings UI voice-list query) - Bootstrap.writeDaemonPlistIfVenvPresent (launchd plist's ProgramArguments) Speaker.downloadVoiceModel already invoked `python3 -u -c "..."` so that path was unaffected — which is why model downloads worked while voice synthesis didn't. Existing v1.7.1 installs need to either auto-update past this commit or manually re-run the bootstrap wizard so the daemon plist gets rewritten with the python3-prefixed args. Co-Authored-By: Claude Opus 4.7 (1M context) --- panel/Bootstrap.swift | 9 ++++++++- panel/PanelNav.swift | 18 ++++++++++++++---- panel/Speaker.swift | 35 +++++++++++++++++++++-------------- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index a4a37ab..a44ca30 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -515,10 +515,17 @@ enum Bootstrap { // benefit, not the daemon's). private static func writeDaemonPlistIfVenvPresent() throws { guard let venvURL = bundledVenvURL() else { return } + // Invoke via python3 with stackvox as a script argument. pip + // stamps an absolute build-time shebang into the stackvox script + // — CI-built bundles end up with /Users/runner/... which doesn't + // exist on user machines, so running the script directly fails + // with "bad interpreter". python3 is a real Mach-O binary that's + // relocatable; calling it directly bypasses the shebang entirely. + let python = venvURL.appendingPathComponent("bin/python3").path let stackvox = venvURL.appendingPathComponent("bin/stackvox").path let logPath = "\(installDir)/daemon.log" try writePlist(label: daemonLabel, - programArgs: [stackvox, "serve"], + programArgs: [python, stackvox, "serve"], logPath: logPath, env: stackvoxEnv(venvURL: venvURL)) } diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 46af888..a98a14f 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -249,11 +249,21 @@ final class PanelNav: ObservableObject { } private nonisolated static func runStackvoxVoices() -> [String] { - let stackvox = "\(NSHomeDirectory())/.stack-nudge/venv/bin/stackvox" - guard FileManager.default.isExecutableFile(atPath: stackvox) else { return [] } + // Invoke python3 with stackvox as a script argument rather than + // executing the stackvox script directly. pip stamps an absolute + // shebang at install time pointing at the build machine's python3 + // path — CI-built bundles end up with /Users/runner/... shebangs + // that don't exist on user machines, so direct execution fails + // with "bad interpreter". Calling python3 directly bypasses it. + let venvBin = "\(NSHomeDirectory())/.stack-nudge/venv/bin" + let python = "\(venvBin)/python3" + let stackvox = "\(venvBin)/stackvox" + guard FileManager.default.isExecutableFile(atPath: python), + FileManager.default.isReadableFile(atPath: stackvox) + else { return [] } let task = Process() - task.executableURL = URL(fileURLWithPath: stackvox) - task.arguments = ["voices"] + task.executableURL = URL(fileURLWithPath: python) + task.arguments = [stackvox, "voices"] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() diff --git a/panel/Speaker.swift b/panel/Speaker.swift index ed2bf51..aa562ac 100644 --- a/panel/Speaker.swift +++ b/panel/Speaker.swift @@ -9,23 +9,29 @@ import AppKit // binary anymore; speech goes through `stackvox say ` as a subcommand. enum Speaker { + // Never invoke the `stackvox` script directly — pip stamps an absolute + // shebang at install time, so a venv built on the CI runner has a + // shebang pointing at /Users/runner/... that doesn't exist on user + // machines. Always go through `python3 ` so the shebang + // is bypassed and python3's own binary (a real Mach-O) handles loading. static func speak(_ text: String, voice: String? = nil, speed: String? = nil) { let venvBin = "\(NSHomeDirectory())/.stack-nudge/venv/bin" - let stackvox = "\(venvBin)/stackvox" - let socketPath = "\(NSHomeDirectory())/.cache/stackvox/daemon.sock" - guard FileManager.default.isExecutableFile(atPath: stackvox) else { return } + let python = "\(venvBin)/python3" + let stackvox = "\(venvBin)/stackvox" + let socketPath = "\(NSHomeDirectory())/.cache/stackvox/daemon.sock" + guard FileManager.default.isExecutableFile(atPath: python), + FileManager.default.isReadableFile(atPath: stackvox) + else { return } + + let venvURL = URL(fileURLWithPath: "\(NSHomeDirectory())/.stack-nudge/venv") + .resolvingSymlinksInPath() + var env = ProcessInfo.processInfo.environment + env.merge(Bootstrap.stackvoxEnv(venvURL: venvURL)) { _, new in new } if !FileManager.default.fileExists(atPath: socketPath) { let serve = Process() - serve.executableURL = URL(fileURLWithPath: stackvox) - serve.arguments = ["serve"] - // Same espeak-ng data-path workaround as the launchd plist — - // the wheel's libespeak-ng.dylib has a CI-build phontab path - // baked in; ESPEAK_DATA_PATH overrides it at runtime. - let venvURL = URL(fileURLWithPath: "\(NSHomeDirectory())/.stack-nudge/venv") - .resolvingSymlinksInPath() - var env = ProcessInfo.processInfo.environment - env.merge(Bootstrap.stackvoxEnv(venvURL: venvURL)) { _, new in new } + serve.executableURL = URL(fileURLWithPath: python) + serve.arguments = [stackvox, "serve"] serve.environment = env try? serve.run() } @@ -34,8 +40,9 @@ enum Speaker { let resolvedVoice = voice ?? config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" let resolvedSpeed = speed ?? config["STACKNUDGE_VOICE_SPEED"] ?? "1.1" let say = Process() - say.executableURL = URL(fileURLWithPath: stackvox) - say.arguments = ["say", "--voice", resolvedVoice, "--speed", resolvedSpeed, text] + say.executableURL = URL(fileURLWithPath: python) + say.arguments = [stackvox, "say", "--voice", resolvedVoice, "--speed", resolvedSpeed, text] + say.environment = env say.standardOutput = FileHandle.nullDevice say.standardError = FileHandle.nullDevice say.terminationHandler = { ended in From 112b2720a2771c88450f58ef43a361f0feea62be Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 19 May 2026 18:36:24 +0100 Subject: [PATCH 2/2] fix(usage): bound ScrollView height + show indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Usage tab's ScrollView wasn't getting an explicit max-height claim, so when the four quota tiers exceeded the panel's available space the content rendered past the bottom edge and clipped behind the PageFooter — with no visible scrollbar to hint at the overflow. - .frame(maxHeight: .infinity) forces the scrollview to bound itself to the available area rather than expanding to fit content. - .scrollIndicators(.visible) overrides macOS's "auto-hide when not scrolling" default so users see the affordance immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- panel/SessionUsage.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 6c048a9..c5d2c0a 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -175,6 +175,13 @@ struct UsageView: View { .padding(.vertical, 14) .background(ThinScrollers()) } + // Explicit max-height claim so the ScrollView reliably + // bounds itself to the panel's available area instead of + // expanding to fit its content (which clipped behind the + // PageFooter without a visible scrollbar hint). Force the + // indicator visible so users can tell there's more to see. + .frame(maxHeight: .infinity) + .scrollIndicators(.visible) } else { emptyState }