ai-notify 0.6.0 → 0.7.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/README.ja.md CHANGED
@@ -87,6 +87,28 @@ AI_NOTIFY_VOLUME=0.5 # この窓の音量(0.0〜2.0)
87
87
  AI_NOTIFY_TSUNDERE_LEVEL=0.8 # この窓のツンデレ既定値(0=デレ〜1=ツン)
88
88
  ```
89
89
 
90
+ ## 🔔 どの種類で通知するか
91
+
92
+ すべての出来事に音とバナーが要るわけではありません。ai-notify は各イベントを(Claude Code の `notification_type`・サブエージェント判定で)**種類**に分類し、どの種類で通知するかを選べます:
93
+
94
+ ```sh
95
+ ai-notify notify # 一覧を表示
96
+ ai-notify notify done off # ターン完了では鳴らさない
97
+ ai-notify notify subagent-done on # サブエージェント完了で鳴らす
98
+ ```
99
+
100
+ | 種類 | いつ | 既定 |
101
+ | ---- | ---- | ---- |
102
+ | `input` | **あなたの入力**待ち(`idle_prompt`) | 🔔 ON |
103
+ | `permission` | **許可**プロンプト | 🔔 ON |
104
+ | `info` | 認証 / MCP elicitation(情報通知) | 🔕 OFF |
105
+ | `done` | ターン**完了**(Stop) | 🔔 ON |
106
+ | `subagent-done` | **サブエージェント**完了(SubagentStop) | 🔕 OFF |
107
+
108
+ OFF の種類は完全に無音(音・バナー・読み上げ・ポップアップなし)。ただし待ち状態は正しく保たれます(無音化した `done` でもポップアップは消えます)。同じ切替はメニューバーの **通知する種類** にもあります。(`subagent-done` は SubagentStop フック配線のため一度 `ai-notify init` が必要)
109
+
110
+ > 注意:Claude は「サブエージェントの実行を待っているだけ」では通知を出しません(`Notification` は**あなたが必要なとき**だけ発火)。つまり「入力待ち」と「サブエージェントで作業中」は別々の通知ではなく、上の種類が実際に区別できる単位です。
111
+
90
112
  ## 🎛️ ネイティブのメニューバー — ミュート・音量・声
91
113
 
92
114
  エージェントが走っているターミナルにはコマンドを打てないので、**メニューバー**から全部操作します:
package/README.md CHANGED
@@ -88,6 +88,28 @@ AI_NOTIFY_TSUNDERE_LEVEL=0.8 # this window's tsundere baseline (0=デレ
88
88
  AI_NOTIFY_VOLUME=0.5 # this window's volume (0.0–2.0)
89
89
  ```
90
90
 
91
+ ## 🔔 Which events alert
92
+
93
+ Not every agent event deserves a sound and a banner. ai-notify classifies each one (using Claude Code's `notification_type` / sub-agent markers) into a **kind**, and you choose which kinds alert:
94
+
95
+ ```sh
96
+ ai-notify notify # show the matrix
97
+ ai-notify notify done off # finished a turn → stay silent
98
+ ai-notify notify subagent-done on # a sub-agent finished → alert
99
+ ```
100
+
101
+ | kind | when | default |
102
+ | ---- | ---- | ------- |
103
+ | `input` | Claude is waiting for **your input** (`idle_prompt`) | 🔔 on |
104
+ | `permission` | a **permission** prompt | 🔔 on |
105
+ | `info` | auth / MCP elicitation (informational) | 🔕 off |
106
+ | `done` | a turn **finished** (Stop) | 🔔 on |
107
+ | `subagent-done` | a **sub-agent** finished (SubagentStop) | 🔕 off |
108
+
109
+ A disabled kind is fully silent — no sound, banner, voice, or popup — but still keeps the waiting state correct (a suppressed `done` still clears a popup). Same toggles live in the menu bar under **通知する種類**. (`subagent-done` needs `ai-notify init` once to wire the SubagentStop hook.)
110
+
111
+ > Note: Claude does **not** emit a notification while merely waiting on a sub-agent to run — `Notification` fires only when *you* are needed. So "waiting for input" and "busy with a sub-agent" aren't separate notifications; the kinds above are what's actually distinguishable.
112
+
91
113
  ## 🎛️ Native menu bar app — mute, volume, and voices
92
114
 
93
115
  You can't type into the terminal that's running an agent, so drive everything from the **menu bar**:
@@ -140,6 +140,16 @@ enum State {
140
140
  }
141
141
  }
