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 +22 -0
- package/README.md +22 -0
- package/menubar/AiNotifyMenuBar.swift +57 -5
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +60 -2
- package/src/notify.mjs +13 -7
- package/src/providers/claude.mjs +6 -2
- package/src/state.mjs +18 -0
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
|
-
|
|
178
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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.
|
|
185
|
-
//
|
|
186
|
-
|
|
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();
|
package/src/providers/claude.mjs
CHANGED
|
@@ -44,13 +44,17 @@ const entry = (node, cliPath, event) => ({
|
|
|
44
44
|
],
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
|
|
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 =
|
|
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');
|