agent-noti 1.2.0 → 1.4.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.
Files changed (4) hide show
  1. package/README.md +127 -11
  2. package/bin/cli.mjs +239 -1
  3. package/bin/play.mjs +64 -27
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -10,21 +10,141 @@ Works on macOS, Linux, and Windows.
10
10
  npm i -g agent-noti
11
11
  ```
12
12
 
13
- That's it. Hooks are added to both Claude Code and Codex automatically. Restart your agent.
13
+ That's it. Hooks are added automatically and the interactive sound picker launches so you can choose a theme. Restart your agent.
14
14
 
15
15
  ## What it does
16
16
 
17
17
  | Event | Sound | Claude Code | Codex |
18
18
  |---|---|---|---|
19
- | Agent finished | `idle.mp3` | Stop | agent-turn-complete |
20
- | Needs your input | `input.mp3` | PermissionRequest | approval-requested |
19
+ | Agent finished | idle sound | Stop | agent-turn-complete |
20
+ | Needs your input | input sound | PermissionRequest | approval-requested |
21
+
22
+ ## Sound themes
23
+
24
+ Each theme includes a separate idle and input sound.
25
+
26
+ | Theme | Description |
27
+ |---|---|
28
+ | default | Original notification |
29
+ | cow | Moo! |
30
+ | goose | Honk! |
31
+ | duck | Quack quack |
32
+ | car | Vroom vroom |
33
+ | slide-whistle | Wheee! |
34
+ | video-game | Retro gaming |
35
+ | digital-glass | Sleek & modern |
21
36
 
22
37
  ## Commands
23
38
 
24
39
  ```sh
25
- agent-noti test # Play both sounds
26
- agent-noti install # Re-add hooks (if needed)
27
- agent-noti uninstall # Remove hooks
40
+ agent-noti install # Add hooks + pick theme (i)
41
+ agent-noti uninstall # Remove hooks
42
+ agent-noti test # Play current sounds (t)
43
+ agent-noti sounds # List available themes (s)
44
+ agent-noti pick # Interactive sound picker (p)
45
+ agent-noti add-custom # Use your own sound files (ac)
46
+ agent-noti volume <1-10> # Set volume level (v)
47
+ agent-noti mute # Mute notifications (m)
48
+ agent-noti unmute # Unmute notifications (u)
49
+ agent-noti ntfy # Configure ntfy.sh push alerts (n)
50
+ agent-noti ntfy-test # Send a test push notification (nt)
51
+ agent-noti reset # Reset everything (r)
52
+ ```
53
+
54
+ Every command has a short alias shown in parentheses — e.g. `agent-noti v 5` instead of `agent-noti volume 5`.
55
+
56
+ ## Interactive picker
57
+
58
+ ```
59
+ agent-noti pick
60
+ ```
61
+
62
+ Navigate with arrow keys, preview sounds before selecting:
63
+
64
+ - **Up / Down** — navigate themes
65
+ - **Left** — play idle sound
66
+ - **Right** — play input sound
67
+ - **Enter** — select theme
68
+ - **q** — quit
69
+
70
+ The picker also includes **+ Add custom** at the bottom, which walks you through importing your own sound files. Once added, your custom sounds appear in the picker below default.
71
+
72
+ ## Custom sounds
73
+
74
+ Run `agent-noti add-custom` (or select **+ Add custom** in the picker) for an interactive flow:
75
+
76
+ 1. Choose idle sound — enter a file path or skip (use default)
77
+ 2. Choose input sound — enter a file path, use same as idle, or skip
78
+
79
+ Custom files are copied to `~/.agent-noti/sounds/` so they persist across package updates.
80
+
81
+ ## Volume & mute
82
+
83
+ ```sh
84
+ agent-noti volume 5 # Set volume 1-10
85
+ agent-noti volume # Show current volume
86
+ agent-noti mute # Silence all notifications
87
+ agent-noti unmute # Re-enable notifications
88
+ ```
89
+
90
+ Setting volume while muted auto-unmutes. Volume works across all platforms.
91
+
92
+ ## Push notifications (ntfy.sh)
93
+
94
+ Get push notifications on your phone or desktop via [ntfy.sh](https://ntfy.sh) — even when you're away from the terminal. ntfy works independently from audio: you can mute sounds and still receive push alerts.
95
+
96
+ ### Setup
97
+
98
+ ```sh
99
+ agent-noti ntfy
100
+ ```
101
+
102
+ This opens an interactive TUI where you can:
103
+ - Set your ntfy server and topic
104
+ - Toggle which events send push notifications (task complete / approval needed)
105
+ - Set priority level
106
+ - Send a test notification
107
+
108
+ On first run you'll be prompted for a topic name. Subscribe to the same topic in the [ntfy app](https://ntfy.sh) (Android/iOS/web) to receive notifications.
109
+
110
+ ### Quick test
111
+
112
+ ```sh
113
+ agent-noti ntfy-test
114
+ ```
115
+
116
+ Sends a test notification to your configured topic without opening the interactive TUI.
117
+
118
+ ### Config fields
119
+
120
+ | Field | Default | Description |
121
+ |---|---|---|
122
+ | `ntfy.enabled` | `false` | Master on/off |
123
+ | `ntfy.server` | `https://ntfy.sh` | ntfy server URL |
124
+ | `ntfy.topic` | — | Your topic name (required) |
125
+ | `ntfy.priority` | `default` | `min`, `low`, `default`, `high`, `urgent` |
126
+ | `ntfy.idle` | `true` | Notify on task complete |
127
+ | `ntfy.input` | `true` | Notify on approval needed |
128
+
129
+ ## Config
130
+
131
+ All settings are stored in `~/.agent-noti/config.json`:
132
+
133
+ ```json
134
+ {
135
+ "idle": "cow",
136
+ "input": "cow",
137
+ "volume": 10,
138
+ "muted": false,
139
+ "ntfy": {
140
+ "enabled": true,
141
+ "server": "https://ntfy.sh",
142
+ "topic": "my-agent-alerts",
143
+ "priority": "default",
144
+ "idle": true,
145
+ "input": true
146
+ }
147
+ }
28
148
  ```