142
142
 
143
+ // Per-kind notification toggles (must mirror state.mjs defaults).
144
+ static func notifyKinds() -> [(key: String, label: String, on: Bool)] {
145
+ let defaults: [String: Bool] = ["input": true, "permission": true, "info": false, "done": true, "subagent-done": false]
146
+ let labels = ["input": "入力待ち", "permission": "許可待ち", "info": "その他の通知", "done": "完了", "subagent-done": "サブエージェント完了"]
147
+ let saved = json("notify-kinds.json")
148
+ return ["input", "permission", "info", "done", "subagent-done"].map { k in
149
+ (k, labels[k] ?? k, (saved[k] as? Bool) ?? defaults[k] ?? true)
150
+ }
151
+ }
152
+
143
153
  // Tsundere baseline level 0.0 (デレ) – 1.0 (ツン). Same file the CLI reads.
144
154
  static func setTsundereLevel(_ v: Double) {
145
155
  try? FileManager.default.createDirectory(atPath: dir(), withIntermediateDirectories: true)
@@ -165,6 +175,15 @@ enum State {
165
175
  }
166
176
  }
167
177
 
178
+ // A card view that reliably dismisses on click even when its window isn't the
179
+ // active app (a gesture recognizer on a non-key floating window is unreliable;
180
+ // acceptsFirstMouse + mouseDown is not).
181
+ final class ClickableCardView: NSView {
182
+ var onClick: () -> Void = {}
183
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
184
+ override func mouseDown(with event: NSEvent) { onClick() }
185
+ }
186
+
168
187
  // One floating "応答待ち" card, built once and updated in place.
