ai-notify 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -9,9 +9,10 @@ import { deriveLabel, cliInvocation, isEphemeralInstall } from './util.mjs';
9
9
  import { curatedVoices, resolveVoice, previewVoice } from './voices.mjs';
10
10
  import * as menubar from './menubar.mjs';
11
11
  import { translate } from './translate.mjs';
12
+ import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
12
13
  import { isMuted, setMuted, toggleMuted, readConfig, writeConfig, paths, DEFAULT_CONFIG } from './state.mjs';
13
14
 
14
- const VERSION = '0.1.0';
15
+ const VERSION = '0.1.1';
15
16
 
16
17
  const args = process.argv.slice(2);
17
18
  const cmd = args[0];
@@ -43,6 +44,37 @@ const readStdinJson = () => {
43
44
  }
44
45
  };
45
46
 
47
+ // Pull the agent's last assistant text from a Claude Code transcript (JSONL),
48
+ // trimmed to a short summary suitable for a notification / read-out.
49
+ const lastAssistantText = (transcriptPath) => {
50
+ try {
51
+ const lines = readFileSync(transcriptPath, 'utf8').split('\n');
52
+ for (let i = lines.length - 1; i >= 0; i--) {
53
+ const line = lines[i].trim();
54
+ if (!line) continue;
55
+ let obj;
56
+ try {
57
+ obj = JSON.parse(line);
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (obj.type !== 'assistant') continue;
62
+ const content = obj.message?.content;
63
+ if (!Array.isArray(content)) continue;
64
+ const text = content
65
+ .filter((c) => c?.type === 'text' && c.text)
66
+ .map((c) => c.text)
67
+ .join(' ')
68
+ .replace(/\s+/g, ' ')
69
+ .trim();
70
+ if (text) return text.length > 140 ? `${text.slice(0, 140)}…` : text;
71
+ }
72
+ } catch {
73
+ /* unreadable transcript — fall back to the template */
74
+ }
75
+ return '';
76
+ };
77
+
46
78
  const cmds = {
47
79
  init() {
48
80
  const dryRun = !!opt('dry-run');
@@ -244,6 +276,24 @@ const cmds = {
244
276
  if (!config.translateTo) log('Enable: ai-notify translate on ja');
245
277
  },
246
278
 
279
+ // Diagnose / test the waiting-window highlight. Run it INSIDE the terminal
280
+ // tab you want to test (not piped) so it has a controlling tty.
281
+ highlight() {
282
+ const sub = positionals[0] || 'test';
283
+ if (sub === 'clear') {
284
+ clearHighlight();
285
+ return log('cleared.');
286
+ }
287
+ const color = positionals[1] || readConfig().highlightColor || 'yellow';
288
+ const info = highlightDiagnose(color);
289
+ log(JSON.stringify(info, null, 2));
290
+ log('\nThis tab should now be highlighted. Reset it with: ai-notify highlight clear');
291
+ if (info.appleTerminal && String(info.appleTerminal).startsWith('ERROR')) {
292
+ log('\n→ AppleScript was blocked. Grant permission in:');
293
+ log(' System Settings → Privacy & Security → Automation → (your terminal) → Terminal');
294
+ }
295
+ },
296
+
247
297
  hook() {
248
298
  const source = opt('source', 'default');
249
299
  let event = opt('event', 'done');
@@ -263,6 +313,12 @@ const cmds = {
263
313
  const data = readStdinJson();
264
314
  cwd = data.cwd || '';
265
315
  message = data.message || '';
316
+ // The Stop hook has no message, so "done" would only say "finished".
317
+ // Pull the agent's last reply from the transcript so the notification
318
+ // says WHAT was done.
319
+ if (!message && event === 'done' && data.transcript_path) {
320
+ message = lastAssistantText(data.transcript_path);
321
+ }
266
322
  }
267
323
 
268
324
  const label = deriveLabel(cwd);
@@ -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/notify.mjs CHANGED
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { existsSync } from 'node:fs';
9
9
  import { isMuted, readConfig } from './state.mjs';
10
10
  import { translate } from './translate.mjs';
11
+ import { highlightWaiting, clearHighlight } from './highlight.mjs';
11
12
 
12
13
  const platform = process.platform; // 'darwin' | 'linux' | 'win32'
13
14
 
@@ -70,16 +71,24 @@ const speak = (text, voice) => {
70
71
  }
71
72
  };
72
73
 
73
- const banner = (title, subtitle, message) => {
74
+ const banner = (title, subtitle, message, { activate, urgent } = {}) => {
74
75
  if (platform === 'darwin') {
75
76
  if (which('terminal-notifier')) {
76
- run('terminal-notifier', ['-title', title, '-subtitle', subtitle, '-message', message]);
77
+ const args = ['-title', title, '-subtitle', subtitle, '-message', message];
78
+ if (activate) args.push('-activate', activate); // click the notification -> focus the app
79
+ run('terminal-notifier', args);
77
80
  } else {
78
81
  const esc = (s) => String(s).replace(/"/g, '\\"');
79
- run('osascript', ['-e', `display notification "${esc(message)}" with title "${esc(title)}" subtitle "${esc(subtitle)}"`]);
82
+ run('osascript', [
83
+ '-e',
84
+ `display notification "${esc(message)}" with title "${esc(title)}" subtitle "${esc(subtitle)}"`,
85
+ ]);
80
86
  }
81
87
  } else if (platform === 'linux') {
82
- if (which('notify-send')) run('notify-send', [`${title}: ${subtitle}`, message]);
88
+ if (which('notify-send')) {
89
+ const args = urgent ? ['-u', 'critical'] : [];
90
+ run('notify-send', [...args, `${title}: ${subtitle}`, message]);
91
+ }
83
92
  }
84
93
  // win32: skipped (no dependency-free toast); sound/voice still fire.
85
94
  };
@@ -101,19 +110,19 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
101
110
  // (falling back to the template on failure).
102
111
  // default -> speak the raw message as-is.
103
112
  // The desktop banner always shows the full original message visually.
104
- let speakText;
113
+ // coreBody = WHAT happened, in the user's language, without the label.
114
+ let coreBody;
105
115
  if (config.speakAgentMessage === false) {
106
- speakText = fromTemplate || fallback;
116
+ coreBody = fromTemplate || fallback;
107
117
  } 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
- }
118
+ const translated = config.translateTo ? translate(message, config.translateTo) : message;
119
+ coreBody = translated || fromTemplate || fallback;
114
120
  } else {
115
- speakText = fromTemplate || fallback;
121
+ coreBody = fromTemplate || fallback;
116
122
  }
123
+ // Prefix the window label for the spoken read-out so you can tell which of
124
+ // many terminals is asking (set a short per-window name with $AI_NOTIFY_LABEL).
125
+ const speakText = config.speakLabel !== false && label ? `${label}、${coreBody}` : coreBody;
117
126
 
118
127
  // Voice precedence (most specific first):
119
128
  // $AI_NOTIFY_VOICE — set per terminal window/pane to give each its own voice
@@ -127,7 +136,28 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
127
136
  }
128
137
 
129
138
  if (!muted || config.bannerWhenMuted) {
130
- const title = 'AI Notify';
131
- banner(title, label || provider, message || speakText);
139
+ const waiting = event === 'waiting';
140
+ banner(
141
+ waiting ? `⏳ ${label || 'input'}` : `✓ ${label || 'done'}`,
142
+ waiting ? 'waiting for input' : '',
143
+ coreBody,
144
+ {
145
+ // Click the notification to bring the waiting app (e.g. the IDE) forward.
146
+ activate: config.notifyActivate !== false ? process.env.__CFBundleIdentifier : undefined,
147
+ urgent: waiting,
148
+ }
149
+ );
150
+ }
151
+
152
+ // Visual highlight of *this* terminal window so a waiting pane stands out
153
+ // among many. Always best-effort, and applied even when muted (you still want
154
+ // to see which window needs you during a meeting).
155
+ if (config.highlightWaiting) {
156
+ try {
157
+ if (event === 'waiting') highlightWaiting(label, config.highlightColor);
158
+ else if (event === 'done') clearHighlight();
159
+ } catch {
160
+ /* visual is best-effort */
161
+ }
132
162
  }
133
163
  };
package/src/state.mjs CHANGED
@@ -55,6 +55,15 @@ export const DEFAULT_CONFIG = {
55
55
  bannerWhenMuted: true,
56
56
  // Spoken read-out of which terminal finished (helps tell tabs apart).
57
57
  speak: true,
58
+ // Prefix the window label to the spoken message so you can tell which of many
59
+ // terminals is asking (set a short per-window name with $AI_NOTIFY_LABEL).
60
+ speakLabel: true,
61
+ // Visually highlight the waiting terminal window/pane (best-effort, by tty).
62
+ // Off by default; the color is yellow / orange / red / green / #RRGGBB.
63
+ highlightWaiting: false,
64
+ highlightColor: 'yellow',
65
+ // Make the desktop notification click bring the terminal/IDE forward.
66
+ notifyActivate: true,
58
67
  // Whether to speak the agent's own text (Codex's reply, a Claude prompt).
59
68
  // That text is in the agent's language — set this false to keep every spoken
60
69
  // read-out in your own language via doneMessage / waitingMessage instead.
@@ -70,12 +79,12 @@ export const DEFAULT_CONFIG = {
70
79
  // 'Kyoko'). Empty = OS default voice. Switch it with `ai-notify voice`. A
71
80
  // per-provider `voice` below, if set, overrides this for that agent.
72
81
  voice: '',
73
- // Spoken read-out templates for agent events. `{label}` is the working-dir
74
- // name. Override per language (e.g. Japanese) in config.json. An agent that
75
- // supplies its own message (Codex's last reply, a Claude prompt) wins over
76
- // these.
77
- doneMessage: '{label} finished',
78
- waitingMessage: '{label} is waiting for input',
82
+ // Spoken read-out templates for agent events. The window label is added
83
+ // separately (speakLabel), so leave {label} out here to avoid doubling it.
84
+ // Override per language (e.g. Japanese) in config.json. An agent that supplies
85
+ // its own message (Codex's last reply, a Claude prompt) wins over these.
86
+ doneMessage: 'finished',
87
+ waitingMessage: 'is waiting for input',
79
88
  providers: {
80
89
  claude: { sound: { waiting: 'Glass', done: 'Hero' }, voice: '' },
81
90
  codex: { sound: { done: 'Submarine' }, voice: '' },