ai-notify 0.1.0 → 0.2.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.
package/src/cli.mjs CHANGED
@@ -3,15 +3,31 @@
3
3
  // One mute switch for all of them, across every terminal. No daemon.
4
4
 
5
5
  import { readFileSync } from 'node:fs';
6
+ import { execSync } from 'node:child_process';
6
7
  import { providers, byId } from './providers/index.mjs';
7
8
  import { emit } from './notify.mjs';
8
9
  import { deriveLabel, cliInvocation, isEphemeralInstall } from './util.mjs';
9
10
  import { curatedVoices, resolveVoice, previewVoice } from './voices.mjs';
10
11
  import * as menubar from './menubar.mjs';
11
12
  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';
13
+ import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
14
+ import * as voicevox from './voicevox.mjs';
15
+ import {
16
+ isMuted,
17
+ setMuted,
18
+ toggleMuted,
19
+ readConfig,
20
+ writeConfig,
21
+ paths,
22
+ DEFAULT_CONFIG,
23
+ readVolume,
24
+ setVolume,
25
+ readPanes,
26
+ readPaneSetting,
27
+ updatePaneSetting,
28
+ } from './state.mjs';
29
+
30
+ const VERSION = '0.2.0';
15
31
 
16
32
  const args = process.argv.slice(2);
17
33
  const cmd = args[0];
@@ -43,6 +59,57 @@ const readStdinJson = () => {
43
59
  }
44
60
  };
45
61
 
62
+ // Pull the agent's last assistant text from a Claude Code transcript (JSONL),
63
+ // trimmed to a short summary suitable for a notification / read-out.
64
+ const lastAssistantText = (transcriptPath) => {
65
+ try {
66
+ const lines = readFileSync(transcriptPath, 'utf8').split('\n');
67
+ for (let i = lines.length - 1; i >= 0; i--) {
68
+ const line = lines[i].trim();
69
+ if (!line) continue;
70
+ let obj;
71
+ try {
72
+ obj = JSON.parse(line);
73
+ } catch {
74
+ continue;
75
+ }
76
+ if (obj.type !== 'assistant') continue;
77
+ const content = obj.message?.content;
78
+ if (!Array.isArray(content)) continue;
79
+ const text = content
80
+ .filter((c) => c?.type === 'text' && c.text)
81
+ .map((c) => c.text)
82
+ .join(' ')
83
+ .replace(/\s+/g, ' ')
84
+ .trim();
85
+ if (text) return text.length > 140 ? `${text.slice(0, 140)}…` : text;
86
+ }
87
+ } catch {
88
+ /* unreadable transcript — fall back to the template */
89
+ }
90
+ return '';
91
+ };
92
+
93
+ // Terminals (ttys) currently running a wired agent — so all open panes can be
94
+ // assigned a voice from the menu bar without first firing a notification.
95
+ const livePanes = () => {
96
+ try {
97
+ const out = execSync('ps -Ao tty=,command=', { encoding: 'utf8', maxBuffer: 1 << 22 });
98
+ const ttys = new Set();
99
+ for (const line of out.split('\n')) {
100
+ const m = line.match(/^(\S+)\s+(.*)$/);
101
+ if (!m) continue;
102
+ const [, tty, cmd] = m;
103
+ if (tty === '??' || tty === '?') continue;
104
+ if (/ai-notify|menubar/.test(cmd)) continue; // skip our own hook/agent
105
+ if (/\bclaude\b|\bcodex\b|\bgemini\b/i.test(cmd)) ttys.add(`/dev/${tty}`);
106
+ }
107
+ return [...ttys];
108
+ } catch {
109
+ return [];
110
+ }
111
+ };
112
+
46
113
  const cmds = {
47
114
  init() {
48
115
  const dryRun = !!opt('dry-run');
@@ -130,6 +197,7 @@ const cmds = {
130
197
 
131
198
  const setVoice = (name) => {
132
199
  config.voice = name; // '' = OS default
200
+ config.tts = 'say'; // choosing a system voice switches the backend off VOICEVOX
133
201
  // Global voice wins only if no per-provider override; clear them so the
134
202
  // single switch actually takes effect everywhere.
135
203
  for (const k of Object.keys(config.providers || {})) {
@@ -179,6 +247,152 @@ const cmds = {
179
247
  log(' Reset: ai-notify voice default');
180
248
  },
181
249
 
250
+ // Speak in VOICEVOX character voices (local engine, free, offline).
251
+ voicevox() {
252
+ const sub = positionals[0] || 'status';
253
+ const config = readConfig();
254
+ const url = config.voicevox?.url || voicevox.DEFAULT_URL;
255
+
256
+ if (sub === 'speakers') {
257
+ const list = voicevox.listSpeakers(url);
258
+ if (!list.length) return log(`No speakers (is VOICEVOX running at ${url}?).`);
259
+ list.forEach((s) => log(` ${String(s.id).padStart(3)} ${s.name}`));
260
+ log(`\nUse one: ai-notify voicevox on <id>`);
261
+ return;
262
+ }
263
+ if (sub === 'on') {
264
+ if (!voicevox.isAvailable(url)) {
265
+ console.error(`VOICEVOX engine not reachable at ${url}. Start the VOICEVOX app first.`);
266
+ process.exit(1);
267
+ }
268
+ const speaker = Number(positionals[1] || config.voicevox?.speaker || 3);
269
+ config.tts = 'voicevox';
270
+ config.voicevox = { ...(config.voicevox || {}), url, speaker };
271
+ writeConfig(config);
272
+ log(`✓ VOICEVOX on (speaker ${speaker}). Testing…`);
273
+ voicevox.speak('ボイスボックスで読み上げます。', speaker, url);
274
+ return;
275
+ }
276
+ if (sub === 'off') {
277
+ config.tts = 'say';
278
+ writeConfig(config);
279
+ return log('VOICEVOX off — using the OS voice.');
280
+ }
281
+ if (sub === 'test') {
282
+ const speaker = Number(positionals[1] || config.voicevox?.speaker || 3);
283
+ const ok = voicevox.speak('これはテスト読み上げです。完了しました。', speaker, url);
284
+ return log(ok ? `spoke with speaker ${speaker}` : `⚠ failed (is VOICEVOX running at ${url}?)`);
285
+ }
286
+ // status
287
+ log(`VOICEVOX: ${config.tts === 'voicevox' ? `on (speaker ${config.voicevox?.speaker})` : 'off'}`);
288
+ log(` engine ${url}: ${voicevox.isAvailable(url) ? '✓ reachable' : '✗ not running'}`);
289
+ if (config.tts !== 'voicevox') log('\nEnable: ai-notify voicevox on (list voices: ai-notify voicevox speakers)');
290
+ },
291
+
292
+ // Output volume 0.0–2.0 (1.0 = normal). Written to a state file the menu bar
293
+ // slider also drives; $AI_NOTIFY_VOLUME overrides per window.
294
+ volume() {
295
+ const arg = positionals[0];
296
+ if (arg === undefined) {
297
+ const config = readConfig();
298
+ const v = readVolume();
299
+ return log(`volume: ${v != null ? v : typeof config.volume === 'number' ? config.volume : 1}`);
300
+ }
301
+ const n = setVolume(arg);
302
+ log(`🔊 volume → ${n}`);
303
+ },
304
+
305
+ // Assign a voice to a specific pane (by tty), from the menu bar.
306
+ // voice-pane <tty> voicevox <id> | say <name> | clear
307
+ 'voice-pane'() {
308
+ const [tty, kind, ref] = positionals;
309
+ if (!tty) {
310
+ console.error('usage: voice-pane <tty> voicevox <id> | say <name> | clear');
311
+ process.exit(1);
312
+ }
313
+ if (!kind || kind === 'clear') {
314
+ updatePaneSetting(tty, { tts: null, speaker: null, voice: null });
315
+ return log(`pane ${tty}: voice reset to default`);
316
+ }
317
+ if (kind === 'voicevox') updatePaneSetting(tty, { tts: 'voicevox', speaker: Number(ref), voice: null });
318
+ else if (kind === 'say') updatePaneSetting(tty, { tts: 'say', voice: ref, speaker: null });
319
+ else {
320
+ console.error(`unknown kind: ${kind}`);
321
+ process.exit(1);
322
+ }
323
+ log(`pane ${tty}: ${kind} ${ref}`);
324
+ },
325
+
326
+ // Set a specific pane's output volume (0.0–2.0), or `clear` to follow global.
327
+ // volume-pane <tty> <0.0-2.0|clear>
328
+ 'volume-pane'() {
329
+ const [tty, arg] = positionals;
330
+ if (!tty || arg === undefined) {
331
+ console.error('usage: volume-pane <tty> <0.0-2.0|clear>');
332
+ process.exit(1);
333
+ }
334
+ if (arg === 'clear') {
335
+ updatePaneSetting(tty, { volume: null });
336
+ return log(`pane ${tty}: volume reset to global`);
337
+ }
338
+ const v = Math.min(2, Math.max(0, Number(arg)));
339
+ updatePaneSetting(tty, { volume: v });
340
+ log(`pane ${tty}: volume ${v}`);
341
+ },
342
+
343
+ // Machine-readable state for the menu bar agent: mute, volume, the selectable
344
+ // voices, and the recently-active panes (for per-pane assignment). Not human.
345
+ 'menu-json'() {
346
+ const config = readConfig();
347
+ const url = config.voicevox?.url || voicevox.DEFAULT_URL;
348
+ const chars = voicevox.isAvailable(url) ? voicevox.listCharacters(url) : [];
349
+ const idName = new Map(chars.map((c) => [c.id, c.name]));
350
+ const voices = [];
351
+ for (const c of chars)
352
+ voices.push({
353
+ section: 'VOICEVOX',
354
+ label: c.name,
355
+ kind: 'voicevox',
356
+ ref: String(c.id),
357
+ currentGlobal: config.tts === 'voicevox' && Number(config.voicevox?.speaker) === c.id,
358
+ });
359
+ for (const n of curatedVoices(10))
360
+ voices.push({
361
+ section: 'System',
362
+ label: n,
363
+ kind: 'say',
364
+ ref: n,
365
+ currentGlobal: config.tts !== 'voicevox' && config.voice === n,
366
+ });
367
+ const labelFor = (pv) => {
368
+ if (!pv) return null;
369
+ return pv.tts === 'voicevox' ? idName.get(Number(pv.speaker)) || `VOICEVOX ${pv.speaker}` : pv.voice || 'system';
370
+ };
371
+ // Panes = live terminals currently running an agent (so they show up before
372
+ // they ever fire a notification) merged with previously-recorded ones.
373
+ const globalVol = readVolume() != null ? readVolume() : typeof config.volume === 'number' ? config.volume : 1;
374
+ const recorded = new Map(readPanes().map((p) => [p.tty, p.label]));
375
+ const ttys = new Set([...livePanes(), ...recorded.keys()]);
376
+ const panes = [...ttys].map((tty) => {
377
+ const s = readPaneSetting(tty);
378
+ return {
379
+ tty,
380
+ label: recorded.get(tty) || tty.replace('/dev/', ''),
381
+ current: labelFor(s.tts ? s : null),
382
+ volume: typeof s.volume === 'number' ? s.volume : globalVol,
383
+ volumeSet: typeof s.volume === 'number',
384
+ };
385
+ });
386
+ log(
387
+ JSON.stringify({
388
+ muted: isMuted(),
389
+ volume: readVolume() != null ? readVolume() : typeof config.volume === 'number' ? config.volume : 1,
390
+ voices,
391
+ panes,
392
+ })
393
+ );
394
+ },
395
+
182
396
  // Native menu bar bell (macOS). Self-contained — no Hammerspoon/SwiftBar.
183
397
  menubar() {
184
398
  const sub = positionals[0] || 'status';
@@ -190,7 +404,7 @@ const cmds = {
190
404
  const r = menubar.install();
191
405
  log(` ✓ app: ${r.app}`);
192
406
  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.');
407
+ log('A 🔔 is now in your menu bar. Left-click for the menu (volume, voices), right-click to mute.');
194
408
  return;
195
409
  }
196
410
  if (sub === 'uninstall') {
@@ -244,6 +458,24 @@ const cmds = {
244
458
  if (!config.translateTo) log('Enable: ai-notify translate on ja');
245
459
  },
246
460
 
461
+ // Diagnose / test the waiting-window highlight. Run it INSIDE the terminal
462
+ // tab you want to test (not piped) so it has a controlling tty.
463
+ highlight() {
464
+ const sub = positionals[0] || 'test';
465
+ if (sub === 'clear') {
466
+ clearHighlight();
467
+ return log('cleared.');
468
+ }
469
+ const color = positionals[1] || readConfig().highlightColor || 'yellow';
470
+ const info = highlightDiagnose(color);
471
+ log(JSON.stringify(info, null, 2));
472
+ log('\nThis tab should now be highlighted. Reset it with: ai-notify highlight clear');
473
+ if (info.appleTerminal && String(info.appleTerminal).startsWith('ERROR')) {
474
+ log('\n→ AppleScript was blocked. Grant permission in:');
475
+ log(' System Settings → Privacy & Security → Automation → (your terminal) → Terminal');
476
+ }
477
+ },
478
+
247
479
  hook() {
248
480
  const source = opt('source', 'default');
249
481
  let event = opt('event', 'done');
@@ -263,6 +495,12 @@ const cmds = {
263
495
  const data = readStdinJson();
264
496
  cwd = data.cwd || '';
265
497
  message = data.message || '';
498
+ // The Stop hook has no message, so "done" would only say "finished".
499
+ // Pull the agent's last reply from the transcript so the notification
500
+ // says WHAT was done.
501
+ if (!message && event === 'done' && data.transcript_path) {
502
+ message = lastAssistantText(data.transcript_path);
503
+ }
266
504
  }
267
505
 
268
506
  const label = deriveLabel(cwd);
@@ -284,7 +522,9 @@ Usage:
284
522
  ai-notify init [--dry-run] [--only claude,codex] wire detected agents
285
523
  ai-notify uninstall [--only ...] remove wiring
286
524
  ai-notify toggle | on | off | status control the mute switch
525
+ ai-notify volume [0.0-2.0] get/set output volume
287
526
  ai-notify voice [number|name|preview|default] pick the spoken voice
527
+ ai-notify voicevox [on <id>|off|speakers|test] speak in VOICEVOX character voices
288
528
  ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
289
529
  ai-notify translate [on <lang>|off|test] speak agent text in your language
290
530
  ai-notify doctor check deps & wiring
@@ -0,0 +1,261 @@
1
+ // Visually highlight the terminal window/pane that is waiting for input, so it
2
+ // stands out among many open terminals. Best-effort and terminal-specific:
3
+ //
4
+ // - tmux -> color the pane background (select-pane -P)
5
+ // - Apple Terminal -> set the tab's background color via AppleScript,
6
+ // matched by tty, restoring the original on done
7
+ // - others (iTerm2,…) -> OSC 11 default-background + a tab-title marker
8
+ //
9
+ // Everything is wrapped so a failure never affects the notification. The tab
10
+ // title marker is the most portable signal and is always emitted.
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { stateDir } from './state.mjs';
16
+
17
+ const isMac = process.platform === 'darwin';
18
+ const BEL = '\x07';
19
+
20
+ // Controlling terminal of this process (works even when stdio is piped).
21
+ const ttyName = () => {
22
+ try {
23
+ const t = execFileSync('ps', ['-o', 'tty=', '-p', String(process.pid)], { encoding: 'utf8' }).trim();
24
+ if (!t || t === '??' || t === '?') return null;
25
+ return t.startsWith('/dev/') ? t : `/dev/${t}`;
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+
31
+ const writeTty = (seq) => {
32
+ const tty = ttyName() || '/dev/tty';
33
+ try {
34
+ writeFileSync(tty, seq);
35
+ } catch {
36
+ /* no controlling terminal — ignore */
37
+ }
38
+ };
39
+
40
+ // Default-background (OSC 11) + icon/tab title (OSC 1/2). Reset uses OSC 111.
41
+ const oscSet = (hex, title) => `\x1b]11;${hex}${BEL}\x1b]1;${title}${BEL}\x1b]2;${title}${BEL}`;
42
+ const oscReset = `\x1b]111${BEL}\x1b]1;${BEL}\x1b]2;${BEL}`;
43
+
44
+ // SGR fallback that works even where OSC is ignored (e.g. JetBrains JediTerm):
45
+ // print a bold black-on-yellow bar straight into the pane, plus a BEL so the
46
+ // IDE flags the tab as having activity. Standard ANSI — renders everywhere.
47
+ const sgrBg = { yellow: 103, orange: '48;5;208', red: 101, green: 102 };
48
+ const sgrBar = (label, color) => {
49
+ const bg = sgrBg[color] || sgrBg.yellow;
50
+ return `\r\n\x1b[1;30;${bg}m ⏳ ${label || 'input'} \x1b[0m${BEL}\r\n`;
51
+ };
52
+
53
+ const colorHex = (c) => {
54
+ const map = { yellow: '#FFD400', orange: '#FF9500', red: '#FF3B30', green: '#34C759' };
55
+ if (!c) return map.yellow;
56
+ return map[c] || (c.startsWith('#') ? c : map.yellow);
57
+ };
58
+
59
+ // --- tmux ---
60
+ const tmuxPane = () => (process.env.TMUX && process.env.TMUX_PANE ? process.env.TMUX_PANE : null);
61
+ const tmuxSet = (c) => {
62
+ const pane = tmuxPane();
63
+ if (!pane) return;
64
+ const color = c === 'yellow' || !c ? 'colour220' : c;
65
+ try {
66
+ execFileSync('tmux', ['select-pane', '-t', pane, '-P', `bg=${color}`]);
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ };
71
+ const tmuxReset = () => {
72
+ const pane = tmuxPane();
73
+ if (!pane) return;
74
+ try {
75
+ execFileSync('tmux', ['select-pane', '-t', pane, '-P', 'bg=default']);
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ };
80
+
81
+ // --- Apple Terminal (set the tab bg by tty; store original to restore) ---
82
+ // Some setups don't export TERM_PROGRAM, so detect Apple Terminal robustly:
83
+ // explicit signals first, then — when the terminal is unknown — attempt anyway.
84
+ // The AppleScript matches by tty, so it's a harmless no-op if this isn't really
85
+ // a Terminal.app tab. Known non-Terminal programs (iTerm.app, vscode, …) opt out
86
+ // to avoid needless automation prompts.
87
+ const isAppleTerminal = () => {
88
+ if (!isMac) return false;
89
+ const tp = process.env.TERM_PROGRAM;
90
+ const bundle = process.env.__CFBundleIdentifier || '';
91
+ if (tp === 'Apple_Terminal' || bundle === 'com.apple.Terminal') return true;
92
+ if (tp || bundle) return false; // a known other terminal/IDE (iTerm, WebStorm…)
93
+ return true; // truly unknown -> attempt; the tty match guards it
94
+ };
95
+
96
+ // JetBrains IDE terminals (WebStorm, IntelliJ, …) — JediTerm/Gen2.
97
+ const isJetBrains = () =>
98
+ process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' ||
99
+ /jetbrains/i.test(process.env.__CFBundleIdentifier || '');
100
+ const savePath = (tty) => join(stateDir(), `hl-${tty.replace(/[^\w]+/g, '_')}`);
101
+
102
+ const appleSet = (rgb16) => {
103
+ const tty = ttyName();
104
+ if (!tty) return;
105
+ const script = `tell application "Terminal"
106
+ repeat with w in windows
107
+ repeat with t in tabs of w
108
+ try
109
+ if (tty of t) is "${tty}" then
110
+ set c to background color of t
111
+ set background color of t to {${rgb16}}
112
+ return ((item 1 of c) & "," & (item 2 of c) & "," & (item 3 of c)) as string
113
+ end if
114
+ end try
115
+ end repeat
116
+ end repeat
117
+ return ""
118
+ end tell`;
119
+ try {
120
+ const orig = execFileSync('osascript', ['-e', script], { encoding: 'utf8', timeout: 3000 }).trim();
121
+ if (orig) {
122
+ mkdirSync(stateDir(), { recursive: true });
123
+ writeFileSync(savePath(tty), orig);
124
+ }
125
+ } catch {
126
+ /* automation permission not granted / ignore */
127
+ }
128
+ };
129
+
130
+ const appleReset = () => {
131
+ const tty = ttyName();
132
+ if (!tty) return;
133
+ const p = savePath(tty);
134
+ // Only restore if WE highlighted this tab (a saved original exists). Without
135
+ // this guard a normal 'done' with no prior 'waiting' would blacken the tab.
136
+ if (!existsSync(p)) return;
137
+ let rgb16 = '0, 0, 0';
138
+ try {
139
+ rgb16 = readFileSync(p, 'utf8').trim() || rgb16;
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+ const script = `tell application "Terminal"
144
+ repeat with w in windows
145
+ repeat with t in tabs of w
146
+ try
147
+ if (tty of t) is "${tty}" then set background color of t to {${rgb16}}
148
+ end try
149
+ end repeat
150
+ end repeat
151
+ end tell`;
152
+ try {
153
+ execFileSync('osascript', ['-e', script], { timeout: 3000, stdio: 'ignore' });
154
+ } catch {
155
+ /* ignore */
156
+ }
157
+ try {
158
+ if (existsSync(p)) rmSync(p);
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ };
163
+
164
+ // 16-bit RGB for AppleScript yellow-ish; reuse hex→approx for custom.
165
+ const rgb16From = (c) => {
166
+ if (c === 'orange') return '65535, 38000, 0';
167
+ if (c === 'red') return '65535, 15000, 12000';
168
+ if (c === 'green') return '13000, 51000, 22000';
169
+ return '65535, 54000, 0'; // yellow
170
+ };
171
+
172
+ // A per-tty marker so `clear` only touches the terminal when WE highlighted it
173
+ // (a 'done' with no prior 'waiting' must leave the window untouched).
174
+ const markPath = (tty) => join(stateDir(), `hl-on-${tty.replace(/[^\w]+/g, '_')}`);
175
+
176
+ export const highlightWaiting = (label, color = 'yellow') => {
177
+ writeTty(oscSet(colorHex(color), `⏳ ${label || 'input'}`));
178
+ if (isJetBrains()) writeTty(sgrBar(label, color)); // OSC ignored here; SGR works
179
+ if (isAppleTerminal()) appleSet(rgb16From(color));
180
+ tmuxSet(color);
181
+ const tty = ttyName();
182
+ if (tty) {
183
+ try {
184
+ mkdirSync(stateDir(), { recursive: true });
185
+ writeFileSync(markPath(tty), '');
186
+ } catch {
187
+ /* ignore */
188
+ }
189
+ }
190
+ };
191
+
192
+ // Foreground diagnostic: run the highlight and surface what happened (tty,
193
+ // terminal, AppleScript error/permission) instead of swallowing it.
194
+ export const diagnose = (color = 'yellow') => {
195
+ const info = {
196
+ platform: process.platform,
197
+ TERM_PROGRAM: process.env.TERM_PROGRAM || null,
198
+ __CFBundleIdentifier: process.env.__CFBundleIdentifier || null,
199
+ isAppleTerminal: isAppleTerminal(),
200
+ TMUX: process.env.TMUX ? process.env.TMUX_PANE || true : false,
201
+ tty: ttyName(),
202
+ };
203
+ try {
204
+ writeTty(oscSet(colorHex(color), '⏳ test'));
205
+ info.osc = `wrote to ${ttyName() || '/dev/tty'}`;
206
+ } catch (e) {
207
+ info.osc = `error: ${e.message}`;
208
+ }
209
+ info.jetBrains = isJetBrains();
210
+ if (isJetBrains()) {
211
+ writeTty(sgrBar('test', color)); // should print a yellow bar right here
212
+ info.sgrBar = 'printed (look for the yellow bar above)';
213
+ }
214
+ if (isAppleTerminal()) {
215
+ const tty = ttyName();
216
+ if (!tty) {
217
+ info.appleTerminal = 'no controlling tty';
218
+ } else {
219
+ const script = `tell application "Terminal"
220
+ repeat with w in windows
221
+ repeat with t in tabs of w
222
+ try
223
+ if (tty of t) is "${tty}" then
224
+ set c to background color of t
225
+ set background color of t to {${rgb16From(color)}}
226
+ return "matched tty, original=" & (c as string)
227
+ end if
228
+ end try
229
+ end repeat
230
+ end repeat
231
+ return "no tab matched tty ${tty}"
232
+ end tell`;
233
+ try {
234
+ info.appleTerminal = execFileSync('osascript', ['-e', script], {
235
+ encoding: 'utf8',
236
+ timeout: 5000,
237
+ }).trim();
238
+ } catch (e) {
239
+ info.appleTerminal = `ERROR: ${(e.stderr || e.message || '').toString().trim()}`;
240
+ }
241
+ }
242
+ } else {
243
+ info.appleTerminal = 'skipped (detected a non-Terminal program)';
244
+ }
245
+ return info;
246
+ };
247
+
248
+ export const clearHighlight = () => {
249
+ const tty = ttyName();
250
+ if (tty && !existsSync(markPath(tty))) return; // we never highlighted this one
251
+ writeTty(oscReset);
252
+ if (isAppleTerminal()) appleReset();
253
+ tmuxReset();
254
+ if (tty) {
255
+ try {
256
+ if (existsSync(markPath(tty))) rmSync(markPath(tty));
257
+ } catch {
258
+ /* ignore */
259
+ }
260
+ }
261
+ };
package/src/menubar.mjs CHANGED
@@ -10,8 +10,10 @@
10
10
  import { homedir, platform } from 'node:os';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
- import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
14
14
  import { execFileSync, spawnSync } from 'node:child_process';
15
+ import { cliInvocation } from './util.mjs';
16
+ import { stateDir } from './state.mjs';
15
17
 
16
18
  export const LABEL = 'com.ai-notify.menubar';
17
19
 
@@ -81,9 +83,21 @@ const unload = () => {
81
83
  return r;
82
84
  };
83
85
 
86
+ // A tiny launcher the menu bar app shells out to for data/actions, so it works
87
+ // regardless of PATH (embeds the resolved node + cli path).
88
+ const writeCliWrapper = () => {
89
+ const { node, cliPath } = cliInvocation();
90
+ const p = join(stateDir(), 'cli');
91
+ mkdirSync(stateDir(), { recursive: true });
92
+ writeFileSync(p, `#!/bin/sh\nexec "${node}" "${cliPath}" "$@"\n`);
93
+ chmodSync(p, 0o755);
94
+ return p;
95
+ };
96
+
84
97
  export const install = () => {
85
98
  if (!isMac()) throw new Error('the menu bar agent is macOS-only');
86
99
  if (!isBuilt()) build();
100
+ writeCliWrapper();
87
101
  writePlist();
88
102
  load();
89
103
  return { app: appPath(), plist: plistPath() };