169
188
  final class PopupCard: NSObject {
170
189
  let window: NSWindow
@@ -174,8 +193,16 @@ final class PopupCard: NSObject {
174
193
  var onClick: () -> Void = {}
175
194
 
176
195
  override init() {
177
- window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
178
- styleMask: [.borderless], backing: .buffered, defer: false)
196
+ // A non-activating floating panel: it receives clicks (the close button
197
+ // and the card's mouseDown both fire) without the background menu-bar app
198
+ // ever becoming active — the reliable way to make a HUD-style window
199
+ // clickable. A plain NSWindow drops the first click while inactive.
200
+ let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
201
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
202
+ panel.isFloatingPanel = true
203
+ panel.becomesKeyOnlyIfNeeded = true
204
+ panel.worksWhenModal = true
205
+ window = panel
179
206
  super.init()
180
207
  window.isOpaque = false
181
208
  window.backgroundColor = .clear
@@ -184,7 +211,8 @@ final class PopupCard: NSObject {
184
211
  window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
185
212
  window.ignoresMouseEvents = false
186
213
 
187
- let card = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
214
+ let card = ClickableCardView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
215
+ card.onClick = { [weak self] in self?.onClick() }
188
216
  card.wantsLayer = true
189
217
  card.layer?.cornerRadius = 16
190
218
  card.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.95).cgColor
@@ -203,7 +231,7 @@ final class PopupCard: NSObject {
203
231
  card.addSubview(face)
204
232
 
205
233
  let badge = NSTextField(labelWithString: "🟡 応答待ち")
206
- badge.frame = NSRect(x: 96, y: 56, width: 196, height: 22)
234
+ badge.frame = NSRect(x: 96, y: 56, width: 150, height: 22)
207
235
  badge.font = .systemFont(ofSize: 13, weight: .bold)
208
236
  badge.textColor = .systemYellow
209
237
  badge.isBordered = false
@@ -218,7 +246,17 @@ final class PopupCard: NSObject {
218
246
  label.backgroundColor = .clear
219
247
  card.addSubview(label)
220
248
 
221
- card.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(clicked)))
249
+ // Visible close button () at the top-right.
250
+ let close = NSButton(frame: NSRect(x: 272, y: 70, width: 20, height: 20))
251
+ close.title = "✕"
252
+ close.font = .systemFont(ofSize: 11, weight: .bold)
253
+ close.isBordered = false
254
+ close.contentTintColor = .secondaryLabelColor
255
+ close.setButtonType(.momentaryChange)
256
+ close.target = self
257
+ close.action = #selector(clicked)
258
+ card.addSubview(close)
259
+
222
260
  window.contentView = card
223
261
  }
224
262
 
@@ -673,6 +711,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
673
711
  popupParent.submenu = popupSub
674
712
  menu.addItem(popupParent)
675
713
 
714
+ // Which kinds of event actually alert (sound / banner / popup).
715
+ let notifyParent = NSMenuItem(title: "通知する種類", action: nil, keyEquivalent: "")
716
+ let notifySub = NSMenu()
717
+ notifySub.addItem(disabledHeader("チェック=音・バナーを出す"))
718
+ for kind in State.notifyKinds() {
719
+ let it = NSMenuItem(title: kind.label, action: #selector(runItem(_:)), keyEquivalent: "")
720
+ it.target = self
721
+ it.representedObject = ["notify", kind.key, "toggle"]
722
+ it.state = kind.on ? .on : .off
723
+ notifySub.addItem(it)
724
+ }
725
+ notifyParent.submenu = notifySub
726
+ menu.addItem(notifyParent)
727
+
676
728
  menu.addItem(.separator())
677
729
  let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
678
730
  quitItem.target = self
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
@@ -41,6 +41,10 @@ import {
41
41
  setPopupDelay,
42
42
  getPopupIgnore,
43
43
  setPopupIgnore,
44
+ NOTIFY_KINDS,
45
+ getNotifyKinds,
46
+ setNotifyKind,
47
+ isNotifyKindEnabled,
44
48
  } from './state.mjs';
45
49
  import { resolve as resolvePath, join as pathJoin } from 'node:path';
46
50
 
@@ -598,6 +602,38 @@ const cmds = {
598
602
  log(`✓ ${bits.join(' · ')}`);
599
603
  },
600
604
 
605
+ // Per-kind notification toggles: which kinds of agent event actually alert
606
+ // (sound / banner / voice / popup). Lets you, e.g., keep input-waiting but
607
+ // silence "done", or turn on sub-agent completions.
608
+ // notify [<kind> on|off|toggle]
609
+ notify() {
610
+ const KIND_LABELS = {
611
+ input: '入力待ち (Claude is waiting for your input)',
612
+ permission: '許可待ち (a permission prompt)',
613
+ info: 'その他 (auth / MCP elicitation — informational)',
614
+ done: '完了 (a turn finished)',
615
+ 'subagent-done': 'サブエージェント完了 (a sub-agent finished)',
616
+ };
617
+ const [kind, action] = positionals;
618
+ if (kind) {
619
+ if (!NOTIFY_KINDS.includes(kind)) {
620
+ console.error(`unknown kind: ${kind}\n kinds: ${NOTIFY_KINDS.join(', ')}`);
621
+ process.exit(1);
622
+ }
623
+ const cur = !!getNotifyKinds()[kind];
624
+ const on = action === 'toggle' ? !cur : action !== 'off';
625
+ setNotifyKind(kind, on);
626
+ if (kind === 'subagent-done' && on) {
627
+ log('subagent-done: ON — run `ai-notify init` once so the SubagentStop hook is wired.');
628
+ }
629
+ return log(`${kind}: ${on ? '🔔 ON (notify)' : '🔕 OFF (silent)'}`);
630
+ }
631
+ const k = getNotifyKinds();
632
+ log('Notify on these events:\n');
633
+ for (const key of NOTIFY_KINDS) log(` ${k[key] ? '🔔' : '🔕'} ${key.padEnd(14)} ${KIND_LABELS[key]}`);
634
+ log('\nToggle: ai-notify notify done off · ai-notify notify subagent-done on');
635
+ },
636
+
601
637
  // The "waiting" character popup (menu bar app): an always-on-top window that
602
638
  // shows a character saying which pane is waiting for input. macOS-only effect.
603
639
  // popup [on|off|toggle|image <path>|delay <sec>|ignore <kw,kw>|status]
@@ -845,6 +881,8 @@ const cmds = {
845
881
  let event = opt('event', 'done');
846
882
  let cwd = '';
847
883
  let message = '';
884
+ let ntype = '';
885
+ let isSubagent = false;
848
886
 
849
887
  if (source === 'codex') {
850
888
  // Codex passes a single JSON argument.
@@ -859,16 +897,24 @@ const cmds = {
859
897
  const data = readStdinJson();
860
898
  cwd = data.cwd || '';
861
899
  message = data.message || '';
900
+ ntype = data.notification_type || ''; // idle_prompt | permission_prompt | ...
901
+ isSubagent = !!data.agent_id; // present => fired from inside a sub-agent
862
902
  // The Stop hook has no message, so "done" would only say "finished".
863
903
  // Pull the agent's last reply from the transcript so the notification
864
904
  // says WHAT was done.
865
- if (!message && event === 'done' && data.transcript_path) {
905
+ if (!message && (event === 'done' || event === 'subagent-done') && data.transcript_path) {
866
906
  message = lastAssistantText(data.transcript_path);
867
907
  }
868
908
  }
869
909
 
910
+ // Classify the event into a kind and honor the per-kind notification toggle.
911
+ // A disabled kind still calls emit (to keep the waiting state correct), but
912
+ // silently. SubagentStop arrives as event "subagent-done" → emit as "done".
913
+ const kind = classifyKind(event, ntype, isSubagent);
914
+ const alert = isNotifyKindEnabled(kind);
870
915
  const label = deriveLabel(cwd);
871
- emit({ provider: byId(source) ? source : 'default', event, label, message });
916
+ const emitEvent = event === 'subagent-done' ? 'done' : event;
917
+ emit({ provider: byId(source) ? source : 'default', event: emitEvent, label, message, alert });
872
918
  },
873
919
 
874
920
  version() { log(VERSION); },
@@ -879,6 +925,17 @@ function emitConfirm() {
879
925
  emit({ provider: 'default', event: 'done', label: 'ai-notify', message: readConfig().onMessage });
880
926
  }
881
927
 
928
+ // Map a raw hook event + Claude's notification_type/agent_id to a notify "kind"
929
+ // the user can toggle. See state.mjs NOTIFY_KINDS.
930
+ function classifyKind(event, ntype, isSubagent) {
931
+ if (event === 'subagent-done' || (event === 'done' && isSubagent)) return 'subagent-done';
932
+ if (event === 'done') return 'done';
933
+ // waiting (Claude Notification): the type tells us *why*.
934
+ if (ntype === 'permission_prompt') return 'permission';
935
+ if (ntype === 'idle_prompt' || ntype === 'elicitation_dialog' || ntype === '') return 'input';
936
+ return 'info'; // auth_success, elicitation_complete/response, anything else
937
+ }
938
+
882
939
  function printHelp() {
883
940
  log(`ai-notify ${VERSION} — notifications for terminal AI coding agents
884
941
 
@@ -893,6 +950,7 @@ Usage:
893
950
  ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
894
951
  ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
895
952
  ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
953
+ ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
896
954
  ai-notify popup [on|off|image <p>|delay <s>|ignore <kw>|portraits] per-pane "waiting" popup, in the pane's voice (macOS)
897
955
  ai-notify translate [on <lang>|off|test] speak agent text in your language
898
956
  ai-notify doctor check deps & wiring
package/src/notify.mjs CHANGED
@@ -145,7 +145,12 @@ const shortenForSpeech = (text, max = 40) => {
145
145
  };
146
146
 
147
147
  // Public entry. Called by the hook handler with already-parsed fields.
148
- export const emit = ({ provider = 'default', event = 'done', label = '', message = '' }) => {
148
+ // `alert` (default true) gates whether this event actually makes noise sound,
149
+ // spoken read-out, banner, highlight, and the waiting popup. When false the call
150
+ // still keeps the pane/waiting state correct (so a suppressed "done" still clears
151
+ // a popup), it just stays silent. The hook decides `alert` from the per-kind
152
+ // notification toggles.
153
+ export const emit = ({ provider = 'default', event = 'done', label = '', message = '', alert = true }) => {
149
154
  const config = readConfig();
150
155
  const muted = isMuted();
151
156
  const p = config.providers[provider] || config.providers.default;
@@ -181,9 +186,10 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
181
186
  // pane's assigned name. Also remember the pane so the menu bar can list it.
182
187
  const tty = controllingTty();
183
188
  recordPane(tty, label);
184
- // waiting -> yellow menu bar status (+ the popup); done clears it. Pass the
185
- // reason text so the popup can filter by it (e.g. ignore sub-agent waits).
186
- setPaneWaiting(tty, event === 'waiting', event === 'waiting' ? message || fromTemplate : '');
189
+ // waiting -> yellow menu bar status (+ the popup); done clears it. A suppressed
190
+ // (alert=false) waiting must NOT light up the popup, so only set it when alert.
191
+ // "done" always clears regardless of alert. Pass the reason text for filtering.
192
+ setPaneWaiting(tty, event === 'waiting' && alert, event === 'waiting' ? message || fromTemplate : '');
187
193
  const pane = readPaneSetting(tty);
188
194
 
189
195
  // Name this pane in the read-out, most-reliable identity first:
@@ -254,7 +260,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
254
260
  }
255
261
  }
256
262
 
257
- if (!muted) {
263
+ if (alert && !muted) {
258
264
  playSound(soundName, outVol);
259
265
  if (config.speak && outVol > 0) {
260
266
  let spoken = false;
@@ -266,7 +272,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
266
272
  }
267
273
  }
268
274
 
269
- if (!muted || config.bannerWhenMuted) {
275
+ if (alert && (!muted || config.bannerWhenMuted)) {
270
276
  const waiting = event === 'waiting';
271
277
  banner(
272
278
  waiting ? `⏳ ${label || 'input'}` : `✓ ${label || 'done'}`,
@@ -283,7 +289,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
283
289
  // Visual highlight of *this* terminal window so a waiting pane stands out
284
290
  // among many. Always best-effort, and applied even when muted (you still want
285
291
  // to see which window needs you during a meeting).
286
- if (config.highlightWaiting) {
292
+ if (alert && config.highlightWaiting) {
287
293
  try {
288
294
  if (event === 'waiting') highlightWaiting(label, config.highlightColor);
289
295
  else if (event === 'done') clearHighlight();
@@ -44,13 +44,17 @@ const entry = (node, cliPath, event) => ({
44
44
  ],
45
45
  });
46
46
 
47
- const EVENTS = { Notification: 'waiting', Stop: 'done' };
47
+ // SubagentStop lets the user (optionally) be alerted when a sub-agent finishes,
48
+ // distinctly from the main turn's Stop. Off by default (see notify kinds), but
49
+ // wired so the toggle works. Only the CORE events count toward "wired" status.
50
+ const EVENTS = { Notification: 'waiting', Stop: 'done', SubagentStop: 'subagent-done' };
51
+ const CORE_EVENTS = ['Notification', 'Stop'];
48
52
 
49
53
  export const status = () => {
50
54
  if (!detect()) return { installed: false, wired: false };
51
55
  const data = load();
52
56
  const hooks = data.hooks || {};
53
- const wired = Object.keys(EVENTS).every((k) =>
57
+ const wired = CORE_EVENTS.every((k) =>
54
58
  (hooks[k] || []).some((g) => (g.hooks || []).some((h) => isOurs(h.command)))
55
59
  );
56
60
  return { installed: true, wired };
package/src/state.mjs CHANGED
@@ -234,6 +234,24 @@ export const setPopupImage = (p) => {
234
234
  else rmSync(popupImagePath(), { force: true });
235
235
  };
236
236
 
237
+ // Per-kind notification toggles — which kinds of agent event actually alert
238
+ // (sound / banner / voice / popup). Lets you, e.g., keep "input waiting" but
239
+ // silence "done", or enable "sub-agent done". Disabled kinds still update the
240
+ // waiting state correctly (so a suppressed "done" still clears a popup).
241
+ export const NOTIFY_KINDS = ['input', 'permission', 'info', 'done', 'subagent-done'];
242
+ const NOTIFY_KIND_DEFAULTS = { input: true, permission: true, info: false, done: true, 'subagent-done': false };
243
+ const notifyKindsPath = () => join(stateDir(), 'notify-kinds.json');
244
+ export const getNotifyKinds = () => ({ ...NOTIFY_KIND_DEFAULTS, ...readJson(notifyKindsPath(), {}) });
245
+ export const isNotifyKindEnabled = (kind) => {
246
+ const k = getNotifyKinds();
247
+ return kind in k ? !!k[kind] : true; // unknown kinds default to alerting
248
+ };
249
+ export const setNotifyKind = (kind, on) => {
250
+ const all = readJson(notifyKindsPath(), {});
251
+ all[kind] = !!on;
252
+ writeJson(notifyKindsPath(), all);
253
+ };
254
+
237
255
  // Popup notify threshold: only show the popup once a pane has been waiting this
238
256
  // many seconds (0 = immediately) — so transient / sub-agent waits don't nag.
239
257
  const popupDelayPath = () => join(stateDir(), 'popup-delay');