Skip to content

voice.ts: desktop notifications are silently unavailable on Linux — the platform guard from #1239 never got its notify-send branch #2024

Description

@MatiasBarboza

Version: 7.40.4 (LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts)

What's broken

On Linux, POST /notify returns 200 {"status":"success","message":"Notification sent"} and no
notification is ever displayed. Nothing is logged about it either — the module reports success for a
channel it knows it cannot reach on this platform.

This is the second half of #1239, which you closed on 2026-05-14 after landing the platform guard.
Your own comment at the time called it out and deferred it:

(Linux notify-send equivalent and the WSL2 powershell-shim path you mentioned are worth doing
eventually but they're a feature, not a fix — separate scope from the ENOENT silencing.)

That was the right call for that issue: the guard traded a loud wrong failure (ENOENT flooding the
journal) for a quiet one, which is a strict improvement while the branch doesn't exist. Filing this
now because three separate threads asked for it to come back on the current release:

It still applies on 7.40.4, unchanged.

file:line

LIFEOS/PULSE/VoiceServer/voice.ts:486-489

async function showDesktopNotification(title: string, message: string): Promise<void> {
  if (!voiceConfig.desktopNotifications) return
  // osascript is macOS-only; on Linux/WSL2 the spawn ENOENT's and floods journal
  if (process.platform !== "darwin") return

The relevant contrast is 100 lines up in the same file. resolveAudioPlayer() at
voice.ts:381-425 already does per-platform resolution for the audio path — afplay on darwin,
then ffplaympg123paplayaplay on everything else, with a cached null when none is
found. The notification path never got the same treatment, so one of the two adjacent output
channels resolves per platform and the other hard-codes one.

Repro against a clean tree

Runs on Linux, needs only bun and git. No install, no config, no API key — TTS is switched off
so the only thing exercised is the notification path.

mkdir -p /tmp/lifeos-repro/home/.claude && cd /tmp/lifeos-repro
git clone --depth 1 --branch v7.40.4 --quiet https://github.com/danielmiessler/LifeOS.git clean
echo '{ "notifications": { "desktop": { "enabled": true } } }' > home/.claude/settings.json

cat > repro.ts <<'TS'
import { startVoice, handleVoiceRequest } from "./clean/LifeOS/install/LIFEOS/PULSE/VoiceServer/voice.ts"
startVoice({ enabled: true })
const res = await handleVoiceRequest(new Request("http://localhost/notify", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ title: "repro", message: "a desktop notification should be visible now", voice_enabled: false }),
}))
console.log("HTTP", res!.status, await res!.text())
TS

HOME=/tmp/lifeos-repro/home bun repro.ts

Negative control

Measured on Linux 7.0.11 (Pop!_OS), notify-send present at /usr/bin/notify-send, desktop
notifications explicitly enabled in settings.json:

{"level":"info","msg":"Voice: notification \"repro\" - \"a desktop notification should be visible now\"","voiceEnabled":false,...}
HTTP 200 {"status":"success","message":"Notification sent"}

Nothing appeared on screen. There is no warn line and no error line about the notification — the
only trace is the info line that says it is notifying. The same script on macOS displays the
notification, so the observable difference is entirely the platform branch.

That is the part worth fixing independently of how it gets fixed: a channel that cannot emit on this
platform reports success. If notify-send never lands, a one-line log("warn", ...) in that branch
would still be an improvement over a green 200.

Suggested fix

Same shape as resolveAudioPlayer(), one function down — resolve a notifier once, cache it, warn
once when there is none:

interface DesktopNotifier {
  path: string
  buildArgs: (title: string, message: string) => string[]
}

let resolvedNotifier: DesktopNotifier | null | undefined = undefined

function resolveDesktopNotifier(): DesktopNotifier | null {
  if (resolvedNotifier !== undefined) return resolvedNotifier

  if (process.platform === "darwin") {
    const path = Bun.which("osascript")
    resolvedNotifier = path
      ? {
          path,
          buildArgs: (title, message) => [
            "-e",
            `display notification "${escapeForAppleScript(message)}" with title "${escapeForAppleScript(title)}" sound name ""`,
          ],
        }
      : null
    return resolvedNotifier
  }

  const notifySend = Bun.which("notify-send")
  resolvedNotifier = notifySend
    ? { path: notifySend, buildArgs: (title, message) => ["--app-name=LifeOS", "--", title, message] }
    : null
  return resolvedNotifier
}

and in showDesktopNotification, replace the early return with a lookup that says so when it fails:

const notifier = resolveDesktopNotifier()
if (!notifier) {
  log("warn", `Voice: no desktop notifier found on ${process.platform} — notification will not be shown`)
  return
}

Two things this buys beyond Linux coverage:

  • Bun.which on darwin too. Today the darwin path spawns /usr/bin/osascript by absolute
    path, so a Mac without it at that exact location hits the original ENOENT rather than the guard.
    Resolving both platforms the same way removes that.
  • No shell string on the Linux side. notify-send takes title and body as separate argv
    entries, so there is nothing to escape — no escapeForAppleScript equivalent to get wrong. The
    -- terminator keeps a message starting with - from being read as a flag.

Running this here since 2026-08-12. Happy to open the PR if you want it; also happy to send just the
log("warn") half if the notify-send branch is still out of scope, since that alone removes the
silent-success.

Separate, not in this issue

The same install also needed a non-ElevenLabs TTS engine, and the gate at voice.ts:531 checks
moduleConfig.elevenlabs_api_key directly rather than "is any engine available". That is a
provider-architecture question, it is what discussion #834 was about, and it does not belong bolted
onto a two-function platform fix. If a fresh version of that discussion is useful I will open it on
its own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions