diff --git a/LifeOS/install/hooks/lib/notification-channel.test.ts b/LifeOS/install/hooks/lib/notification-channel.test.ts new file mode 100644 index 0000000000..0c6ee9f012 --- /dev/null +++ b/LifeOS/install/hooks/lib/notification-channel.test.ts @@ -0,0 +1,62 @@ +/** + * notification-channel.test.ts — getNotificationChannel() must treat a Claude + * desktop-app session as 'desktop'. The app sets none of the TERM-family + * variables the sniff keys on, only CLAUDE_CODE_ENTRYPOINT=claude-desktop, + * so every voice-firing hook classified it 'headless' and skipped /notify. + * (public issue #1975) + * + * bun test LifeOS/install/hooks/lib/notification-channel.test.ts + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getNotificationChannel } from "./notification-channel"; + +const VARS = [ + "LIFEOS_NOTIFICATION_CHANNEL", + "TERM", + "TERM_PROGRAM", + "KITTY_WINDOW_ID", + "SSH_TTY", + "CLAUDE_CODE_ENTRYPOINT", +] as const; + +const saved: Partial> = {}; + +beforeEach(() => { + for (const k of VARS) { + saved[k] = process.env[k]; + delete process.env[k]; + } +}); + +afterEach(() => { + for (const k of VARS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("getNotificationChannel", () => { + test("desktop app: entrypoint marker, no terminal identity → desktop", () => { + process.env.CLAUDE_CODE_ENTRYPOINT = "claude-desktop"; + expect(getNotificationChannel()).toBe("desktop"); + }); + + test("explicit LIFEOS_NOTIFICATION_CHANNEL still wins over the desktop marker", () => { + process.env.CLAUDE_CODE_ENTRYPOINT = "claude-desktop"; + process.env.LIFEOS_NOTIFICATION_CHANNEL = "headless"; + expect(getNotificationChannel()).toBe("headless"); + process.env.LIFEOS_NOTIFICATION_CHANNEL = "imessage"; + expect(getNotificationChannel()).toBe("imessage"); + }); + + test("no terminal identity and no desktop marker → headless", () => { + expect(getNotificationChannel()).toBe("headless"); + process.env.CLAUDE_CODE_ENTRYPOINT = "cli"; + expect(getNotificationChannel()).toBe("headless"); + }); + + test("terminal identity alone → desktop (unchanged)", () => { + process.env.TERM = "xterm-256color"; + expect(getNotificationChannel()).toBe("desktop"); + }); +}); diff --git a/LifeOS/install/hooks/lib/notification-channel.ts b/LifeOS/install/hooks/lib/notification-channel.ts index 532b83abda..7b5a0f787e 100644 --- a/LifeOS/install/hooks/lib/notification-channel.ts +++ b/LifeOS/install/hooks/lib/notification-channel.ts @@ -35,6 +35,10 @@ const VOICE_LOG_PATH = paiPath('MEMORY', 'VOICE', 'voice-events.jsonl'); * carry only HOME+PATH; an interactive session always inherits TERM (kitty, * Terminal.app, ssh). A session that was not spawned from a real terminal * must never reach the speaker, no matter what the model inside it does. + * The Claude desktop app is the one interactive surface with a speaker + * and no terminal: it sets none of the TERM-family variables, only + * CLAUDE_CODE_ENTRYPOINT=claude-desktop, so that marker counts as + * terminal identity here (public issue #1975). * 3. Otherwise 'desktop' — terminal/main-session behavior is preserved. * * Spawner contract: every LifeOS tool that spawns `claude --print` also sets @@ -46,7 +50,8 @@ export function getNotificationChannel(): NotificationChannel { const raw = process.env.LIFEOS_NOTIFICATION_CHANNEL; if (raw && raw.length > 0) return raw as NotificationChannel; const env = process.env; - if (!env.TERM && !env.TERM_PROGRAM && !env.KITTY_WINDOW_ID && !env.SSH_TTY) { + const desktopApp = env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop'; + if (!env.TERM && !env.TERM_PROGRAM && !env.KITTY_WINDOW_ID && !env.SSH_TTY && !desktopApp) { return 'headless'; } return 'desktop';