29
149
 
30
150
  ## Uninstall
@@ -35,16 +155,12 @@ npm uninstall -g agent-noti
35
155
 
36
156
  Hooks are removed automatically.
37
157
 
38
- ## Custom sounds
39
-
40
- Replace `sounds/idle.mp3` and `sounds/input.mp3` in the package directory with your own files.
41
-
42
158
  ## Platform support
43
159
 
44
160
  | OS | Audio player |
45
161
  |---|---|
46
162
  | macOS | `afplay` (built-in) |
47
- | Linux | `ffplay`, `paplay`, or `mpv` |
163
+ | Linux | `ffplay`, `paplay`, or `mpv` (tries in order) |
48
164
  | Windows | PowerShell MediaPlayer |
49
165
 
50
166
  ## License
package/bin/cli.mjs CHANGED
@@ -421,6 +421,14 @@ function sounds() {
421
421
  console.log(" Theme: idle=%s, input=%s", idleLabel, inputLabel);
422
422
  const volBar = "#".repeat(vol) + "-".repeat(10 - vol);
423
423
  console.log(` Volume: [${volBar}] ${vol}/10${muted ? " (MUTED)" : ""}`);
424
+
425
+ if (config.ntfy && config.ntfy.topic) {
426
+ const n = config.ntfy;
427
+ const server = (n.server || "https://ntfy.sh").replace(/^https?:\/\//, "").replace(/\/+$/, "");
428
+ const status = n.enabled ? "enabled" : "disabled";
429
+ console.log(` ntfy: ${status} (${server}/${n.topic})`);
430
+ }
431
+
424
432
  console.log("");
425
433
  }
426
434
 
@@ -564,6 +572,232 @@ async function addCustom() {
564
572
  console.log(" Custom sounds applied.\n");
565
573
  }
566
574
 
575
+ // --- ntfy.sh push notifications ---
576
+
577
+ const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"];
578
+
579
+ function ntfyPrompt(label) {
580
+ return new Promise((resolve) => {
581
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
582
+ rl.question(` ${label}`, (answer) => {
583
+ rl.close();
584
+ resolve(answer.trim());
585
+ });
586
+ });
587
+ }
588
+
589
+ async function ntfyInitialSetup() {
590
+ console.log("\n First-time ntfy.sh setup\n");
591
+ const topic = await ntfyPrompt("Topic (required): ");
592
+ if (!topic) {
593
+ console.log(" Topic is required. Aborting.\n");
594
+ return null;
595
+ }
596
+ const server = await ntfyPrompt("Server (enter for https://ntfy.sh): ");
597
+ return { topic, server: server || "https://ntfy.sh" };
598
+ }
599
+
600
+ async function ntfySendTest(ntfyConfig) {
601
+ const server = (ntfyConfig.server || "https://ntfy.sh").replace(/\/+$/, "");
602
+ const url = `${server}/${ntfyConfig.topic}`;
603
+ const res = await fetch(url, {
604
+ method: "POST",
605
+ headers: {
606
+ Title: "Test Notification",
607
+ Priority: ntfyConfig.priority || "default",
608
+ Tags: "bell",
609
+ },
610
+ body: "agent-noti test notification",
611
+ });
612
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
613
+ }
614
+
615
+ function ntfy() {
616
+ return new Promise(async (resolve) => {
617
+ if (!process.stdin.isTTY) {
618
+ console.log("\n This command requires an interactive terminal.\n");
619
+ resolve();
620
+ return;
621
+ }
622
+
623
+ const config = readConfig();
624
+
625
+ // First-time setup: prompt for topic
626
+ if (!config.ntfy || !config.ntfy.topic) {
627
+ const setup = await ntfyInitialSetup();
628
+ if (!setup) { resolve(); return; }
629
+ config.ntfy = {
630
+ enabled: true,
631
+ server: setup.server,
632
+ topic: setup.topic,
633
+ priority: "default",
634
+ idle: true,
635
+ input: true,
636
+ };
637
+ writeConfig(config);
638
+ }
639
+
640
+ const ntfyConf = config.ntfy;
641
+ const rows = ["idle", "input"];
642
+ let selected = 0;
643
+ let statusMsg = "";
644
+ const totalLines = 12;
645
+
646
+ function render(firstTime) {
647
+ if (!firstTime) process.stdout.write(`\x1b[${totalLines}A`);
648
+
649
+ process.stdout.write("\x1b[2K\n");
650
+ process.stdout.write(`\x1b[2K \x1b[1mntfy.sh push notifications\x1b[0m\n`);
651
+ process.stdout.write("\x1b[2K\n");
652
+ process.stdout.write(`\x1b[2K Server: \x1b[36m${ntfyConf.server || "https://ntfy.sh"}\x1b[0m\n`);
653
+ process.stdout.write(`\x1b[2K Topic: \x1b[36m${ntfyConf.topic}\x1b[0m\n`);
654
+ process.stdout.write(`\x1b[2K Priority: \x1b[36m${ntfyConf.priority || "default"}\x1b[0m\n`);
655
+ process.stdout.write("\x1b[2K\n");
656
+
657
+ for (let i = 0; i < rows.length; i++) {
658
+ const checked = ntfyConf[rows[i]] ? "x" : " ";
659
+ const label = rows[i] === "idle" ? "Notify on task complete (idle)" : "Notify on approval needed (input)";
660
+ const arrow = i === selected ? "\x1b[36m> " : " ";
661
+ const color = i === selected ? "\x1b[36m" : "\x1b[0m";
662
+ process.stdout.write(`\x1b[2K ${arrow}${color}[${checked}] ${label}\x1b[0m\n`);
663
+ }
664
+
665
+ process.stdout.write("\x1b[2K\n");
666
+ const status = statusMsg ? ` ${statusMsg}` : "";
667
+ process.stdout.write(`\x1b[2K \x1b[90m[space] Toggle [up/down] Navigate [e] Edit [t] Test [q] Save & quit\x1b[0m${status}\n`);
668
+ process.stdout.write(`\x1b[2K\n`);
669
+ }
670
+
671
+ const stdin = process.stdin;
672
+ stdin.setRawMode(true);
673
+ stdin.resume();
674
+ stdin.setEncoding("utf8");
675
+
676
+ render(true);
677
+
678
+ function cleanup() {
679
+ stdin.removeListener("data", onKey);
680
+ stdin.setRawMode(false);
681
+ stdin.pause();
682
+ }
683
+
684
+ function save() {
685
+ ntfyConf.enabled = ntfyConf.idle || ntfyConf.input;
686
+ config.ntfy = ntfyConf;
687
+ writeConfig(config);
688
+ }
689
+
690
+ async function editFields() {
691
+ cleanup();
692
+ console.log("");
693
+
694
+ const newServer = await ntfyPrompt(`Server (${ntfyConf.server || "https://ntfy.sh"}): `);
695
+ if (newServer) ntfyConf.server = newServer;
696
+
697
+ const newTopic = await ntfyPrompt(`Topic (${ntfyConf.topic}): `);
698
+ if (newTopic) ntfyConf.topic = newTopic;
699
+
700
+ const priIdx = NTFY_PRIORITIES.indexOf(ntfyConf.priority || "default");
701
+ const newPri = await ntfyPrompt(`Priority [${NTFY_PRIORITIES.join("/")}] (${NTFY_PRIORITIES[priIdx]}): `);
702
+ if (newPri && NTFY_PRIORITIES.includes(newPri)) ntfyConf.priority = newPri;
703
+
704
+ // Re-enter raw mode and re-render
705
+ stdin.setRawMode(true);
706
+ stdin.resume();
707
+ stdin.setEncoding("utf8");
708
+ stdin.on("data", onKey);
709
+ render(true);
710
+ }
711
+
712
+ async function testNotification() {
713
+ cleanup();
714
+ statusMsg = "\x1b[33mSending...\x1b[0m";
715
+ // Re-enter raw mode to render
716
+ stdin.setRawMode(true);
717
+ stdin.resume();
718
+ stdin.setEncoding("utf8");
719
+ render(true);
720
+
721
+ try {
722
+ await ntfySendTest(ntfyConf);
723
+ statusMsg = "\x1b[32mSent!\x1b[0m";
724
+ } catch (e) {
725
+ statusMsg = `\x1b[31mFailed: ${e.message}\x1b[0m`;
726
+ }
727
+
728
+ stdin.on("data", onKey);
729
+ render();
730
+ setTimeout(() => { statusMsg = ""; render(); }, 3000);
731
+ }
732
+
733
+ async function onKey(key) {
734
+ if (key === "\x03") {
735
+ cleanup();
736
+ save();
737
+ console.log("");
738
+ process.exit(0);
739
+ }
740
+
741
+ if (key === "q" || key === "Q" || key === "\r" || key === "\n") {
742
+ cleanup();
743
+ save();
744
+ console.log(`\n ntfy config saved (${ntfyConf.enabled ? "enabled" : "disabled"}).\n`);
745
+ resolve();
746
+ return;
747
+ }
748
+
749
+ if (key === " ") {
750
+ ntfyConf[rows[selected]] = !ntfyConf[rows[selected]];
751
+ render();
752
+ return;
753
+ }
754
+
755
+ if (key === "\x1b[A" || key === "k") {
756
+ selected = (selected - 1 + rows.length) % rows.length;
757
+ render();
758
+ return;
759
+ }
760
+
761
+ if (key === "\x1b[B" || key === "j") {
762
+ selected = (selected + 1) % rows.length;
763
+ render();
764
+ return;
765
+ }
766
+
767
+ if (key === "e" || key === "E") {
768
+ await editFields();
769
+ return;
770
+ }
771
+
772
+ if (key === "t" || key === "T") {
773
+ stdin.removeListener("data", onKey);
774
+ await testNotification();
775
+ return;
776
+ }
777
+ }
778
+
779
+ stdin.on("data", onKey);
780
+ });
781
+ }
782
+
783
+ async function ntfyTest() {
784
+ const config = readConfig();
785
+ const ntfyConf = config.ntfy;
786
+
787
+ if (!ntfyConf || !ntfyConf.topic) {
788
+ console.log("\n ntfy not configured. Run 'agent-noti ntfy' first.\n");
789
+ return;
790
+ }
791
+
792
+ process.stdout.write("\n Sending test notification...");
793
+ try {
794
+ await ntfySendTest(ntfyConf);
795
+ console.log(" sent!\n");
796
+ } catch (e) {
797
+ console.log(` failed: ${e.message}\n`);
798
+ }
799
+ }
800
+
567
801
  function mute() {
568
802
  const config = readConfig();
569
803
  config.muted = true;
@@ -604,7 +838,7 @@ function volume(args) {
604
838
 
605
839
  function reset() {
606
840
  writeConfig({ idle: "default", input: "default", volume: 10, muted: false });
607
- console.log("\n Reset to defaults (theme=default, volume=10, unmuted).\n");
841
+ console.log("\n Reset to defaults (theme=default, volume=10, unmuted, ntfy cleared).\n");
608
842
  }
609
843
 
610
844
  function applyPickerChoice(choice) {
@@ -651,6 +885,8 @@ async function main() {
651
885
  case "mute": case "m": mute(); break;
652
886
  case "unmute": case "u": unmute(); break;
653
887
  case "reset": case "r": reset(); break;
888
+ case "ntfy": case "n": await ntfy(); break;
889
+ case "ntfy-test": case "nt": await ntfyTest(); break;
654
890
  default:
655
891
  console.log("");
656
892
  console.log(" agent-noti install (i) Add hooks + pick theme");
@@ -662,6 +898,8 @@ async function main() {
662
898
  console.log(" agent-noti volume (v) Set volume <1-10>");
663
899
  console.log(" agent-noti mute (m) Mute notifications");
664
900
  console.log(" agent-noti unmute (u) Unmute notifications");
901
+ console.log(" agent-noti ntfy (n) Configure ntfy.sh push notifications");
902
+ console.log(" agent-noti ntfy-test (nt) Send a test push notification");
665
903
  console.log(" agent-noti reset (r) Reset everything");
666
904
  console.log("");
667
905
  }
package/bin/play.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  * node play.mjs --file <path> — plays a file directly
7
7
  *
8
8
  * Respects ~/.agent-noti/config.json for mute and volume (1-10).
9
+ * Also sends ntfy.sh push notifications when configured.
9
10
  */
10
11
 
11
12
  import { execFile, exec } from "child_process";
@@ -69,36 +70,72 @@ function resolveSound(arg) {
69
70
  return findFile(`${arg}-idle`) || findFile(arg) || null;
70
71
  }
71
72
 
72
- const arg = process.argv[2];
73
- if (!arg) process.exit(0);
73
+ const NTFY_MESSAGES = {
74
+ idle: { title: "Task Complete", tags: "white_check_mark", body: "Agent finished task" },
75
+ input: { title: "Approval Needed", tags: "warning", body: "Agent needs your approval" },
76
+ };
74
77
 
75
- const config = readConfig();
76
-
77
- // Mute check (skip for --file, which is used by picker previews)
78
- if (arg !== "--file" && config.muted) process.exit(0);
78
+ async function sendNtfy(event, config) {
79
+ try {
80
+ const ntfy = config.ntfy;
81
+ if (!ntfy || !ntfy.enabled || !ntfy.topic) return;
82
+ if (!ntfy[event]) return;
83
+
84
+ const msg = NTFY_MESSAGES[event];
85
+ if (!msg) return;
86
+
87
+ const server = (ntfy.server || "https://ntfy.sh").replace(/\/+$/, "");
88
+ const url = `${server}/${ntfy.topic}`;
89
+
90
+ await fetch(url, {
91
+ method: "POST",
92
+ headers: {
93
+ Title: msg.title,
94
+ Priority: ntfy.priority || "default",
95
+ Tags: msg.tags,
96
+ },
97
+ body: msg.body,
98
+ });
99
+ } catch {}
100
+ }
79
101
 
80
- const file = resolveSound(arg);
81
- if (!file) process.exit(1);
102
+ (async () => {
103
+ const arg = process.argv[2];
104
+ if (!arg) process.exit(0);
82
105
 
83
- // Volume: 1-10 config 0.0-1.0 native scale
84
- const vol = Math.max(1, Math.min(10, config.volume ?? 10));
85
- const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
86
- const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
87
- const volPulse = Math.round(volFloat * 65536); // paplay scale
106
+ const config = readConfig();
88
107
 
89
- const os = platform();
108
+ // Send ntfy push notification for actual events (not --file previews)
109
+ if (EVENTS.includes(arg)) {
110
+ await sendNtfy(arg, config);
111
+ }
90
112
 
91
- if (os === "darwin") {
92
- execFile("afplay", ["-v", String(volFloat), file], () => {});
93
- } else if (os === "win32") {
94
- exec(
95
- `powershell -NoProfile -Command "Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volFloat}; $p.Play(); Start-Sleep -Seconds 3"`,
96
- () => {}
97
- );
98
- } else {
99
- execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
100
- if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
101
- if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
113
+ // Mute check (skip for --file, which is used by picker previews)
114
+ if (arg !== "--file" && config.muted) process.exit(0);
115
+
116
+ const file = resolveSound(arg);
117
+ if (!file) process.exit(1);
118
+
119
+ // Volume: 1-10 config → 0.0-1.0 native scale
120
+ const vol = Math.max(1, Math.min(10, config.volume ?? 10));
121
+ const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
122
+ const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
123
+ const volPulse = Math.round(volFloat * 65536); // paplay scale
124
+
125
+ const os = platform();
126
+
127
+ if (os === "darwin") {
128
+ execFile("afplay", ["-v", String(volFloat), file], () => {});
129
+ } else if (os === "win32") {
130
+ exec(
131
+ `powershell -NoProfile -Command "Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volFloat}; $p.Play(); Start-Sleep -Seconds 3"`,
132
+ () => {}
133
+ );
134
+ } else {
135
+ execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
136
+ if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
137
+ if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
138
+ });
102
139
  });
103
- });
104
- }
140
+ }
141
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-noti",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Audio notifications for Claude Code & Codex — customizable sound themes",
5
5
  "bin": {
6
6
  "agent-noti": "./bin/cli.mjs"