agent-noti 1.3.0 → 1.4.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.md +61 -10
- package/bin/cli.mjs +254 -1
- package/bin/play.mjs +94 -28
- package/bin/stamp.mjs +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,16 +37,18 @@ Each theme includes a separate idle and input sound.
|
|
|
37
37
|
## Commands
|
|
38
38
|
|
|
39
39
|
```sh
|
|
40
|
-
agent-noti install # Add hooks + pick theme
|
|
40
|
+
agent-noti install # Add hooks + pick theme (i)
|
|
41
41
|
agent-noti uninstall # Remove hooks
|
|
42
|
-
agent-noti test # Play current sounds
|
|
43
|
-
agent-noti sounds # List available themes
|
|
44
|
-
agent-noti pick # Interactive sound picker
|
|
45
|
-
agent-noti add-custom # Use your own sound files
|
|
46
|
-
agent-noti volume <1-10> # Set volume level
|
|
47
|
-
agent-noti mute # Mute notifications
|
|
48
|
-
agent-noti unmute # Unmute notifications
|
|
49
|
-
agent-noti
|
|
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)
|
|
50
52
|
```
|
|
51
53
|
|
|
52
54
|
Every command has a short alias shown in parentheses — e.g. `agent-noti v 5` instead of `agent-noti volume 5`.
|
|
@@ -87,6 +89,46 @@ agent-noti unmute # Re-enable notifications
|
|
|
87
89
|
|
|
88
90
|
Setting volume while muted auto-unmutes. Volume works across all platforms.
|
|
89
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 and time threshold
|
|
106
|
+
- Send a test notification
|
|
107
|
+
|
|
108
|
+
The **threshold** setting (in minutes) skips push notifications for quick tasks. For example, set it to `5` and you'll only get pinged if the agent ran for at least 5 minutes. Set to `0` to always notify.
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
### Quick test
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
agent-noti ntfy-test
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Sends a test notification to your configured topic without opening the interactive TUI.
|
|
119
|
+
|
|
120
|
+
### Config fields
|
|
121
|
+
|
|
122
|
+
| Field | Default | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `ntfy.enabled` | `false` | Master on/off |
|
|
125
|
+
| `ntfy.server` | `https://ntfy.sh` | ntfy server URL |
|
|
126
|
+
| `ntfy.topic` | — | Your topic name (required) |
|
|
127
|
+
| `ntfy.priority` | `default` | `min`, `low`, `default`, `high`, `urgent` |
|
|
128
|
+
| `ntfy.threshold` | `0` | Min minutes before notifying (0 = always) |
|
|
129
|
+
| `ntfy.idle` | `true` | Notify on task complete |
|
|
130
|
+
| `ntfy.input` | `true` | Notify on approval needed |
|
|
131
|
+
|
|
90
132
|
## Config
|
|
91
133
|
|
|
92
134
|
All settings are stored in `~/.agent-noti/config.json`:
|
|
@@ -96,7 +138,16 @@ All settings are stored in `~/.agent-noti/config.json`:
|
|
|
96
138
|
"idle": "cow",
|
|
97
139
|
"input": "cow",
|
|
98
140
|
"volume": 10,
|
|
99
|
-
"muted": false
|
|
141
|
+
"muted": false,
|
|
142
|
+
"ntfy": {
|
|
143
|
+
"enabled": true,
|
|
144
|
+
"server": "https://ntfy.sh",
|
|
145
|
+
"topic": "my-agent-alerts",
|
|
146
|
+
"priority": "default",
|
|
147
|
+
"threshold": 5,
|
|
148
|
+
"idle": true,
|
|
149
|
+
"input": true
|
|
150
|
+
}
|
|
100
151
|
}
|
|
101
152
|
```
|
|
102
153
|
|
package/bin/cli.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { homedir, platform } from "os";
|
|
|
9
9
|
|
|
10
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const PLAY_SCRIPT = join(__dirname, "play.mjs");
|
|
12
|
+
const STAMP_SCRIPT = join(__dirname, "stamp.mjs");
|
|
12
13
|
const CODEX_NOTIFY_SCRIPT = join(__dirname, "codex-notify.mjs");
|
|
13
14
|
const SOUNDS_DIR = join(__dirname, "..", "sounds");
|
|
14
15
|
|
|
@@ -98,9 +99,11 @@ function writeConfig(config) {
|
|
|
98
99
|
function buildClaudeHooks() {
|
|
99
100
|
const idle = { type: "command", command: `node "${PLAY_SCRIPT}" idle` };
|
|
100
101
|
const input = { type: "command", command: `node "${PLAY_SCRIPT}" input` };
|
|
102
|
+
const stamp = { type: "command", command: `node "${STAMP_SCRIPT}"` };
|
|
101
103
|
return {
|
|
102
104
|
Stop: [{ hooks: [idle], metadata: { id: HOOK_ID } }],
|
|
103
105
|
PermissionRequest: [{ hooks: [input], metadata: { id: HOOK_ID } }],
|
|
106
|
+
PromptSubmit: [{ hooks: [stamp], metadata: { id: HOOK_ID } }],
|
|
104
107
|
};
|
|
105
108
|
}
|
|
106
109
|
|
|
@@ -421,6 +424,14 @@ function sounds() {
|
|
|
421
424
|
console.log(" Theme: idle=%s, input=%s", idleLabel, inputLabel);
|
|
422
425
|
const volBar = "#".repeat(vol) + "-".repeat(10 - vol);
|
|
423
426
|
console.log(` Volume: [${volBar}] ${vol}/10${muted ? " (MUTED)" : ""}`);
|
|
427
|
+
|
|
428
|
+
if (config.ntfy && config.ntfy.topic) {
|
|
429
|
+
const n = config.ntfy;
|
|
430
|
+
const server = (n.server || "https://ntfy.sh").replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
431
|
+
const status = n.enabled ? "enabled" : "disabled";
|
|
432
|
+
console.log(` ntfy: ${status} (${server}/${n.topic})`);
|
|
433
|
+
}
|
|
434
|
+
|
|
424
435
|
console.log("");
|
|
425
436
|
}
|
|
426
437
|
|
|
@@ -564,6 +575,244 @@ async function addCustom() {
|
|
|
564
575
|
console.log(" Custom sounds applied.\n");
|
|
565
576
|
}
|
|
566
577
|
|
|
578
|
+
// --- ntfy.sh push notifications ---
|
|
579
|
+
|
|
580
|
+
const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"];
|
|
581
|
+
|
|
582
|
+
function ntfyPrompt(label) {
|
|
583
|
+
return new Promise((resolve) => {
|
|
584
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
585
|
+
rl.question(` ${label}`, (answer) => {
|
|
586
|
+
rl.close();
|
|
587
|
+
resolve(answer.trim());
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function ntfyInitialSetup() {
|
|
593
|
+
console.log("\n First-time ntfy.sh setup\n");
|
|
594
|
+
const topic = await ntfyPrompt("Topic (required): ");
|
|
595
|
+
if (!topic) {
|
|
596
|
+
console.log(" Topic is required. Aborting.\n");
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const server = await ntfyPrompt("Server (enter for https://ntfy.sh): ");
|
|
600
|
+
return { topic, server: server || "https://ntfy.sh" };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function ntfySendTest(ntfyConfig) {
|
|
604
|
+
const server = (ntfyConfig.server || "https://ntfy.sh").replace(/\/+$/, "");
|
|
605
|
+
const url = `${server}/${ntfyConfig.topic}`;
|
|
606
|
+
const res = await fetch(url, {
|
|
607
|
+
method: "POST",
|
|
608
|
+
headers: {
|
|
609
|
+
Title: "Test Notification",
|
|
610
|
+
Priority: ntfyConfig.priority || "default",
|
|
611
|
+
Tags: "bell",
|
|
612
|
+
},
|
|
613
|
+
body: "agent-noti test notification",
|
|
614
|
+
});
|
|
615
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function ntfy() {
|
|
619
|
+
return new Promise(async (resolve) => {
|
|
620
|
+
if (!process.stdin.isTTY) {
|
|
621
|
+
console.log("\n This command requires an interactive terminal.\n");
|
|
622
|
+
resolve();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const config = readConfig();
|
|
627
|
+
|
|
628
|
+
// First-time setup: prompt for topic
|
|
629
|
+
if (!config.ntfy || !config.ntfy.topic) {
|
|
630
|
+
const setup = await ntfyInitialSetup();
|
|
631
|
+
if (!setup) { resolve(); return; }
|
|
632
|
+
config.ntfy = {
|
|
633
|
+
enabled: true,
|
|
634
|
+
server: setup.server,
|
|
635
|
+
topic: setup.topic,
|
|
636
|
+
priority: "default",
|
|
637
|
+
threshold: 0,
|
|
638
|
+
idle: true,
|
|
639
|
+
input: true,
|
|
640
|
+
};
|
|
641
|
+
writeConfig(config);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const ntfyConf = config.ntfy;
|
|
645
|
+
const rows = ["idle", "input"];
|
|
646
|
+
let selected = 0;
|
|
647
|
+
let statusMsg = "";
|
|
648
|
+
const totalLines = 13;
|
|
649
|
+
|
|
650
|
+
function render(firstTime) {
|
|
651
|
+
if (!firstTime) process.stdout.write(`\x1b[${totalLines}A`);
|
|
652
|
+
|
|
653
|
+
const thresh = ntfyConf.threshold ?? 0;
|
|
654
|
+
const threshLabel = thresh === 0 ? "off (always notify)" : `${thresh} min`;
|
|
655
|
+
|
|
656
|
+
process.stdout.write("\x1b[2K\n");
|
|
657
|
+
process.stdout.write(`\x1b[2K \x1b[1mntfy.sh push notifications\x1b[0m\n`);
|
|
658
|
+
process.stdout.write("\x1b[2K\n");
|
|
659
|
+
process.stdout.write(`\x1b[2K Server: \x1b[36m${ntfyConf.server || "https://ntfy.sh"}\x1b[0m\n`);
|
|
660
|
+
process.stdout.write(`\x1b[2K Topic: \x1b[36m${ntfyConf.topic}\x1b[0m\n`);
|
|
661
|
+
process.stdout.write(`\x1b[2K Priority: \x1b[36m${ntfyConf.priority || "default"}\x1b[0m\n`);
|
|
662
|
+
process.stdout.write(`\x1b[2K Threshold: \x1b[36m${threshLabel}\x1b[0m\n`);
|
|
663
|
+
process.stdout.write("\x1b[2K\n");
|
|
664
|
+
|
|
665
|
+
for (let i = 0; i < rows.length; i++) {
|
|
666
|
+
const checked = ntfyConf[rows[i]] ? "x" : " ";
|
|
667
|
+
const label = rows[i] === "idle" ? "Notify on task complete (idle)" : "Notify on approval needed (input)";
|
|
668
|
+
const arrow = i === selected ? "\x1b[36m> " : " ";
|
|
669
|
+
const color = i === selected ? "\x1b[36m" : "\x1b[0m";
|
|
670
|
+
process.stdout.write(`\x1b[2K ${arrow}${color}[${checked}] ${label}\x1b[0m\n`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
process.stdout.write("\x1b[2K\n");
|
|
674
|
+
const status = statusMsg ? ` ${statusMsg}` : "";
|
|
675
|
+
process.stdout.write(`\x1b[2K \x1b[90m[space] Toggle [up/down] Navigate [e] Edit [t] Test [q] Save & quit\x1b[0m${status}\n`);
|
|
676
|
+
process.stdout.write(`\x1b[2K\n`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const stdin = process.stdin;
|
|
680
|
+
stdin.setRawMode(true);
|
|
681
|
+
stdin.resume();
|
|
682
|
+
stdin.setEncoding("utf8");
|
|
683
|
+
|
|
684
|
+
render(true);
|
|
685
|
+
|
|
686
|
+
function cleanup() {
|
|
687
|
+
stdin.removeListener("data", onKey);
|
|
688
|
+
stdin.setRawMode(false);
|
|
689
|
+
stdin.pause();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function save() {
|
|
693
|
+
ntfyConf.enabled = ntfyConf.idle || ntfyConf.input;
|
|
694
|
+
config.ntfy = ntfyConf;
|
|
695
|
+
writeConfig(config);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function editFields() {
|
|
699
|
+
cleanup();
|
|
700
|
+
console.log("");
|
|
701
|
+
|
|
702
|
+
const newServer = await ntfyPrompt(`Server (${ntfyConf.server || "https://ntfy.sh"}): `);
|
|
703
|
+
if (newServer) ntfyConf.server = newServer;
|
|
704
|
+
|
|
705
|
+
const newTopic = await ntfyPrompt(`Topic (${ntfyConf.topic}): `);
|
|
706
|
+
if (newTopic) ntfyConf.topic = newTopic;
|
|
707
|
+
|
|
708
|
+
const priIdx = NTFY_PRIORITIES.indexOf(ntfyConf.priority || "default");
|
|
709
|
+
const newPri = await ntfyPrompt(`Priority [${NTFY_PRIORITIES.join("/")}] (${NTFY_PRIORITIES[priIdx]}): `);
|
|
710
|
+
if (newPri && NTFY_PRIORITIES.includes(newPri)) ntfyConf.priority = newPri;
|
|
711
|
+
|
|
712
|
+
const curThresh = ntfyConf.threshold ?? 0;
|
|
713
|
+
const newThresh = await ntfyPrompt(`Threshold in minutes, 0=off (${curThresh}): `);
|
|
714
|
+
if (newThresh !== "") {
|
|
715
|
+
const val = parseInt(newThresh, 10);
|
|
716
|
+
if (!isNaN(val) && val >= 0) ntfyConf.threshold = val;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Re-enter raw mode and re-render
|
|
720
|
+
stdin.setRawMode(true);
|
|
721
|
+
stdin.resume();
|
|
722
|
+
stdin.setEncoding("utf8");
|
|
723
|
+
stdin.on("data", onKey);
|
|
724
|
+
render(true);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function testNotification() {
|
|
728
|
+
cleanup();
|
|
729
|
+
statusMsg = "\x1b[33mSending...\x1b[0m";
|
|
730
|
+
// Re-enter raw mode to render
|
|
731
|
+
stdin.setRawMode(true);
|
|
732
|
+
stdin.resume();
|
|
733
|
+
stdin.setEncoding("utf8");
|
|
734
|
+
render(true);
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
await ntfySendTest(ntfyConf);
|
|
738
|
+
statusMsg = "\x1b[32mSent!\x1b[0m";
|
|
739
|
+
} catch (e) {
|
|
740
|
+
statusMsg = `\x1b[31mFailed: ${e.message}\x1b[0m`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
stdin.on("data", onKey);
|
|
744
|
+
render();
|
|
745
|
+
setTimeout(() => { statusMsg = ""; render(); }, 3000);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function onKey(key) {
|
|
749
|
+
if (key === "\x03") {
|
|
750
|
+
cleanup();
|
|
751
|
+
save();
|
|
752
|
+
console.log("");
|
|
753
|
+
process.exit(0);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (key === "q" || key === "Q" || key === "\r" || key === "\n") {
|
|
757
|
+
cleanup();
|
|
758
|
+
save();
|
|
759
|
+
console.log(`\n ntfy config saved (${ntfyConf.enabled ? "enabled" : "disabled"}).\n`);
|
|
760
|
+
resolve();
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (key === " ") {
|
|
765
|
+
ntfyConf[rows[selected]] = !ntfyConf[rows[selected]];
|
|
766
|
+
render();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (key === "\x1b[A" || key === "k") {
|
|
771
|
+
selected = (selected - 1 + rows.length) % rows.length;
|
|
772
|
+
render();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (key === "\x1b[B" || key === "j") {
|
|
777
|
+
selected = (selected + 1) % rows.length;
|
|
778
|
+
render();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (key === "e" || key === "E") {
|
|
783
|
+
await editFields();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (key === "t" || key === "T") {
|
|
788
|
+
stdin.removeListener("data", onKey);
|
|
789
|
+
await testNotification();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
stdin.on("data", onKey);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function ntfyTest() {
|
|
799
|
+
const config = readConfig();
|
|
800
|
+
const ntfyConf = config.ntfy;
|
|
801
|
+
|
|
802
|
+
if (!ntfyConf || !ntfyConf.topic) {
|
|
803
|
+
console.log("\n ntfy not configured. Run 'agent-noti ntfy' first.\n");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
process.stdout.write("\n Sending test notification...");
|
|
808
|
+
try {
|
|
809
|
+
await ntfySendTest(ntfyConf);
|
|
810
|
+
console.log(" sent!\n");
|
|
811
|
+
} catch (e) {
|
|
812
|
+
console.log(` failed: ${e.message}\n`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
567
816
|
function mute() {
|
|
568
817
|
const config = readConfig();
|
|
569
818
|
config.muted = true;
|
|
@@ -604,7 +853,7 @@ function volume(args) {
|
|
|
604
853
|
|
|
605
854
|
function reset() {
|
|
606
855
|
writeConfig({ idle: "default", input: "default", volume: 10, muted: false });
|
|
607
|
-
console.log("\n Reset to defaults (theme=default, volume=10, unmuted).\n");
|
|
856
|
+
console.log("\n Reset to defaults (theme=default, volume=10, unmuted, ntfy cleared).\n");
|
|
608
857
|
}
|
|
609
858
|
|
|
610
859
|
function applyPickerChoice(choice) {
|
|
@@ -651,6 +900,8 @@ async function main() {
|
|
|
651
900
|
case "mute": case "m": mute(); break;
|
|
652
901
|
case "unmute": case "u": unmute(); break;
|
|
653
902
|
case "reset": case "r": reset(); break;
|
|
903
|
+
case "ntfy": case "n": await ntfy(); break;
|
|
904
|
+
case "ntfy-test": case "nt": await ntfyTest(); break;
|
|
654
905
|
default:
|
|
655
906
|
console.log("");
|
|
656
907
|
console.log(" agent-noti install (i) Add hooks + pick theme");
|
|
@@ -662,6 +913,8 @@ async function main() {
|
|
|
662
913
|
console.log(" agent-noti volume (v) Set volume <1-10>");
|
|
663
914
|
console.log(" agent-noti mute (m) Mute notifications");
|
|
664
915
|
console.log(" agent-noti unmute (u) Unmute notifications");
|
|
916
|
+
console.log(" agent-noti ntfy (n) Configure ntfy.sh push notifications");
|
|
917
|
+
console.log(" agent-noti ntfy-test (nt) Send a test push notification");
|
|
665
918
|
console.log(" agent-noti reset (r) Reset everything");
|
|
666
919
|
console.log("");
|
|
667
920
|
}
|
package/bin/play.mjs
CHANGED
|
@@ -6,17 +6,21 @@
|
|
|
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";
|
|
12
13
|
import { join, dirname } from "path";
|
|
13
14
|
import { fileURLToPath } from "url";
|
|
14
15
|
import { platform, homedir } from "os";
|
|
15
|
-
import { existsSync, readFileSync } from "fs";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
16
17
|
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const SOUNDS_DIR = join(__dirname, "..", "sounds");
|
|
19
|
-
const
|
|
20
|
+
const CONFIG_DIR = join(homedir(), ".agent-noti");
|
|
21
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
22
|
+
const LAST_PROMPT_PATH = join(CONFIG_DIR, "last-prompt");
|
|
23
|
+
const LAST_EVENT_PATH = join(CONFIG_DIR, "last-event");
|
|
20
24
|
|
|
21
25
|
const EVENTS = ["idle", "input"];
|
|
22
26
|
|
|
@@ -69,36 +73,98 @@ function resolveSound(arg) {
|
|
|
69
73
|
return findFile(`${arg}-idle`) || findFile(arg) || null;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
const
|
|
73
|
-
|
|
76
|
+
const NTFY_MESSAGES = {
|
|
77
|
+
idle: { title: "Task Complete", tags: "white_check_mark", body: "Agent finished task" },
|
|
78
|
+
input: { title: "Approval Needed", tags: "warning", body: "Agent needs your approval" },
|
|
79
|
+
};
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
async function sendNtfy(event, config) {
|
|
82
|
+
try {
|
|
83
|
+
const ntfy = config.ntfy;
|
|
84
|
+
if (!ntfy || !ntfy.enabled || !ntfy.topic) return;
|
|
85
|
+
if (!ntfy[event]) return;
|
|
86
|
+
|
|
87
|
+
const msg = NTFY_MESSAGES[event];
|
|
88
|
+
if (!msg) return;
|
|
89
|
+
|
|
90
|
+
const server = (ntfy.server || "https://ntfy.sh").replace(/\/+$/, "");
|
|
91
|
+
const url = `${server}/${ntfy.topic}`;
|
|
92
|
+
|
|
93
|
+
await fetch(url, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: {
|
|
96
|
+
Title: msg.title,
|
|
97
|
+
Priority: ntfy.priority || "default",
|
|
98
|
+
Tags: msg.tags,
|
|
99
|
+
},
|
|
100
|
+
body: msg.body,
|
|
101
|
+
});
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
(async () => {
|
|
106
|
+
const arg = process.argv[2];
|
|
107
|
+
if (!arg) process.exit(0);
|
|
76
108
|
|
|
77
|
-
|
|
78
|
-
if (arg !== "--file" && config.muted) process.exit(0);
|
|
109
|
+
const config = readConfig();
|
|
79
110
|
|
|
80
|
-
|
|
81
|
-
if (
|
|
111
|
+
// Send ntfy push notification for actual events (not --file previews)
|
|
112
|
+
if (EVENTS.includes(arg)) {
|
|
113
|
+
const threshold = config.ntfy?.threshold ?? 0; // minutes, 0 = always
|
|
114
|
+
let shouldSend = true;
|
|
115
|
+
|
|
116
|
+
if (threshold > 0) {
|
|
117
|
+
// Claude Code: PromptSubmit hook writes last-prompt (exact task start)
|
|
118
|
+
// Codex fallback: use last-event (time since previous event)
|
|
119
|
+
const tsFile = existsSync(LAST_PROMPT_PATH) ? LAST_PROMPT_PATH : LAST_EVENT_PATH;
|
|
120
|
+
try {
|
|
121
|
+
if (existsSync(tsFile)) {
|
|
122
|
+
const started = parseInt(readFileSync(tsFile, "utf-8"), 10);
|
|
123
|
+
if (!isNaN(started)) {
|
|
124
|
+
const elapsed = (Date.now() - started) / 60000;
|
|
125
|
+
shouldSend = elapsed >= threshold;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
82
130
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
|
|
87
|
-
const volPulse = Math.round(volFloat * 65536); // paplay scale
|
|
131
|
+
if (shouldSend) {
|
|
132
|
+
await sendNtfy(arg, config);
|
|
133
|
+
}
|
|
88
134
|
|
|
89
|
-
|
|
135
|
+
// Write last-event timestamp (Codex fallback for threshold)
|
|
136
|
+
try {
|
|
137
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
138
|
+
writeFileSync(LAST_EVENT_PATH, String(Date.now()));
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
90
141
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
142
|
+
// Mute check (skip for --file, which is used by picker previews)
|
|
143
|
+
if (arg !== "--file" && config.muted) process.exit(0);
|
|
144
|
+
|
|
145
|
+
const file = resolveSound(arg);
|
|
146
|
+
if (!file) process.exit(1);
|
|
147
|
+
|
|
148
|
+
// Volume: 1-10 config → 0.0-1.0 native scale
|
|
149
|
+
const vol = Math.max(1, Math.min(10, config.volume ?? 10));
|
|
150
|
+
const volFloat = vol / 10; // 0.1 – 1.0 (macOS, Windows)
|
|
151
|
+
const volPct = vol * 10; // 10 – 100 (Linux ffplay, mpv)
|
|
152
|
+
const volPulse = Math.round(volFloat * 65536); // paplay scale
|
|
153
|
+
|
|
154
|
+
const os = platform();
|
|
155
|
+
|
|
156
|
+
if (os === "darwin") {
|
|
157
|
+
execFile("afplay", ["-v", String(volFloat), file], () => {});
|
|
158
|
+
} else if (os === "win32") {
|
|
159
|
+
exec(
|
|
160
|
+
`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"`,
|
|
161
|
+
() => {}
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
execFile("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(volPct), file], (err) => {
|
|
165
|
+
if (err) execFile("paplay", ["--volume", String(volPulse), file], (err2) => {
|
|
166
|
+
if (err2) execFile("mpv", ["--no-video", `--volume=${volPct}`, file], () => {});
|
|
167
|
+
});
|
|
102
168
|
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
169
|
+
}
|
|
170
|
+
})();
|
package/bin/stamp.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Records the current timestamp to ~/.agent-noti/last-prompt.
|
|
4
|
+
* Called by the PromptSubmit hook so play.mjs can measure task duration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
|
|
11
|
+
const dir = join(homedir(), ".agent-noti");
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
writeFileSync(join(dir, "last-prompt"), String(Date.now()));
|