ai-notify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ #!/bin/bash
2
+ # Menu bar indicator + toggle for ai-notify, via SwiftBar (or xbar).
3
+ #
4
+ # This is the recommended setup: an always-visible 🔔 / 🔕 in the macOS menu
5
+ # bar that you click to mute everything — no terminal needed, state always shown.
6
+ #
7
+ # Install:
8
+ # 1. brew install --cask swiftbar (or xbar)
9
+ # 2. Copy this file into your SwiftBar plugin folder, keep the ".3s.sh"
10
+ # suffix (refreshes every 3s), and make it executable:
11
+ # chmod +x ai-notify.3s.sh
12
+ # 3. The icon appears in your menu bar. Click it → "Toggle mute".
13
+
14
+ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
15
+
16
+ ICON=$(ai-notify status --icon 2>/dev/null || echo "❓")
17
+
18
+ # Menu bar title (just the glyph)
19
+ echo "$ICON"
20
+ echo "---"
21
+ # Dropdown actions. terminal=false runs silently; refresh=true re-renders the icon.
22
+ echo "Toggle mute | bash=ai-notify param1=toggle terminal=false refresh=true"
23
+ echo "Show status | bash=ai-notify param1=status terminal=true"
@@ -0,0 +1,37 @@
1
+ # Show mute state in your tmux / shell prompt
2
+
3
+ If you live in tmux, put the indicator in the status bar — always visible,
4
+ across every pane, even while agents run.
5
+
6
+ `~/.tmux.conf`:
7
+
8
+ ```tmux
9
+ set -g status-interval 3
10
+ set -ga status-right '#(ai-notify status --icon) '
11
+ ```
12
+
13
+ Bind a key to toggle without leaving tmux (here: prefix + N):
14
+
15
+ ```tmux
16
+ bind N run-shell 'ai-notify toggle'
17
+ ```
18
+
19
+ ## Shell prompt (zsh)
20
+
21
+ Show `🔕` in your prompt only when muted:
22
+
23
+ ```zsh
24
+ ai_notify_indicator() { [ "$(ai-notify status --plain)" = muted ] && echo "🔕 "; }
25
+ setopt PROMPT_SUBST
26
+ PROMPT='$(ai_notify_indicator)'$PROMPT
27
+ ```
28
+
29
+ ## Starship
30
+
31
+ ```toml
32
+ # ~/.config/starship.toml
33
+ [custom.ai_notify]
34
+ command = "ai-notify status --icon"
35
+ when = true
36
+ format = "[$output]($style) "
37
+ ```
package/src/cli.mjs ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ // ai-notify — desktop/sound notifications for terminal AI coding agents.
3
+ // One mute switch for all of them, across every terminal. No daemon.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { providers, byId } from './providers/index.mjs';
7
+ import { emit } from './notify.mjs';
8
+ import { deriveLabel, cliInvocation, isEphemeralInstall } from './util.mjs';
9
+ import { curatedVoices, resolveVoice, previewVoice } from './voices.mjs';
10
+ import * as menubar from './menubar.mjs';
11
+ import { translate } from './translate.mjs';
12
+ import { isMuted, setMuted, toggleMuted, readConfig, writeConfig, paths, DEFAULT_CONFIG } from './state.mjs';
13
+
14
+ const VERSION = '0.1.0';
15
+
16
+ const args = process.argv.slice(2);
17
+ const cmd = args[0];
18
+
19
+ // Tiny flag parser: --key value / --flag
20
+ const opt = (name, fallback = undefined) => {
21
+ const i = args.indexOf(`--${name}`);
22
+ if (i === -1) return fallback;
23
+ const next = args[i + 1];
24
+ return next && !next.startsWith('--') ? next : true;
25
+ };
26
+ const positionals = args.slice(1).filter((a) => !a.startsWith('--'));
27
+
28
+ const log = (...m) => console.log(...m);
29
+ const onlyFilter = () => {
30
+ const only = opt('only');
31
+ return typeof only === 'string' ? only.split(',').map((s) => s.trim()) : null;
32
+ };
33
+ const selected = () => {
34
+ const only = onlyFilter();
35
+ return providers.filter((p) => (only ? only.includes(p.id) : true));
36
+ };
37
+
38
+ const readStdinJson = () => {
39
+ try {
40
+ return JSON.parse(readFileSync(0, 'utf8'));
41
+ } catch {
42
+ return {};
43
+ }
44
+ };
45
+
46
+ const cmds = {
47
+ init() {
48
+ const dryRun = !!opt('dry-run');
49
+ const { node, cliPath } = cliInvocation();
50
+ if (isEphemeralInstall(cliPath)) {
51
+ log('⚠ Running from a temporary npx cache. Hooks need a persistent install.');
52
+ log(' Install first: npm i -g ai-notify then: ai-notify init\n');
53
+ }
54
+ log(dryRun ? 'Preview (no changes written):\n' : 'Wiring detected agents:\n');
55
+ let any = false;
56
+ for (const p of selected()) {
57
+ if (!p.detect()) continue;
58
+ any = true;
59
+ const r = p.wire({ node, cliPath, dryRun });
60
+ const icon = r.skipped ? '⚠ ' : r.changed ? '✓ ' : '· ';
61
+ log(` ${icon}${p.displayName}: ${r.detail}`);
62
+ }
63
+ if (!any) log(' No supported agents detected (looked for Claude Code, Codex, Gemini).');
64
+ log(`\nMute toggle: ai-notify toggle Status: ai-notify status`);
65
+ if (!dryRun) log('Restart already-running Codex sessions to pick up the change.');
66
+ },
67
+
68
+ uninstall() {
69
+ const dryRun = !!opt('dry-run');
70
+ log('Removing ai-notify wiring:\n');
71
+ for (const p of selected()) {
72
+ const r = p.unwire({ dryRun });
73
+ log(` ${r.changed ? '✓ ' : '· '}${p.displayName}: ${r.detail}`);
74
+ }
75
+ },
76
+
77
+ on() { setMuted(false); log('🔔 notifications ON'); emitConfirm(); },
78
+ off() { setMuted(true); log('🔕 notifications OFF (muted)'); },
79
+ toggle() {
80
+ const muted = toggleMuted();
81
+ log(muted ? '🔕 notifications OFF (muted)' : '🔔 notifications ON');
82
+ if (!muted) emitConfirm();
83
+ },
84
+ status() {
85
+ // Compact forms for embedding in menu bars, prompts, tmux, and the
86
+ // Claude Code statusline — where you can't type a command but want the
87
+ // state always visible.
88
+ if (opt('icon')) return log(isMuted() ? '🔕' : '🔔');
89
+ if (opt('plain')) return log(isMuted() ? 'muted' : 'on');
90
+
91
+ log(`notifications: ${isMuted() ? '🔕 OFF (muted)' : '🔔 ON'}`);
92
+ log(`flag: ${paths.muteFlagPath()}`);
93
+ log(`config: ${paths.configPath()}\n`);
94
+ for (const p of providers) {
95
+ const s = p.status();
96
+ if (!s.installed) continue;
97
+ log(` ${p.displayName.padEnd(14)} ${s.wired ? '✓ wired' : '✗ not wired'}`);
98
+ }
99
+ },
100
+
101
+ doctor() {
102
+ log(`ai-notify ${VERSION} (node ${process.version}, ${process.platform})\n`);
103
+ const { cliPath } = cliInvocation();
104
+ if (isEphemeralInstall(cliPath)) log('⚠ ephemeral npx install — run `npm i -g ai-notify` for hooks to persist.\n');
105
+ log('Agents:');
106
+ for (const p of providers) {
107
+ const s = p.status();
108
+ log(` ${p.displayName.padEnd(14)} ${!s.installed ? '— not installed' : s.wired ? '✓ wired' : '✗ detected, not wired'}`);
109
+ }
110
+ },
111
+
112
+ config() {
113
+ if (positionals[0] === 'init') {
114
+ const file = writeConfig(readConfig());
115
+ log(`wrote ${file}`);
116
+ } else {
117
+ log(JSON.stringify(readConfig(), null, 2));
118
+ }
119
+ },
120
+
121
+ // Pick the spoken read-out voice from the machine's built-in `say` voices.
122
+ // Offline, free, no API. Different voice per task = tell terminals apart.
123
+ voice() {
124
+ const sub = positionals[0];
125
+ const config = readConfig();
126
+ const list = curatedVoices(10);
127
+ const sample =
128
+ (config.doneMessage || 'finished').replace(/\{label\}/g, 'ai-notify').replace(/\s+/g, ' ').trim() ||
129
+ 'finished';
130
+
131
+ const setVoice = (name) => {
132
+ config.voice = name; // '' = OS default
133
+ // Global voice wins only if no per-provider override; clear them so the
134
+ // single switch actually takes effect everywhere.
135
+ for (const k of Object.keys(config.providers || {})) {
136
+ if (config.providers[k]) delete config.providers[k].voice;
137
+ }
138
+ writeConfig(config);
139
+ };
140
+
141
+ if (sub === 'preview' || sub === 'test' || sub === 'all') {
142
+ if (!list.length) return log('No `say` voices found (this is a macOS feature).');
143
+ log('Previewing voices — listen, then: ai-notify voice <number>\n');
144
+ list.forEach((n, i) => {
145
+ log(` ${String(i + 1).padStart(2)}. ${n}`);
146
+ previewVoice(n, `${i + 1}番。${n}。${sample}`);
147
+ });
148
+ return;
149
+ }
150
+
151
+ if (sub === 'default' || sub === 'off' || sub === 'reset' || sub === 'none') {
152
+ setVoice('');
153
+ return log('Voice reset to the OS default.');
154
+ }
155
+
156
+ if (sub) {
157
+ const picked = resolveVoice(sub, list);
158
+ if (!picked) {
159
+ console.error(`unknown voice: ${sub} (see: ai-notify voice)`);
160
+ process.exit(1);
161
+ }
162
+ setVoice(picked);
163
+ log(`🔊 voice → ${picked}`);
164
+ previewVoice(picked, sample);
165
+ return;
166
+ }
167
+
168
+ // No arg: list the menu.
169
+ if (!list.length) {
170
+ log('No `say` voices found — voice selection is a macOS feature.');
171
+ log('On other platforms, set "voice" in config.json to any name your TTS accepts.');
172
+ return;
173
+ }
174
+ const current = config.voice || '(OS default)';
175
+ log(`Current voice: ${current}\n`);
176
+ list.forEach((n, i) => log(` ${String(i + 1).padStart(2)}. ${n}${n === config.voice ? ' ← current' : ''}`));
177
+ log('\n Choose: ai-notify voice <number|name>');
178
+ log(' Hear all: ai-notify voice preview');
179
+ log(' Reset: ai-notify voice default');
180
+ },
181
+
182
+ // Native menu bar bell (macOS). Self-contained — no Hammerspoon/SwiftBar.
183
+ menubar() {
184
+ const sub = positionals[0] || 'status';
185
+ if (!menubar.isMac()) return log('The menu bar agent is macOS-only.');
186
+
187
+ if (sub === 'install') {
188
+ log('Installing the ai-notify menu bar agent…');
189
+ if (!menubar.isBuilt()) log(' building the app (system Swift)…');
190
+ const r = menubar.install();
191
+ log(` ✓ app: ${r.app}`);
192
+ log(` ✓ agent: ${r.plist} (starts at login)`);
193
+ log('A 🔔 should now be in your menu bar. Left-click toggles, right-click for a menu.');
194
+ return;
195
+ }
196
+ if (sub === 'uninstall') {
197
+ menubar.uninstall();
198
+ log('✓ Removed the menu bar agent (LaunchAgent unloaded, app stopped).');
199
+ return;
200
+ }
201
+ if (sub === 'build') {
202
+ log(`✓ built: ${menubar.build()}`);
203
+ return;
204
+ }
205
+ // status
206
+ log(`menu bar agent:`);
207
+ log(` built: ${menubar.isBuilt() ? '✓' : '— (run: ai-notify menubar build)'}`);
208
+ log(` installed: ${menubar.isInstalled() ? '✓ (auto-start at login)' : '—'}`);
209
+ log(` running: ${menubar.isRunning() ? '✓' : '—'}`);
210
+ if (!menubar.isInstalled()) log('\nEnable it: ai-notify menubar install');
211
+ },
212
+
213
+ // Translate the agent's spoken message into your language before speaking it.
214
+ // Key-less and free (one HTTP request, no dependency); falls back to your
215
+ // templates if offline.
216
+ translate() {
217
+ const sub = positionals[0] || 'status';
218
+ const config = readConfig();
219
+
220
+ if (sub === 'on') {
221
+ const lang = positionals[1] || 'ja';
222
+ config.translateTo = lang;
223
+ config.speakAgentMessage = true; // we must keep the message to translate it
224
+ writeConfig(config);
225
+ log(`✓ translation on → ${lang}. Testing…`);
226
+ const out = translate('The task is done. I updated three files.', lang, 8000);
227
+ log(out ? ` EN→ ${out}` : ' ⚠ no result (offline?). Falls back to your templates.');
228
+ return;
229
+ }
230
+ if (sub === 'off' || sub === 'none') {
231
+ config.translateTo = '';
232
+ writeConfig(config);
233
+ return log('Translation off.');
234
+ }
235
+ if (sub === 'test') {
236
+ const lang = config.translateTo || 'ja';
237
+ const text = positionals.slice(1).join(' ') || 'Claude needs your permission to run a command.';
238
+ const out = translate(text, lang, 8000);
239
+ log(out ? `EN ${text}\n${lang.toUpperCase()} ${out}` : '⚠ no result (offline?)');
240
+ return;
241
+ }
242
+ // status
243
+ log(`translation: ${config.translateTo ? `on → ${config.translateTo}` : 'off'}`);
244
+ if (!config.translateTo) log('Enable: ai-notify translate on ja');
245
+ },
246
+
247
+ hook() {
248
+ const source = opt('source', 'default');
249
+ let event = opt('event', 'done');
250
+ let cwd = '';
251
+ let message = '';
252
+
253
+ if (source === 'codex') {
254
+ // Codex passes a single JSON argument.
255
+ let data = {};
256
+ try { data = JSON.parse(positionals[0] || '{}'); } catch { /* ignore */ }
257
+ if (data.type && data.type !== 'agent-turn-complete') process.exit(0);
258
+ cwd = data.cwd || '';
259
+ message = data['last-assistant-message'] || '';
260
+ event = 'done';
261
+ } else {
262
+ // Claude (and the generic case) pass JSON on stdin.
263
+ const data = readStdinJson();
264
+ cwd = data.cwd || '';
265
+ message = data.message || '';
266
+ }
267
+
268
+ const label = deriveLabel(cwd);
269
+ emit({ provider: byId(source) ? source : 'default', event, label, message });
270
+ },
271
+
272
+ version() { log(VERSION); },
273
+ help() { printHelp(); },
274
+ };
275
+
276
+ function emitConfirm() {
277
+ emit({ provider: 'default', event: 'done', label: 'ai-notify', message: readConfig().onMessage });
278
+ }
279
+
280
+ function printHelp() {
281
+ log(`ai-notify ${VERSION} — notifications for terminal AI coding agents
282
+
283
+ Usage:
284
+ ai-notify init [--dry-run] [--only claude,codex] wire detected agents
285
+ ai-notify uninstall [--only ...] remove wiring
286
+ ai-notify toggle | on | off | status control the mute switch
287
+ ai-notify voice [number|name|preview|default] pick the spoken voice
288
+ ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
289
+ ai-notify translate [on <lang>|off|test] speak agent text in your language
290
+ ai-notify doctor check deps & wiring
291
+ ai-notify config [init] print (or write) config
292
+
293
+ Per-window overrides (export in a terminal before launching the agent):
294
+ AI_NOTIFY_VOICE=Eddy give this window/pane its own spoken voice
295
+ AI_NOTIFY_LABEL=api name this window in the spoken/banner read-out
296
+
297
+ Make it one tap: bind a hotkey / menubar button to \`ai-notify toggle\`
298
+ (see recipes/ for macOS Shortcuts, Raycast, Stream Deck).`);
299
+ }
300
+
301
+ const handler =
302
+ cmds[cmd] ||
303
+ (cmd === '-v' || cmd === '--version' ? cmds.version : null) ||
304
+ (cmd === undefined || cmd === '-h' || cmd === '--help' ? cmds.help : null);
305
+
306
+ if (!handler) {
307
+ console.error(`unknown command: ${cmd}\n`);
308
+ printHelp();
309
+ process.exit(1);
310
+ }
311
+ handler();
@@ -0,0 +1,97 @@
1
+ // Native menu bar agent management (macOS).
2
+ //
3
+ // Ships a tiny self-contained NSStatusItem app (menubar/) and runs it as a
4
+ // per-user LaunchAgent so a live 🔔/🔕 appears in the menu bar — with NO
5
+ // third-party app (Hammerspoon/SwiftBar/etc.) required.
6
+ //
7
+ // The app and the CLI share one truth: the mute flag file. No IPC, no daemon
8
+ // beyond this one lightweight GUI agent.
9
+
10
+ import { homedir, platform } from 'node:os';
11
+ import { join, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
14
+ import { execFileSync, spawnSync } from 'node:child_process';
15
+
16
+ export const LABEL = 'com.ai-notify.menubar';
17
+
18
+ const pkgRoot = () => dirname(dirname(fileURLToPath(import.meta.url))); // .../ai-notify
19
+ const menubarDir = () => join(pkgRoot(), 'menubar');
20
+ export const appPath = () => join(menubarDir(), 'dist', 'ai-notify.app');
21
+ const exePath = () => join(appPath(), 'Contents', 'MacOS', 'ai-notify-menubar');
22
+ const plistPath = () => join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
23
+
24
+ export const isMac = () => platform() === 'darwin';
25
+ export const isBuilt = () => existsSync(exePath());
26
+ export const isInstalled = () => existsSync(plistPath());
27
+
28
+ export const isRunning = () => {
29
+ const r = spawnSync('pgrep', ['-f', 'ai-notify-menubar'], { encoding: 'utf8' });
30
+ return r.status === 0 && r.stdout.trim().length > 0;
31
+ };
32
+
33
+ // Build the .app from source with the system Swift toolchain (no Xcode project).
34
+ export const build = () => {
35
+ const script = join(menubarDir(), 'build.sh');
36
+ if (!existsSync(script)) throw new Error('menubar/build.sh missing');
37
+ execFileSync('bash', [script], { stdio: 'inherit' });
38
+ return appPath();
39
+ };
40
+
41
+ const writePlist = () => {
42
+ const dir = dirname(plistPath());
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
44
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
45
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
46
+ <plist version="1.0">
47
+ <dict>
48
+ <key>Label</key><string>${LABEL}</string>
49
+ <key>ProgramArguments</key>
50
+ <array>
51
+ <string>${exePath()}</string>
52
+ </array>
53
+ <key>RunAtLoad</key><true/>
54
+ <key>ProcessType</key><string>Interactive</string>
55
+ <key>LimitLoadToSessionType</key><string>Aqua</string>
56
+ </dict>
57
+ </plist>
58
+ `;
59
+ writeFileSync(plistPath(), xml);
60
+ };
61
+
62
+ const launchctl = (...args) => spawnSync('launchctl', args, { encoding: 'utf8' });
63
+
64
+ const killExisting = () => spawnSync('pkill', ['-f', 'ai-notify-menubar']);
65
+
66
+ // Load (and start) the agent. Tries the modern domain API, falls back to legacy.
67
+ const load = () => {
68
+ const uid = process.getuid();
69
+ killExisting(); // avoid a duplicate icon if one was launched by hand
70
+ let r = launchctl('bootstrap', `gui/${uid}`, plistPath());
71
+ if (r.status !== 0) r = launchctl('load', '-w', plistPath());
72
+ launchctl('kickstart', '-k', `gui/${uid}/${LABEL}`);
73
+ return r;
74
+ };
75
+
76
+ const unload = () => {
77
+ const uid = process.getuid();
78
+ let r = launchctl('bootout', `gui/${uid}/${LABEL}`);
79
+ if (r.status !== 0) r = launchctl('unload', '-w', plistPath());
80
+ killExisting();
81
+ return r;
82
+ };
83
+
84
+ export const install = () => {
85
+ if (!isMac()) throw new Error('the menu bar agent is macOS-only');
86
+ if (!isBuilt()) build();
87
+ writePlist();
88
+ load();
89
+ return { app: appPath(), plist: plistPath() };
90
+ };
91
+
92
+ export const uninstall = () => {
93
+ if (isInstalled()) unload();
94
+ else killExisting();
95
+ if (existsSync(plistPath())) rmSync(plistPath());
96
+ return { plist: plistPath() };
97
+ };
package/src/notify.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // Cross-platform notifier: sound + spoken read-out + desktop banner.
2
+ //
3
+ // Every emitter is best-effort and degrades silently when a backend is missing,
4
+ // so a Linux box without `notify-send` (or a Mac without `terminal-notifier`)
5
+ // never errors — it just does what it can.
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import { isMuted, readConfig } from './state.mjs';
10
+ import { translate } from './translate.mjs';
11
+
12
+ const platform = process.platform; // 'darwin' | 'linux' | 'win32'
13
+
14
+ const run = (cmd, args) => {
15
+ try {
16
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: false });
17
+ child.on('error', () => {}); // missing binary -> ignore
18
+ } catch {
19
+ /* ignore */
20
+ }
21
+ };
22
+
23
+ const which = (bin) =>
24
+ (process.env.PATH || '')
25
+ .split(':')
26
+ .some((dir) => dir && existsSync(`${dir}/${bin}`));
27
+
28
+ // Resolve a configured sound name to something the OS can play.
29
+ const resolveSound = (name) => {
30
+ if (!name) return null;
31
+ if (name.includes('/')) return name; // already an absolute/relative path
32
+ if (platform === 'darwin') return `/System/Library/Sounds/${name}.aiff`;
33
+ return name; // linux/win: treated as a freedesktop event id / ignored
34
+ };
35
+
36
+ const playSound = (name) => {
37
+ const sound = resolveSound(name);
38
+ if (platform === 'darwin') {
39
+ if (sound && existsSync(sound)) {
40
+ // play twice, a touch louder, so it is hard to miss
41
+ run('afplay', ['-v', '2', sound]);
42
+ run('afplay', ['-v', '2', sound]);
43
+ }
44
+ } else if (platform === 'linux') {
45
+ if (which('paplay') && existsSync('/usr/share/sounds/freedesktop/stereo/complete.oga')) {
46
+ run('paplay', ['/usr/share/sounds/freedesktop/stereo/complete.oga']);
47
+ } else if (which('canberra-gtk-play')) {
48
+ run('canberra-gtk-play', ['-i', 'complete']);
49
+ } else if (which('aplay')) {
50
+ run('aplay', ['-q', '/usr/share/sounds/alsa/Front_Center.wav']);
51
+ }
52
+ } else if (platform === 'win32') {
53
+ run('powershell', ['-NoProfile', '-Command', '[console]::beep(880,200)']);
54
+ }
55
+ };
56
+
57
+ const speak = (text, voice) => {
58
+ if (!text) return;
59
+ if (platform === 'darwin') {
60
+ run('say', voice ? ['-v', voice, text] : [text]);
61
+ } else if (platform === 'linux') {
62
+ if (which('spd-say')) run('spd-say', [text]);
63
+ else if (which('espeak')) run('espeak', [text]);
64
+ } else if (platform === 'win32') {
65
+ run('powershell', [
66
+ '-NoProfile',
67
+ '-Command',
68
+ `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${text.replace(/'/g, '')}')`,
69
+ ]);
70
+ }
71
+ };
72
+
73
+ const banner = (title, subtitle, message) => {
74
+ if (platform === 'darwin') {
75
+ if (which('terminal-notifier')) {
76
+ run('terminal-notifier', ['-title', title, '-subtitle', subtitle, '-message', message]);
77
+ } else {
78
+ const esc = (s) => String(s).replace(/"/g, '\\"');
79
+ run('osascript', ['-e', `display notification "${esc(message)}" with title "${esc(title)}" subtitle "${esc(subtitle)}"`]);
80
+ }
81
+ } else if (platform === 'linux') {
82
+ if (which('notify-send')) run('notify-send', [`${title}: ${subtitle}`, message]);
83
+ }
84
+ // win32: skipped (no dependency-free toast); sound/voice still fire.
85
+ };
86
+
87
+ // Public entry. Called by the hook handler with already-parsed fields.
88
+ export const emit = ({ provider = 'default', event = 'done', label = '', message = '' }) => {
89
+ const config = readConfig();
90
+ const muted = isMuted();
91
+ const p = config.providers[provider] || config.providers.default;
92
+
93
+ const soundName = (p.sound && (p.sound[event] || p.sound.done)) || null;
94
+ const template = (event === 'waiting' ? config.waitingMessage : config.doneMessage) || '';
95
+ const fromTemplate = template.replace(/\{label\}/g, label).replace(/\s+/g, ' ').trim();
96
+ const fallback = event === 'waiting' ? 'is waiting for input' : 'finished';
97
+ // The agent's own text (Codex's reply, a Claude prompt) is in the agent's
98
+ // language — often English — not necessarily the user's. Three modes:
99
+ // speakAgentMessage:false -> never speak it; use the localized template.
100
+ // translateTo set -> translate it into your language, speak that
101
+ // (falling back to the template on failure).
102
+ // default -> speak the raw message as-is.
103
+ // The desktop banner always shows the full original message visually.
104
+ let speakText;
105
+ if (config.speakAgentMessage === false) {
106
+ speakText = fromTemplate || fallback;
107
+ } else if (message) {
108
+ if (config.translateTo) {
109
+ const translated = translate(message, config.translateTo);
110
+ speakText = translated || fromTemplate || fallback;
111
+ } else {
112
+ speakText = message;
113
+ }
114
+ } else {
115
+ speakText = fromTemplate || fallback;
116
+ }
117
+
118
+ // Voice precedence (most specific first):
119
+ // $AI_NOTIFY_VOICE — set per terminal window/pane to give each its own voice
120
+ // provider voice — per agent (Claude vs Codex)
121
+ // global voice — the single `ai-notify voice` switch
122
+ const voice = process.env.AI_NOTIFY_VOICE || p.voice || config.voice;
123
+
124
+ if (!muted) {
125
+ playSound(soundName);
126
+ if (config.speak) speak(speakText, voice);
127
+ }
128
+
129
+ if (!muted || config.bannerWhenMuted) {
130
+ const title = 'AI Notify';
131
+ banner(title, label || provider, message || speakText);
132
+ }
133
+ };