ai-notify 0.8.0 → 0.9.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/README.ja.md
CHANGED
|
@@ -121,6 +121,7 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
|
|
|
121
121
|
|
|
122
122
|
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定。開いている各ターミナルに、**読み上げ名**(どのペインが終わったか声で分かる)・**声**・**音量**を個別設定でき、各行にそのペインの声が一覧表示されます。
|
|
123
123
|
- **右クリック** → 即ミュート切替。
|
|
124
|
+
- **⚙ 設定…** → **整列したスライダー+編集可能な数値フィールド**(音量・ツンデレ・戦争・速さ/高さ/抑揚)と**プリセット保存**(`ai-notify preset save <名前>` / `load` / `delete`)の設定ウィンドウ。毎回調整し直さなくて済みます。
|
|
124
125
|
|
|
125
126
|
<p>
|
|
126
127
|
<img alt="ai-notify メニュー — 音量・ツンデレ・声" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
package/README.md
CHANGED
|
@@ -122,6 +122,7 @@ A monochrome waveform icon shows status by color (Adobe-style): plain when idle,
|
|
|
122
122
|
|
|
123
123
|
- **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls. Each open terminal gets its own **spoken name** (read out so you know *which* pane finished), **voice**, and **volume** — the row shows each pane's voice at a glance.
|
|
124
124
|
- **Right-click** → instant mute toggle.
|
|
125
|
+
- **⚙ 設定…** → a settings window with **aligned sliders + editable numeric fields** (volume, tsundere, war, speed/pitch/intonation) and **saveable presets** (`ai-notify preset save <name>` / `load` / `delete`), so you don't re-tune every time.
|
|
125
126
|
|
|
126
127
|
<p>
|
|
127
128
|
<img alt="ai-notify menu — volume, tsundere, and voice controls" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
@@ -275,9 +275,200 @@ final class PopupCard: NSObject {
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// One settings row: a label (or checkbox) + a slider + an editable numeric field,
|
|
279
|
+
// kept on a single shared grid so every row lines up. The slider and the field
|
|
280
|
+
// stay in sync; both call `onChange`.
|
|
281
|
+
final class SettingsRow: NSObject {
|
|
282
|
+
let view = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 32))
|
|
283
|
+
private let slider: NSSlider
|
|
284
|
+
private let field = NSTextField()
|
|
285
|
+
private let lo: Double
|
|
286
|
+
private let hi: Double
|
|
287
|
+
private let onChange: (Double) -> Void
|
|
288
|
+
private let onToggle: (() -> Void)?
|
|
289
|
+
|
|
290
|
+
init(title: String, asCheckbox: Bool, on: Bool, lo: Double, hi: Double, value: Double,
|
|
291
|
+
fill: NSColor, onToggle: (() -> Void)? = nil, onChange: @escaping (Double) -> Void) {
|
|
292
|
+
self.lo = lo; self.hi = hi; self.onChange = onChange; self.onToggle = onToggle
|
|
293
|
+
slider = NSSlider(value: value, minValue: lo, maxValue: hi, target: nil, action: nil)
|
|
294
|
+
super.init()
|
|
295
|
+
|
|
296
|
+
if asCheckbox {
|
|
297
|
+
let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(toggled))
|
|
298
|
+
cb.frame = NSRect(x: 16, y: 6, width: 106, height: 20)
|
|
299
|
+
cb.state = on ? .on : .off
|
|
300
|
+
view.addSubview(cb)
|
|
301
|
+
} else {
|
|
302
|
+
let lbl = NSTextField(labelWithString: title)
|
|
303
|
+
lbl.frame = NSRect(x: 16, y: 7, width: 106, height: 18)
|
|
304
|
+
lbl.textColor = .labelColor
|
|
305
|
+
view.addSubview(lbl)
|
|
306
|
+
}
|
|
307
|
+
// Unified grid: slider always at the same x/width, field always after it.
|
|
308
|
+
slider.frame = NSRect(x: 128, y: 5, width: 250, height: 20)
|
|
309
|
+
slider.target = self; slider.action = #selector(sliderMoved)
|
|
310
|
+
slider.isContinuous = true
|
|
311
|
+
slider.trackFillColor = fill
|
|
312
|
+
view.addSubview(slider)
|
|
313
|
+
|
|
314
|
+
field.frame = NSRect(x: 392, y: 5, width: 56, height: 20)
|
|
315
|
+
field.alignment = .right
|
|
316
|
+
field.target = self; field.action = #selector(fieldEdited)
|
|
317
|
+
view.addSubview(field)
|
|
318
|
+
setField(value)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private func setField(_ v: Double) { field.stringValue = String(format: "%.2f", v) }
|
|
322
|
+
@objc private func toggled() { onToggle?() }
|
|
323
|
+
@objc private func sliderMoved() { setField(slider.doubleValue); onChange(slider.doubleValue) }
|
|
324
|
+
@objc private func fieldEdited() {
|
|
325
|
+
var v = Double(field.stringValue) ?? slider.doubleValue
|
|
326
|
+
v = min(hi, max(lo, v))
|
|
327
|
+
slider.doubleValue = v; setField(v); onChange(v)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The settings window: aligned sliders + editable numeric fields for volume,
|
|
332
|
+
// tsundere, war, and the VOICEVOX prosody, plus a saveable preset bar.
|
|
333
|
+
final class SettingsWindowController: NSObject {
|
|
334
|
+
private var window: NSWindow?
|
|
335
|
+
private var presetPopup: NSPopUpButton?
|
|
336
|
+
var windowNumber: Int { window?.windowNumber ?? 0 }
|
|
337
|
+
|
|
338
|
+
func show() {
|
|
339
|
+
if window == nil { build() }
|
|
340
|
+
reloadValues()
|
|
341
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
342
|
+
window?.center()
|
|
343
|
+
window?.makeKeyAndOrderFront(nil)
|
|
344
|
+
window?.orderFrontRegardless()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private func menuJSON() -> [String: Any] {
|
|
348
|
+
(State.cli(["menu-json"], capture: true)?.data(using: .utf8))
|
|
349
|
+
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private func build() {
|
|
353
|
+
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 470, height: 360),
|
|
354
|
+
styleMask: [.titled, .closable], backing: .buffered, defer: false)
|
|
355
|
+
w.title = "ai-notify 設定"
|
|
356
|
+
w.isReleasedWhenClosed = false
|
|
357
|
+
window = w
|
|
358
|
+
rebuildContent()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Re-create the rows from the current state (also used after loading a preset).
|
|
362
|
+
private func rebuildContent() {
|
|
363
|
+
guard let w = window else { return }
|
|
364
|
+
let j = menuJSON()
|
|
365
|
+
let content = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 360))
|
|
366
|
+
|
|
367
|
+
// Preset bar.
|
|
368
|
+
let presetLabel = NSTextField(labelWithString: "プリセット")
|
|
369
|
+
presetLabel.frame = NSRect(x: 16, y: 322, width: 70, height: 18)
|
|
370
|
+
content.addSubview(presetLabel)
|
|
371
|
+
let popup = NSPopUpButton(frame: NSRect(x: 92, y: 318, width: 180, height: 26))
|
|
372
|
+
for name in presetNames() { popup.addItem(withTitle: name) }
|
|
373
|
+
if popup.numberOfItems == 0 { popup.addItem(withTitle: "(なし)"); popup.isEnabled = false }
|
|
374
|
+
content.addSubview(popup)
|
|
375
|
+
presetPopup = popup
|
|
376
|
+
let apply = NSButton(title: "適用", target: self, action: #selector(applyPreset)); apply.frame = NSRect(x: 278, y: 318, width: 52, height: 26); apply.bezelStyle = .rounded; content.addSubview(apply)
|
|
377
|
+
let save = NSButton(title: "保存…", target: self, action: #selector(savePreset)); save.frame = NSRect(x: 332, y: 318, width: 60, height: 26); save.bezelStyle = .rounded; content.addSubview(save)
|
|
378
|
+
let del = NSButton(title: "削除", target: self, action: #selector(deletePreset)); del.frame = NSRect(x: 394, y: 318, width: 52, height: 26); del.bezelStyle = .rounded; content.addSubview(del)
|
|
379
|
+
|
|
380
|
+
let sep = NSBox(frame: NSRect(x: 12, y: 306, width: 446, height: 1)); sep.boxType = .separator; content.addSubview(sep)
|
|
381
|
+
|
|
382
|
+
// Parameter rows (top-down).
|
|
383
|
+
let blue = NSColor(srgbRed: 0, green: 122.0 / 255.0, blue: 1, alpha: 1)
|
|
384
|
+
let pink = NSColor.systemPink
|
|
385
|
+
let red = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1)
|
|
386
|
+
let tsun = j["tsundere"] as? [String: Any]
|
|
387
|
+
let warj = j["war"] as? [String: Any]
|
|
388
|
+
let pr = j["prosody"] as? [String: Any] ?? [:]
|
|
389
|
+
let range = j["prosodyRange"] as? [String: Any] ?? [:]
|
|
390
|
+
func bound(_ key: String, _ dlo: Double, _ dhi: Double) -> (Double, Double) {
|
|
391
|
+
let r = range[key] as? [Any]
|
|
392
|
+
return ((r?.first as? Double) ?? dlo, (r?.last as? Double) ?? dhi)
|
|
393
|
+
}
|
|
394
|
+
let (slo, shi) = bound("speed", 0.5, 1.5)
|
|
395
|
+
let (plo, phi) = bound("pitch", -0.15, 0.15)
|
|
396
|
+
let (ilo, ihi) = bound("intonation", 0.0, 1.5)
|
|
397
|
+
|
|
398
|
+
let rows: [SettingsRow] = [
|
|
399
|
+
SettingsRow(title: "音量", asCheckbox: false, on: false, lo: 0, hi: 2, value: (j["volume"] as? Double) ?? 1, fill: blue,
|
|
400
|
+
onChange: { State.cli(["volume", String(format: "%.2f", $0)]) }),
|
|
401
|
+
SettingsRow(title: "ツンデレ", asCheckbox: true, on: (tsun?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (tsun?["level"] as? Double) ?? 0.5, fill: pink,
|
|
402
|
+
onToggle: { State.cli(["tsundere", "toggle"]) },
|
|
403
|
+
onChange: { State.cli(["tsundere", "level", String(format: "%.2f", $0)]) }),
|
|
404
|
+
SettingsRow(title: "戦争", asCheckbox: true, on: (warj?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (warj?["level"] as? Double) ?? 0.5, fill: red,
|
|
405
|
+
onToggle: { State.cli(["war", "toggle"]) },
|
|
406
|
+
onChange: { State.cli(["war", "level", String(format: "%.2f", $0)]) }),
|
|
407
|
+
SettingsRow(title: "速さ", asCheckbox: false, on: false, lo: slo, hi: shi, value: (pr["speed"] as? Double) ?? 1, fill: blue,
|
|
408
|
+
onChange: { State.cli(["voice-prosody", "speed", String(format: "%.3f", $0)]) }),
|
|
409
|
+
SettingsRow(title: "高さ", asCheckbox: false, on: false, lo: plo, hi: phi, value: (pr["pitch"] as? Double) ?? 0, fill: blue,
|
|
410
|
+
onChange: { State.cli(["voice-prosody", "pitch", String(format: "%.3f", $0)]) }),
|
|
411
|
+
SettingsRow(title: "抑揚", asCheckbox: false, on: false, lo: ilo, hi: ihi, value: (pr["intonation"] as? Double) ?? 1, fill: blue,
|
|
412
|
+
onChange: { State.cli(["voice-prosody", "intonation", String(format: "%.3f", $0)]) }),
|
|
413
|
+
]
|
|
414
|
+
var y = 264
|
|
415
|
+
let header = NSTextField(labelWithString: "ツンデレ/戦争は 0=デレ・平時 〜 1=ツン・危機")
|
|
416
|
+
header.frame = NSRect(x: 16, y: 286, width: 440, height: 16)
|
|
417
|
+
header.font = .systemFont(ofSize: 11); header.textColor = .secondaryLabelColor
|
|
418
|
+
content.addSubview(header)
|
|
419
|
+
for r in rows {
|
|
420
|
+
r.view.frame.origin = NSPoint(x: 0, y: CGFloat(y))
|
|
421
|
+
content.addSubview(r.view)
|
|
422
|
+
self.rows.append(r) // retain
|
|
423
|
+
y -= 36
|
|
424
|
+
}
|
|
425
|
+
w.contentView = content
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private var rows: [SettingsRow] = []
|
|
429
|
+
|
|
430
|
+
private func presetNames() -> [String] {
|
|
431
|
+
let out = State.cli(["preset", "list"], capture: true) ?? ""
|
|
432
|
+
return out.split(separator: "\n").map { String($0).trimmingCharacters(in: .whitespaces) }
|
|
433
|
+
.filter { !$0.isEmpty && $0 != "(no presets)" }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func reloadValues() {
|
|
437
|
+
rows.removeAll()
|
|
438
|
+
rebuildContent()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
@objc private func applyPreset() {
|
|
442
|
+
guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
|
|
443
|
+
State.cli(["preset", "load", name])
|
|
444
|
+
reloadValues()
|
|
445
|
+
}
|
|
446
|
+
@objc private func deletePreset() {
|
|
447
|
+
guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
|
|
448
|
+
State.cli(["preset", "delete", name])
|
|
449
|
+
reloadValues()
|
|
450
|
+
}
|
|
451
|
+
@objc private func savePreset() {
|
|
452
|
+
let alert = NSAlert()
|
|
453
|
+
alert.messageText = "プリセットを保存"
|
|
454
|
+
alert.informativeText = "現在の音量・ツンデレ・戦争・読み上げ設定を名前を付けて保存します"
|
|
455
|
+
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
|
|
456
|
+
field.placeholderString = "例: 集中モード"
|
|
457
|
+
alert.accessoryView = field
|
|
458
|
+
alert.addButton(withTitle: "保存"); alert.addButton(withTitle: "キャンセル")
|
|
459
|
+
alert.window.initialFirstResponder = field
|
|
460
|
+
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
461
|
+
let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
462
|
+
guard !name.isEmpty else { return }
|
|
463
|
+
State.cli(["preset", "save", name])
|
|
464
|
+
reloadValues()
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
278
468
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
279
469
|
private var statusItem: NSStatusItem!
|
|
280
470
|
private var timer: Timer?
|
|
471
|
+
private let settings = SettingsWindowController()
|
|
281
472
|
|
|
282
473
|
// The "waiting for input" popup — one floating card per waiting pane.
|
|
283
474
|
private var waitingCards: [String: PopupCard] = [:] // keyed by tty
|
|
@@ -424,6 +615,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
424
615
|
|
|
425
616
|
private func toggle() { State.setMuted(!State.isMuted); render() }
|
|
426
617
|
@objc private func quit() { NSApp.terminate(nil) }
|
|
618
|
+
@objc private func openSettings() { settings.show() }
|
|
427
619
|
|
|
428
620
|
@objc private func volumeChanged(_ s: NSSlider) { State.setVolume(s.doubleValue) }
|
|
429
621
|
// Slider is shown reversed (left = ツン, right = デレ) but the file keeps the
|
|
@@ -762,6 +954,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
762
954
|
notifyParent.submenu = notifySub
|
|
763
955
|
menu.addItem(notifyParent)
|
|
764
956
|
|
|
957
|
+
menu.addItem(.separator())
|
|
958
|
+
let settingsItem = NSMenuItem(title: "⚙ 設定(スライダー・数値・プリセット)…", action: #selector(openSettings), keyEquivalent: ",")
|
|
959
|
+
settingsItem.target = self
|
|
960
|
+
menu.addItem(settingsItem)
|
|
961
|
+
|
|
765
962
|
menu.addItem(.separator())
|
|
766
963
|
let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
|
|
767
964
|
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.9.0",
|
|
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
|
@@ -451,6 +451,74 @@ const cmds = {
|
|
|
451
451
|
if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
|
|
452
452
|
},
|
|
453
453
|
|
|
454
|
+
// Named presets: snapshot the current volume / prosody / tsundere / war and
|
|
455
|
+
// restore them later, so you don't re-tune every time.
|
|
456
|
+
// preset [list | save <name> | load <name> | delete <name>]
|
|
457
|
+
preset() {
|
|
458
|
+
const sub = positionals[0] || 'list';
|
|
459
|
+
const file = pathJoin(paths.stateDir(), 'presets.json');
|
|
460
|
+
const read = () => {
|
|
461
|
+
try {
|
|
462
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
463
|
+
} catch {
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const write = (o) => {
|
|
468
|
+
mkdirSync(paths.stateDir(), { recursive: true });
|
|
469
|
+
writeFileSync(file, JSON.stringify(o, null, 2));
|
|
470
|
+
};
|
|
471
|
+
const name = positionals.slice(1).join(' ').trim();
|
|
472
|
+
|
|
473
|
+
if (sub === 'list') {
|
|
474
|
+
const names = Object.keys(read());
|
|
475
|
+
return log(names.length ? names.join('\n') : '(no presets)');
|
|
476
|
+
}
|
|
477
|
+
if (sub === 'save') {
|
|
478
|
+
if (!name) return void (console.error('usage: preset save <name>'), process.exit(1));
|
|
479
|
+
const config = readConfig();
|
|
480
|
+
const snap = {
|
|
481
|
+
volume: readVolume() != null ? readVolume() : config.volume ?? 1,
|
|
482
|
+
prosody: readVoiceProsody(),
|
|
483
|
+
tsundere: {
|
|
484
|
+
enabled: !!config.tsundere?.enabled,
|
|
485
|
+
level: readTsundereLevel() != null ? readTsundereLevel() : config.tsundere?.level ?? 0.5,
|
|
486
|
+
},
|
|
487
|
+
war: { enabled: isWarEnabled(), level: readWarLevel() },
|
|
488
|
+
};
|
|
489
|
+
const p = read();
|
|
490
|
+
p[name] = snap;
|
|
491
|
+
write(p);
|
|
492
|
+
return log(`💾 saved preset "${name}"`);
|
|
493
|
+
}
|
|
494
|
+
if (sub === 'delete' || sub === 'rm') {
|
|
495
|
+
const p = read();
|
|
496
|
+
if (!(name in p)) return void (console.error(`no preset: ${name}`), process.exit(1));
|
|
497
|
+
delete p[name];
|
|
498
|
+
write(p);
|
|
499
|
+
return log(`deleted "${name}"`);
|
|
500
|
+
}
|
|
501
|
+
if (sub === 'load' || sub === 'apply') {
|
|
502
|
+
const s = read()[name];
|
|
503
|
+
if (!s) return void (console.error(`no preset: ${name}`), process.exit(1));
|
|
504
|
+
if (typeof s.volume === 'number') setVolume(s.volume);
|
|
505
|
+
if (s.prosody) for (const k of ['speed', 'pitch', 'intonation']) if (typeof s.prosody[k] === 'number') setVoiceProsody(k, s.prosody[k]);
|
|
506
|
+
if (s.tsundere) {
|
|
507
|
+
if (typeof s.tsundere.level === 'number') setTsundereLevel(s.tsundere.level);
|
|
508
|
+
const config = readConfig();
|
|
509
|
+
config.tsundere = { ...(config.tsundere || {}), enabled: !!s.tsundere.enabled };
|
|
510
|
+
writeConfig(config);
|
|
511
|
+
}
|
|
512
|
+
if (s.war) {
|
|
513
|
+
setWarEnabled(!!s.war.enabled);
|
|
514
|
+
if (typeof s.war.level === 'number') setWarLevel(s.war.level);
|
|
515
|
+
}
|
|
516
|
+
return log(`▶ loaded preset "${name}"`);
|
|
517
|
+
}
|
|
518
|
+
console.error('usage: preset [list | save <name> | load <name> | delete <name>]');
|
|
519
|
+
process.exit(1);
|
|
520
|
+
},
|
|
521
|
+
|
|
454
522
|
// War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
|
|
455
523
|
// max 危機的. Combined with the tsundere level for the operator's 好感度.
|
|
456
524
|
// war [on|off|toggle|level <0-1>|test|status]
|
|
@@ -1033,6 +1101,7 @@ Usage:
|
|
|
1033
1101
|
ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
|
|
1034
1102
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
1035
1103
|
ai-notify war [on|off|level <0-1>|test|status] war mode (平時⇄戦闘⇄危機; tsundere level = 好感度)
|
|
1104
|
+
ai-notify preset [list|save <name>|load <name>|delete <name>] save/restore volume+tsundere+war+prosody
|
|
1036
1105
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
1037
1106
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
1038
1107
|
ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
|