Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions LifeOS/install/hooks/lib/notification-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof VARS)[number], string | undefined>> = {};

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");
});
});
7 changes: 6 additions & 1 deletion LifeOS/install/hooks/lib/notification-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down