ai-notify 0.4.4 → 0.4.6

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.4.4",
3
+ "version": "0.4.6",
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/notify.mjs CHANGED
@@ -184,11 +184,22 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
184
184
  setPaneWaiting(tty, event === 'waiting'); // waiting -> yellow menu bar status; done clears it
185
185
  const pane = readPaneSetting(tty);
186
186
 
187
- // Name this pane in the read-out. An explicit per-pane name (set from the menu
188
- // bar) is ALWAYS spoken; the auto-derived label (often just the working dir)
189
- // is prefixed only when speakLabel is on it's slow filler otherwise.
190
- const spokenName = pane.speakName || (config.speakLabel === true && label ? label : '');
191
- const speakText = spokenName ? `${spokenName}、${spokenBody}` : spokenBody;
187
+ // Name this pane in the read-out, most-reliable identity first:
188
+ // 1. $AI_NOTIFY_LABEL set in the pane's shell, inherited by the hook even
189
+ // when the agent runs it detached (no tty). Always spoken: setting it is
190
+ // explicit intent. The reliable way to name a pane for Claude Code.
191
+ // 2. pane.speakName set from the menu bar, keyed by tty. Works only when
192
+ // the hook resolves to the pane's tty (see controllingTty's tree walk).
193
+ // 3. the auto-derived label — only when speakLabel is on (else slow filler).
194
+ const envName = (process.env.AI_NOTIFY_LABEL || '').trim();
195
+ const spokenName = envName || pane.speakName || (config.speakLabel === true && label ? label : '');
196
+ // Join the pane name to the read-out as the SUBJECT. Japanese needs the は
197
+ // topic particle ("ジョンは、…") — a bare comma ("ジョン、…") reads as calling
198
+ // out TO John, not saying John is the one finishing / waiting. Other languages
199
+ // just get a comma.
200
+ const isJa = (s) => /[぀-ヿ㐀-鿿ヲ-゚]/.test(s); // kana / kanji / half-width kana
201
+ const joinName = (name, body) => (name ? `${name}${isJa(body) ? 'は、' : ', '}${body}` : body);
202
+ const speakText = joinName(spokenName, spokenBody);
192
203
 
193
204
  // Per-pane voice (precedence: $AI_NOTIFY_* env > this pane's pick > global).
194
205
  const tts = pane.tts || config.tts;
@@ -233,7 +244,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
233
244
  speakTone = tsundere.axisFor(eff);
234
245
  outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
235
246
  outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
236
- if (spokenName) outText = `${spokenName}、${outText}`;
247
+ if (spokenName) outText = joinName(spokenName, outText);
237
248
  if (tts === 'voicevox') {
238
249
  const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
239
250
  const axis = tsundere.axisFor(eff);
package/src/util.mjs CHANGED
@@ -40,18 +40,34 @@ export const isEphemeralInstall = (cliPath) => /[/\\]_npx[/\\]/.test(cliPath);
40
40
 
41
41
  export const MARKER = 'ai-notify'; // substring used to detect our own wiring
42
42
 
43
- // The controlling terminal of this process (e.g. "/dev/ttys010"), which is
44
- // stable per terminal pane — used to scope per-pane settings. null if none.
43
+ // The controlling terminal of the agent's pane (e.g. "/dev/ttys010"), used to
44
+ // scope per-pane settings. Returns null if none can be found.
45
+ //
46
+ // Agents often run the notify hook detached (Claude Code wires it `async`), so
47
+ // the hook process itself frequently has NO controlling tty — but its parent
48
+ // (the agent, e.g. `claude`) still owns the pane's terminal. So we walk up the
49
+ // process tree until we find a real tty, which makes the hook resolve to the
50
+ // SAME tty the menu bar lists the pane under (it scans the agent process).
45
51
  export const controllingTty = () => {
46
- try {
47
- const t = execFileSync('ps', ['-o', 'tty=', '-p', String(process.pid)], {
48
- stdio: ['ignore', 'pipe', 'ignore'],
49
- })
50
- .toString()
51
- .trim();
52
- if (!t || t === '??' || t === '?') return null;
53
- return t.startsWith('/dev/') ? t : `/dev/${t}`;
54
- } catch {
55
- return null;
52
+ let pid = process.pid;
53
+ for (let depth = 0; depth < 8 && pid > 1; depth++) {
54
+ try {
55
+ const line = execFileSync('ps', ['-o', 'tty=', '-o', 'ppid=', '-p', String(pid)], {
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ })
58
+ .toString()
59
+ .trim();
60
+ if (!line) return null;
61
+ // "ttys010 1234" or "?? 1234" (no controlling tty for this pid)
62
+ const sp = line.lastIndexOf(' ');
63
+ const tty = line.slice(0, sp).trim();
64
+ const ppid = parseInt(line.slice(sp + 1).trim(), 10);
65
+ if (tty && tty !== '??' && tty !== '?') return tty.startsWith('/dev/') ? tty : `/dev/${tty}`;
66
+ if (!Number.isFinite(ppid) || ppid <= 1) return null;
67
+ pid = ppid;
68
+ } catch {
69
+ return null;
70
+ }
56
71
  }
72
+ return null;
57
73
  };